ability to cancel construction and have a portion of the funds returned
[browser-rts.git] / src / repository / city.ts
1 import { v4 as uuid } from 'uuid';
2 import { ERROR_CODE, InsufficientResourceError, NotFoundError } from '../errors';
3 import {Repository} from './base';
4 import * as config from '../config';
5 import { BuildQueue, BuildQueueRepository } from './build-queue';
6 import { DateTime, Duration } from 'luxon';
7 import { UnitTrainingQueue, UnitTrainingQueueRepository, UnitTrainingQueueWithName } from './training-queue';
8 import { coalesce, pluck } from '../lib/util';
9 import { Building, BuildingRepository } from './buildings';
10 import { Unit, UnitRepository } from './unit';
11 import _, { random } from 'lodash';
12 import { Army, ArmyQueue, ArmyRepository } from './army';
13
14 export type City = {
15         id: string;
16     owner: string;
17     totalSpace: number;
18     usedSpace: number;
19     credits: number;
20     alloys: number;
21     energy: number;
22     food: number;
23     population: number;
24     soldiers: number;
25     attackers: number;
26     defenders: number;
27     sp_attackers: number;
28     sp_defenders: number;
29     homes: number;
30     farms: number;
31     warehouses: number;
32     solar_panels: number;
33     accumulators: number;
34     mining_facilities: number;
35     ore_refinery: number;
36     barracks: number;
37     special_attacker_trainer: number;
38     special_defender_trainer: number;
39     icon: string;
40     max_construction_queue: number;
41     max_training_queue: number;
42 }
43
44 export type CityWithLocation = {
45     sector_id: number;
46     location_x: number;
47     location_y: number;
48 } & City;
49
50 export class CityRepository extends Repository<City> {
51     buildQueue: BuildQueueRepository;
52     buildingRepository: BuildingRepository;
53     unitRepository: UnitRepository;
54     unitTrainigQueue: UnitTrainingQueueRepository;
55     armyRepository: ArmyRepository;
56
57     constructor() {
58         super('cities');
59         this.buildingRepository = new BuildingRepository();
60         this.buildQueue = new BuildQueueRepository();
61         this.unitRepository = new UnitRepository();
62         this.unitTrainigQueue = new UnitTrainingQueueRepository();
63         this.armyRepository = new ArmyRepository();
64     }
65
66     async create(accountId: string, rebel: boolean = false): Promise<CityWithLocation> {
67       const icon = rebel ? `/colony-ships/rebels/${random(1, 6)}.png` : '/colony-ships/01.png';
68         const info: City = {
69             id: uuid(),
70             owner: accountId,
71             totalSpace: 100,
72             usedSpace: 0,
73             credits: 10000,
74             alloys: 10000,
75             energy: 10000,
76             food: 10000,
77             population: 1000,
78             soldiers: 100,
79             attackers: 0,
80             defenders: 0,
81             sp_attackers: 0,
82             sp_defenders: 0,
83             homes: 20,
84             farms: 5,
85             warehouses: 5,
86             solar_panels: 5,
87             accumulators: 5,
88             mining_facilities: 5,
89             ore_refinery: 5,
90             barracks: 0,
91             special_attacker_trainer: 0,
92             special_defender_trainer: 0,
93             max_construction_queue: 2,
94             max_training_queue: 2,
95             icon
96         };
97
98         await this.Insert(info);
99
100         // placement can happen randomly
101         const availableSectors = config.AVAILABLE_SECTORS;
102         const sector = _.random(1, availableSectors);
103
104         const location = {
105             sector_id: await this.getAvailableSector(),
106             location_x: random(0, 25),
107             location_y: random(0, 25)
108         }
109
110         await this.db.raw('insert into locations (sector_id, city_id, location_x, location_y) values (?, ?, ?, ?)', [
111             location.sector_id,
112             info.id,
113             location.location_x,
114             location.location_y
115         ]);
116
117         return {
118             ...info,
119             sector_id: location.sector_id,
120             location_x: location.location_x,
121             location_y: location.location_y
122         };
123     }
124
125     async getAvailableSector(): Promise<number> {
126         // figure out which sectors have space (40% fill rate at 25x25);
127         const availableSectors = await this.db.raw<{count: Number, sector_id: number}[]>(`select count(sector_id) as count, sector_id from locations group by sector_id`);
128         const sample = _.sample(availableSectors.filter(sector => sector.count < 250)) as {count: number, sector_id: number};
129
130         if(!sample) {
131             return _.sortBy(availableSectors, 'sector_id').pop().sector_id+1;
132         }
133         
134         return sample.sector_id;
135     }
136
137     async save(city: Partial<City>) {
138       if(!city.id) {
139         throw new Error('Unknown city to save');
140       }
141       const fieldsToSave = [
142         'totalSpace', 'usedSpace', 'credits', 'alloys', 'energy', 'food',
143         'poulation', 'soldiers', 'attackers', 'defenders', 'sp_attackers', 'sp_defenders',
144         'homes', 'farms', 'barracks', 'special_attacker_trainer', 'special_defender_trainer'
145       ];
146       
147       const finalData = {};
148
149       fieldsToSave.forEach(field => {
150         if(city.hasOwnProperty(field)) {
151           finalData[field] = city[field];
152         }
153       });
154
155       await this.Save(finalData, {id: city.id});
156       return city;
157     }
158
159     async findById(cityId: string): Promise<CityWithLocation> {
160         const city = await this.db.raw<CityWithLocation[]>(`select c.*, l.sector_id, l.location_x, l.location_y from cities c
161         join locations l on c.id = l.city_id 
162         where id = ? limit 1`, [cityId]);
163
164         if(!city) {
165             throw new NotFoundError('User has no city', ERROR_CODE.NO_CITY);
166         }
167
168         return city.pop();
169
170     }
171
172     async getUsersCity(owner: string): Promise<CityWithLocation> {
173         const city = await this.db.raw<CityWithLocation[]>(`select c.*, l.sector_id, l.location_x, l.location_y from cities c
174         join locations l on c.id = l.city_id 
175         where owner = ? limit 1`, [owner]);
176
177         if(!city) {
178             throw new NotFoundError('User has no city', ERROR_CODE.NO_CITY);
179         }
180
181         return city.pop();
182     }
183
184     findAllInSector(sector_id: number): Promise<CityWithLocation[]> {
185         return this.db.raw<CityWithLocation[]>(`select c.*, l.sector_id, l.location_x, l.location_y from cities c
186 join locations l on c.id = l.city_id 
187 where l.sector_id = ?`, [sector_id]);
188     }
189
190     async buildBuilding(building: Building, amount: number, city: City): Promise<BuildQueue> {
191         const freeSpace = city.totalSpace - city.usedSpace;
192
193         if(freeSpace < building.land) {
194             throw new InsufficientResourceError('land', building.land, freeSpace);
195         }
196
197         if(city.credits < building.credits) {
198             throw new InsufficientResourceError('credits', building.credits, city.credits);
199         }
200
201         if(city.alloys < building.alloys) {
202             throw new InsufficientResourceError('alloys', building.alloys, city.alloys);
203         }
204
205         if(city.energy < building.energy) {
206             throw new InsufficientResourceError('energy', building.energy, city.energy);
207         }
208
209         // validate that they have enough empty construction queues
210         const concurrentConstruction = await this.buildQueue.list(city.owner);
211         if(concurrentConstruction.length >= city.max_construction_queue) {
212           throw new InsufficientResourceError('Construction queues', concurrentConstruction.length + 1, city.max_construction_queue);
213         }
214
215         city.usedSpace += (building.land * amount);
216         city.credits -= (building.credits * amount);
217         city.alloys -= (building.alloys * amount);
218         city.energy -= (building.energy * amount);
219
220         await this.save(city);
221
222         const due = Duration.fromObject({ hours: building.time});
223         const queue = await this.buildQueue.create(
224             city.owner, 
225             DateTime.now().plus({ milliseconds: due.as('milliseconds') }).toMillis(), 
226             building.slug,
227             amount
228         );
229
230         return queue;
231     }
232
233     /**
234      * Returns the distance in seconds
235      * @param city1 
236      * @param city2 
237      */
238     distanceInSeconds(city1: CityWithLocation, city2: CityWithLocation): number {
239         return this.distanceInHours(city1, city2) * 60 * 60;
240     }
241
242     distanceInHours(city1: CityWithLocation, city2: CityWithLocation): number {
243         const dist = Math.sqrt(
244             Math.pow((city2.location_x - city1.location_x), 2) 
245             + 
246             Math.pow((city2.location_y - city1.location_y), 2)
247         );
248
249         // sectors always add 4 hours
250         const sector_dist = Math.abs(city1.sector_id - city2.sector_id) * 6;
251
252         return _.round(dist/4, 2) + sector_dist;
253
254     }
255
256     async train(unit: Unit, amount: number, city: City): Promise<UnitTrainingQueue> {
257         if(city.credits < unit.credits) {
258             throw new InsufficientResourceError('credits', unit.credits, city.credits);
259         }
260
261         if(city.food < unit.food) {
262             throw new InsufficientResourceError('food', unit.food, city.food);
263         }
264
265         if(city.population < coalesce(unit.population, 0)) {
266             throw new InsufficientResourceError('population', unit.population, city.population);
267         }
268
269         if(city.soldiers < coalesce(unit.soldiers, 0)) {
270             throw new InsufficientResourceError('soldiers', unit.soldiers, city.soldiers);
271         }
272
273         if(city.attackers < coalesce(unit.attackers, 0)) {
274             throw new InsufficientResourceError('attackers', unit.attackers, city.attackers);
275         }
276
277         if(city.defenders < coalesce(unit.defenders, 0)) {
278             throw new InsufficientResourceError('defenders', unit.defenders, city.defenders);
279         }
280
281         // validate that they have enough empty training queues
282         const concurrentTraining = await this.unitTrainigQueue.list(city.owner);
283         if(concurrentTraining.length >= city.max_training_queue) {
284           throw new InsufficientResourceError('Training queues', concurrentTraining.length + 1, city.max_training_queue);
285         }
286
287         // ok they have everything, lets update their city 
288         // and create the entry in the training queue
289
290         city.credits -= unit.credits * amount;
291         city.food -= unit.food * amount;
292         city.population -= unit.population * amount;
293         city.soldiers -= unit.soldiers * amount;
294         city.attackers -= unit.attackers * amount;
295         city.defenders -= unit.defenders * amount;
296
297         console.log(city);
298
299         await this.save(city);
300
301         // barracks can drop this by 0.01% for each barrack.
302
303         let additionalOffset = 0;
304         if(unit.slug === 'sp_attackers') {
305           additionalOffset = (this.spAttackerTraininerBoost(city) * unit.time);
306         }
307         else if(unit.slug === 'sp_defenders') {
308           additionalOffset = (this.spDefenderTraininerBoost(city) * unit.time);
309         }
310
311         const barracksOffset = _.round((this.barracksImprovement(city) * unit.time) + unit.time - additionalOffset, 2);
312
313         const due = Duration.fromObject({ hours: barracksOffset });
314
315         const queue = await this.unitTrainigQueue.create(
316             city.owner, 
317             DateTime.now().plus({ milliseconds: due.as('milliseconds') }).toMillis(), 
318             unit.slug,
319             amount
320         );
321
322         return queue;
323     }
324
325     async power(checkUnits: {soldiers: number, attackers: number, defenders: number, sp_attackers: number, sp_defenders: number}): Promise<number> {
326         const units = _.keyBy(await this.unitRepository.list(), 'slug');
327         let power = 0;
328
329         _.each(checkUnits, (count, slug) => {
330             try {
331                 power += units[slug].attack * count;
332             }
333             catch(e) {
334             }
335         });
336
337         return power
338     }
339
340     barracksImprovement(city: City): number {
341       return city.barracks * 0.0001;
342     }
343
344     spAttackerTraininerBoost(city: City): number {
345       return city.special_attacker_trainer * 0.002;
346     }
347
348     spDefenderTraininerBoost(city: City): number {
349       return city.special_defender_trainer * 0.002;
350     }
351
352     maxPopulation(city: City): number {
353       return city.homes * 25;
354     }
355
356     maxFood(city: City): number {
357       return city.warehouses * 250;
358     }
359
360     maxEnergy(city: City): number {
361       return city.accumulators * 150;
362     }
363
364     maxAlloy(city: City): number {
365       return city.ore_refinery * 75;
366     }
367
368     async foodProductionPerTick(city: City): Promise<number> {
369       // eventually we should supply the warehouse formula 
370       // to calculate the max amount of food created per tick
371       return city.farms * 50;
372     }
373
374     async foodUsagePerTick(city: City): Promise<number> {
375       return (
376         (city.soldiers * 0.5) + 
377         (city.population * 0.25) + 
378         (city.attackers * 0.75) + 
379         (city.attackers * 0.75) + 
380         (city.sp_attackers * 1.3) + 
381         (city.sp_defenders * 1.3)
382       )
383     }
384
385     async energyProductionPerTick(city: City): Promise<number> {
386       return city.solar_panels * 125;
387     }
388
389     async energyUsagePerTick(city: City): Promise<number> {
390       const buildings = await this.buildingRepository.list();
391       const buildingsMap = pluck<Building>(buildings, 'slug');
392       const totalEnergy = Math.ceil(_.sum([
393         city.farms * (buildingsMap['farms'].energy * 0.1),
394         city.barracks * (buildingsMap['barracks'].energy * 0.1),
395         city.special_defender_trainer * (buildingsMap['special_defender_trainer'].energy * 0.1),
396         city.special_attacker_trainer * (buildingsMap['special_attacker_trainer'].energy * 0.1),
397         city.homes * (buildingsMap['homes'].energy * 0.1),
398         city.warehouses * (buildingsMap['warehouses'].energy * 0.1),
399         city.solar_panels * (buildingsMap['solar_panels'].energy * 0.1),
400         city.mining_facilities * (buildingsMap['mining_facilities'].energy * 0.1),
401         city.ore_refinery * (buildingsMap['ore_refinery'].energy * 0.1)
402       ]));
403       return totalEnergy;
404     }
405
406     async attack(attacker: CityWithLocation, attacked: CityWithLocation, army: Army): Promise<ArmyQueue> {
407         // validate the user has enough of a military! 
408         if(attacker.soldiers < army.soldiers) {
409             throw new InsufficientResourceError('soldiers', army.soldiers, attacker.soldiers);
410         }
411         if(attacker.attackers < army.attackers) {
412             throw new InsufficientResourceError('attackers', army.attackers, attacker.attackers);
413         }
414         if(attacker.defenders < army.defenders) {
415             throw new InsufficientResourceError('defenders', army.defenders, attacker.defenders);
416         }
417         if(attacker.sp_attackers < army.sp_attackers) {
418             throw new InsufficientResourceError('sp_attackers', army.sp_attackers, attacker.sp_attackers);
419         }
420         if(attacker.sp_defenders < army.sp_defenders) {
421             throw new InsufficientResourceError('sp_defenders', army.sp_defenders, attacker.sp_defenders);
422         }
423
424         // ok, it's a real army lets send it off!
425         attacker.soldiers -= army.soldiers;
426         attacker.attackers -= army.attackers;
427         attacker.defenders -= army.defenders;
428         attacker.sp_attackers -= army.sp_attackers;
429         attacker.sp_defenders -= army.sp_defenders;
430
431         await this.save(attacker);
432
433         return this.armyRepository.create(
434             attacker.owner,
435             attacker,
436             attacked,
437             army,
438             this.distanceInSeconds(attacker, attacked)
439         );
440     }
441
442     async getBuildQueues(owner: string): Promise<BuildQueue[]> {
443         return this.buildQueue.list(owner);
444     }
445
446     async getUnitTrainingQueues(owner: string): Promise<UnitTrainingQueueWithName[]> {
447         return this.unitTrainigQueue.list(owner);
448     }
449 }