86b1b93b19676f86623d9728eb09c2c81c72eb80
[browser-rts.git] / src / api.ts
1 import { HttpServer } from './lib/server';
2 import * as config from './config';
3 import { AccountRepository } from './repository/accounts';
4 import { City, CityRepository } from './repository/city';
5 import { MailRepository } from './repository/mail';
6 import {BadInputError, ERROR_CODE, NotFoundError} from './errors';
7 import { renderKingomOverview } from './render/kingdom-overview';
8 import { renderLandDevelopment } from './render/land-development';
9 import { tick } from './tasks/tick';
10 import { construction } from './tasks/construction';
11 import { unitTraining } from './tasks/unit-training';
12 import { fight } from './tasks/fight';
13 import { renderUnitTraining } from './render/unit-training';
14 import { launchOffensive, listOperations, renderOverworldMap } from './render/map';
15 import { createBullBoard } from '@bull-board/api';
16 import { BullAdapter } from '@bull-board/api/bullAdapter';
17 import _ from 'lodash';
18 import { renderCost } from './render/costs';
19 import {renderMailroom, renderMessage} from './render/mail';
20 import {topbar} from './render/topbar';
21 import {renderPublicChatMessage} from './render/chat-message';
22 import validator from 'validator';
23
24
25 const server = new HttpServer(config.API_PORT);
26 const accountRepo = new AccountRepository();
27 const cityRepo = new CityRepository();
28 const mailRepo = new MailRepository();
29
30 const cache: Record<string, any> = {
31   online_users: []
32 };
33 const msgBuffer: string[] = [];
34
35 createBullBoard({
36         queues: [
37                 new BullAdapter(tick.queue),
38                 new BullAdapter(construction.queue),
39                 new BullAdapter(unitTraining.queue),
40                 new BullAdapter(fight.queue)
41         ],
42         serverAdapter: server.bullAdapter,
43 });
44
45 server.post<{
46         body: {username: string, password: string}}, 
47         string
48         >
49         ('/accounts', async (req) => {
50         const { username, password} = req.body;
51         if(!username || !password || username.length < 3 || password.length < 3) {
52                 throw new BadInputError('Invalid username or password', ERROR_CODE.INVALID_USERNAME);
53         }
54         const acct = await accountRepo.create(username, password);
55
56         // lets create the city!
57         await cityRepo.create(acct.id);
58
59         return `<div class="alert success">You are all signed up! You can go ahead and log in</div>`;
60 });
61
62 server.post<{body: {username: string, password: string}}, void>('/login', async (req, raw, res) => {
63         const { username, password} = req.body;
64         if(!username || !password || username.length < 3 || password.length < 3) {
65                 throw new BadInputError('Invalid username or password', ERROR_CODE.INVALID_USERNAME);
66         }
67         const {account, session} = await accountRepo.login(username, password);
68         if(!account) {
69                 throw new NotFoundError('Invalid username or password', ERROR_CODE.USER_NOT_FOUND);
70         }
71
72         res.setHeader('hx-redirect', `/game.html?token=${session.id}&id=${session.account_id}`);
73
74 });
75
76 server.post<{body: {
77         soldiers: number,
78         attackers: number,
79         defenders: number,
80         sp_attackers: number,
81         sp_defenders: number
82   }}, string>('/attack-power', async req => {
83         const power = await cityRepo.power(req.body);
84
85         return power.toLocaleString();
86
87 });
88
89 server.get<{params: { cityId: string }}, string>('/city/:cityId', async req => {
90         const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
91         const yourCity = await cityRepo.getUsersCity(account.id);
92         const city = await cityRepo.findById(req.params.cityId);
93         const acct = await accountRepo.FindOne({id: city.owner});
94
95
96         return await launchOffensive(city, acct || {
97                 id: '-',
98                 username: 'Rebels',
99                 password: ''
100         }, yourCity, account);
101 });
102
103 server.get<{}, string>('/poll/overview', async req => {
104         const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
105         const city = await cityRepo.getUsersCity(account.id);
106   const unreadMail = await mailRepo.countUnread(account.id);
107
108
109   const usage = {
110     foodUsagePerTick: await cityRepo.foodUsagePerTick(city),
111     foodProductionPerTick: await cityRepo.foodProductionPerTick(city),
112     energyUsagePerTick: await cityRepo.energyUsagePerTick(city),
113     energyProductionPerTick: await cityRepo.energyProductionPerTick(city)
114   }
115
116         return renderKingomOverview({
117     ...city,
118     ...usage
119   }, account) + topbar({...city, ...usage}, unreadMail);
120 });
121
122 server.get<{}, string>('/poll/construction', async req => {
123         const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
124         const city = await cityRepo.getUsersCity(account.id);
125         const buildings = await cityRepo.buildingRepository.list();
126   const unreadMail = await mailRepo.countUnread(account.id);
127
128         const buildQueues = await cityRepo.getBuildQueues(account.id);
129   const usage = {
130     foodUsagePerTick: await cityRepo.foodUsagePerTick(city),
131     foodProductionPerTick: await cityRepo.foodProductionPerTick(city),
132     energyUsagePerTick: await cityRepo.energyUsagePerTick(city),
133     energyProductionPerTick: await cityRepo.energyProductionPerTick(city)
134   }
135         return renderLandDevelopment(city, buildings, buildQueues) + topbar({...city, ...usage}, unreadMail);
136 });
137
138 server.get<{}, string>('/poll/unit-training', async req => {
139         const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
140         const city = await cityRepo.getUsersCity(account.id);
141   const unreadMail = await mailRepo.countUnread(account.id);
142
143         const unitTrainingQueues = await cityRepo.getUnitTrainingQueues(account.id);
144         const units = await cityRepo.unitRepository.list();
145   const usage = {
146     foodUsagePerTick: await cityRepo.foodUsagePerTick(city),
147     foodProductionPerTick: await cityRepo.foodProductionPerTick(city),
148     energyUsagePerTick: await cityRepo.energyUsagePerTick(city),
149     energyProductionPerTick: await cityRepo.energyProductionPerTick(city)
150   }
151
152         return renderUnitTraining(city, units, unitTrainingQueues) + topbar({
153     ...city,
154     ...usage
155   }, unreadMail);
156 });
157
158 server.post<{body: {sector: string}}, string>('/poll/map', async req => {
159         const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
160         const city = await cityRepo.getUsersCity(account.id);
161   const unreadMail = await mailRepo.countUnread(account.id);
162
163   let sector = city.sector_id;
164   if(req.body.sector) {
165     try {
166       sector = parseInt(req.body.sector);
167     }
168     catch(e) {
169       sector = city.sector_id;
170     }
171   }
172
173   const usage = {
174     foodUsagePerTick: await cityRepo.foodUsagePerTick(city),
175     foodProductionPerTick: await cityRepo.foodProductionPerTick(city),
176     energyUsagePerTick: await cityRepo.energyUsagePerTick(city),
177     energyProductionPerTick: await cityRepo.energyProductionPerTick(city)
178   }
179
180         return renderOverworldMap(await cityRepo.findAllInSector(sector), city, sector) + topbar({
181     ...city,
182     ...usage
183   }, unreadMail);
184 });
185
186 server.get<{}, string>('/poll/mailroom', async req => {
187         const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
188         const city = await cityRepo.getUsersCity(account.id);
189   const unreadMail = await mailRepo.countUnread(account.id);
190
191   const usage = {
192     foodUsagePerTick: await cityRepo.foodUsagePerTick(city),
193     foodProductionPerTick: await cityRepo.foodProductionPerTick(city),
194     energyUsagePerTick: await cityRepo.energyUsagePerTick(city),
195     energyProductionPerTick: await cityRepo.energyProductionPerTick(city)
196   }
197
198         return renderMailroom(await mailRepo.listReceivedMessages(account.id)) + topbar({
199     ...city,
200     ...usage
201   }, unreadMail);
202 });
203
204
205 server.post<{
206         body: {
207                 amount: string,
208                 building_type: string
209         }
210 }, string>('/cost/construction', async req => {
211         const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
212         const city = await cityRepo.getUsersCity(account.id);
213
214         const amount = parseInt(req.body.amount.trim(), 10);
215
216         if(isNaN(amount) || amount < 1) {
217                 return '';
218         }
219         const building = await cityRepo.buildingRepository.findBySlug(req.body.building_type);
220
221         if(!building) {
222                 throw new NotFoundError(`Invalid building type ${req.body.building_type}`, ERROR_CODE.INVALID_BUILDING);
223         }
224
225   const cost = {
226                 credits: building.credits * amount,
227                 alloys: building.alloys * amount,
228                 energy: building.energy * amount,
229                 land: building.land * amount,
230                 time: building.time
231   };
232
233   return renderCost(cost, city);
234 });
235
236 server.post<{
237         body: {
238                 amount: string;
239                 type: string;
240         }
241 }, string>('/cost/training', async req => {
242         const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
243         const city = await cityRepo.getUsersCity(account.id);
244         const amount = parseInt(req.body.amount, 10);
245
246         if(isNaN(amount) || amount < 1) {
247                 return '';
248         }
249
250         const unit = await cityRepo.unitRepository.findBySlug(req.body.type);
251         if(!unit) {
252                 throw new NotFoundError(`Invalid unit type ${req.body.type}`, ERROR_CODE.INVALID_UNIT);
253         }
254
255         return renderCost({
256                 population: unit.population * amount,
257                 soldiers: unit.soldiers * amount,
258                 attackers: unit.attackers * amount,
259                 defenders: unit.defenders * amount,
260                 credits: unit.credits * amount,
261                 food: unit.food * amount,
262     time: unit.time * amount
263         }, city);
264 });
265
266 server.post<{
267         body: {
268                 amount: string,
269                 building_type: string,
270         }
271 }, void>('/build', async req => {
272   const account = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
273   const city = await cityRepo.getUsersCity(account.id);
274
275   const amount = parseInt(req.body.amount, 10);
276   if(amount < 1) {
277     throw new BadInputError('Please specify an amount > 0', ERROR_CODE.INVALID_AMOUNT);
278   }
279   const building = await cityRepo.buildingRepository.findBySlug(req.body.building_type);
280
281   if(!building) {
282     throw new NotFoundError(`Invalid building type ${req.body.building_type}`, ERROR_CODE.INVALID_BUILDING);
283   }
284
285   const queueData = await cityRepo.buildBuilding(building, amount, city);
286
287         construction.trigger(queueData, { delay: queueData.due - Date.now() });
288 }, 'reload-construction-queue');
289
290 server.post<{
291                 body: {
292                         amount: string,
293                         type: string
294                 }
295         },
296         void
297         >('/units', async req => {
298         const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
299         const city = await cityRepo.getUsersCity(acct.id);
300
301         const amount  = parseInt(req.body.amount, 10) || 0;
302   console.log('request amount?!', amount);
303   if(amount < 1) {
304     throw new BadInputError('Please specify an amount > 0', ERROR_CODE.INVALID_AMOUNT);
305   }
306         const unit = await cityRepo.unitRepository.findBySlug(req.body.type);
307
308         if(!unit) {
309                 throw new NotFoundError(`Invalid unit type ${req.body.type}`, ERROR_CODE.INVALID_UNIT);
310         }
311
312         const queueData = await cityRepo.train(unit, amount, city);
313         unitTraining.trigger(queueData, { delay: queueData.due - Date.now() });
314
315 }, 'reload-unit-training');
316
317 server.post<{
318         body: {
319                 city: string,
320                 soldiers: string,
321                 attackers: string,
322                 defenders: string,
323                 sp_attackers: string,
324                 sp_defenders: string
325         }
326         }, 
327         void
328         >('/attack', async req => {
329                 const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
330                 const city = await cityRepo.getUsersCity(acct.id);
331                 const attackedCity = await cityRepo.findById(req.body.city);
332
333                 const army = {
334                         soldiers: parseInt(req.body.soldiers),
335                         attackers: parseInt(req.body.attackers),
336                         defenders: parseInt(req.body.defenders),
337                         sp_attackers: parseInt(req.body.sp_attackers),
338                         sp_defenders: parseInt(req.body.sp_defenders)
339                 };
340
341                 const armyQueue = await cityRepo.attack(city, attackedCity, army);
342
343                 fight.trigger(armyQueue, {
344                         delay: armyQueue.due - Date.now()
345                 });
346         }, 'reload-outgoing-attacks');
347
348 server.get<{params: {id: string}}, string>('/messages/:id', async req => {
349         const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
350   const city = await cityRepo.getUsersCity(acct.id);
351         const msg = await mailRepo.getMessage(req.params.id, acct.id);
352
353         if(!msg) {
354                 throw new NotFoundError('No such message', ERROR_CODE.DUPLICATE_CACHE_KEY);
355         }
356
357   const usage = {
358     foodUsagePerTick: await cityRepo.foodUsagePerTick(city),
359     foodProductionPerTick: await cityRepo.foodProductionPerTick(city),
360     energyUsagePerTick: await cityRepo.energyUsagePerTick(city),
361     energyProductionPerTick: await cityRepo.energyProductionPerTick(city)
362   }
363
364         await mailRepo.markAsRead(msg.id, msg.to_account);
365   const unreadMail = await mailRepo.countUnread(acct.id);
366
367         return renderMailroom(await mailRepo.listReceivedMessages(acct.id), msg) + topbar({
368     ...city,
369     ...usage
370   }, unreadMail);
371 });
372
373 server.get<void, string>('/attacks/outgoing', async req => {
374         const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
375         const city = await cityRepo.getUsersCity(acct.id);
376         const attacks = await cityRepo.armyRepository.listOutgoing(city.id);
377
378         return listOperations(attacks);
379 });
380
381 server.post<{body: {message: string}}, void>('/chat', async req => {
382   const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
383   const now = Date.now();
384
385   if(!_.isEmpty(req.body.message)) {
386     const msg = renderPublicChatMessage(acct.username, req.body.message);
387     server.ws.emit('/chat-message', msg);
388     msgBuffer.unshift(msg);
389     while(msgBuffer.length > 30) {
390       msgBuffer.pop();
391     }
392   }
393
394   return;
395 });
396
397 server.post<{params: {queueId: string}}, void>('/construction/:queueId/cancel', async req => {
398         const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
399         const city = await cityRepo.getUsersCity(acct.id);
400
401   if(!validator.isUUID(req.params.queueId)) {
402     throw new BadInputError('Invalid queue ID', ERROR_CODE.INVALID_BUILD_QUEUE);
403   }
404
405   // validate that this is an actual queue
406   const queue = await cityRepo.buildQueue.FindOne({owner: city.owner, id: req.params.queueId});
407
408   if(!queue) {
409     throw new NotFoundError('That queue does not exist', ERROR_CODE.INVALID_BUILD_QUEUE);
410   }
411
412   const [, building] = await Promise.all([
413     cityRepo.buildQueue.Delete({
414       owner: city.owner,
415       id: req.params.queueId
416     }),
417     cityRepo.buildingRepository.findBySlug(queue.building_type)
418   ]);
419
420   // now that it's deleted we can give the player back some percentage 
421   // of resources based on how close they are to completion.
422   const diff = (queue.due - Date.now()) / (queue.due - queue.created);
423   // force a 20% loss minimum
424   const finalDiff = diff < 0.2 ? 0.2 : diff;
425
426   const costReturn: Partial<City> = {
427     id: city.id,
428     credits: city.credits + Math.floor(building.credits * queue.amount * diff),
429     alloys: city.alloys + Math.floor(building.alloys * queue.amount * diff),
430     energy: city.energy + Math.floor(building.energy * queue.amount * diff),
431     usedSpace: city.usedSpace - (building.land * queue.amount)
432   };
433
434   console.log('update', costReturn)
435
436   await cityRepo.save(costReturn);
437
438 }, 'reload-construction-queue');
439
440 server.post<{params: {queueId: string}}, void>('/training/:queueId/cancel', async req => {
441         const acct = await accountRepo.validate(req.authInfo.accountId, req.authInfo.token);
442         const city = await cityRepo.getUsersCity(acct.id);
443
444   if(!validator.isUUID(req.params.queueId)) {
445     throw new BadInputError('Invalid queue ID', ERROR_CODE.INVALID_BUILD_QUEUE);
446   }
447   
448   const queue = await cityRepo.unitTrainigQueue.FindOne({owner: city.owner, id: req.params.queueId});
449   if(!queue) {
450     throw new NotFoundError('That queue does not exist', ERROR_CODE.INVALID_BUILD_QUEUE);
451   }
452
453   const [, unit] = await Promise.all([
454     cityRepo.unitTrainigQueue.Delete({
455       owner: city.owner,
456       id: req.params.queueId
457     }),
458     cityRepo.unitRepository.FindOne({slug: queue.unit_type})
459   ]);
460
461   // now that it's deleted we can give the player back some percentage 
462   // of resources based on how close they are to completion.
463   const diff = (queue.due - Date.now()) / (queue.due - queue.created);
464   // force a 20% loss minimum
465   const finalDiff = diff < 0.2 ? 0.2 : diff;
466
467   const costReturn: Partial<City> = {
468     id: city.id,
469     credits: city.credits + Math.floor(unit.credits * queue.amount * diff),
470     food: city.food + Math.floor(unit.food * queue.amount * diff),
471     population: city.population + Math.floor(unit.population * queue.amount),
472     soldiers: city.soldiers + Math.floor(unit.soldiers * queue.amount),
473     attackers: city.attackers + Math.floor(unit.attackers * queue.amount),
474     defenders: city.defenders + Math.floor(unit.attackers * queue.amount)
475   };
476
477   console.log('update', costReturn)
478
479   await cityRepo.save(costReturn);
480
481 }, 'reload-unit-training');
482
483 server.get<void, string>('/server-stats', async req => {
484   const date = new Date();
485   const min = date.getMinutes();
486   return `
487   <div class="text-right">
488     <span class="success-text">${server.ws.engine.clientsCount} Online</span><br>
489     <span>
490     Server Time: ${date.getHours()}:${min < 10 ? '0'+min : min}
491     </span>
492   </div>`;
493 });
494
495 server.ws.on('connection', async socket => {
496   const auth = server.authFromUrl(socket.request.headers['referer']);
497   const acct = await accountRepo.validate(auth.authInfo.accountId, auth.authInfo.token);
498
499   server.ws.emit('/chat-message', msgBuffer.join("\n"));
500
501   server.ws.emit('/chat-message', renderPublicChatMessage('Server', `${acct.username} logged in`));
502
503   cache.online_users.push(acct.username);
504
505   socket.on('disconnect', () => {
506     cache.online_users.splice(cache.online_users.indexOf(acct.username), 1);
507   });
508 });
509
510 server.start();
511
512 tick.trigger({
513   lastTickAt: 0,
514   lastTick: 0
515 }, {
516   repeat: {
517     cron: '0 * * * *'
518   }
519 });