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