chore(release): 0.3.3
[risinglegends.git] / src / server / api.ts
1 import * as otel from './tracing';
2 import { version } from "../../package.json";
3 import { config as dotenv } from 'dotenv';
4 import { join } from 'path';
5 import express, {Request, Response} from 'express';
6 import bodyParser from 'body-parser';
7 import xss from 'xss';
8 import { rateLimit } from 'express-rate-limit';
9
10 import http from 'http';
11 import { Server, Socket } from 'socket.io';
12 import * as CONSTANT from '../shared/constants';
13 import { logger } from './lib/logger';
14 import { loadPlayer, createPlayer, updatePlayer, movePlayer } from './player';
15 import { random, sample } from 'lodash';
16 import {broadcastMessage, Message} from '../shared/message';
17 import {maxHp, maxVigor, Player} from '../shared/player';
18 import {createFight, getMonsterList, getMonsterLocation, getRandomMonster, loadMonster, loadMonsterFromFight, loadMonsterWithFaction} from './monster';
19 import {addInventoryItem, getEquippedItems, getInventory, getInventoryItem} from './inventory';
20 import { getItemFromPlayer, getItemFromShop, getPlayersItems, getShopItems, givePlayerItem, updateItemCount } from './items';
21 import {FightTrigger, Monster, MonsterForFight} from '../shared/monsters';
22 import {getShopEquipment, listShopItems } from './shopEquipment';
23 import {EquipmentSlot} from '../shared/inventory';
24 import { clearTravelPlan, completeTravel, getAllPaths, getAllServices, getCityDetails, getService, getTravelPlan, stepForward, travel } from './map';
25 import { signup, login, authEndpoint } from './auth';
26 import {db} from './lib/db';
27 import { getPlayerSkills} from './skills';
28
29 import { fightRound, blockPlayerInFight } from './fight';
30
31 import { router as healerRouter } from './locations/healer';
32 import { router as professionRouter } from './locations/recruiter';
33 import { router as repairRouter } from './locations/repair';
34
35 import * as Alert from './views/alert';
36 import { renderPlayerBar } from './views/player-bar'
37 import { renderEquipmentDetails, renderStore } from './views/stores';
38 import { renderMap } from './views/map';
39 import { renderProfilePage } from './views/profile';
40 import { renderSkills } from './views/skills';
41 import { renderInventoryPage } from './views/inventory';
42 import { renderMonsterSelector, renderOnlyMonsterSelector } from './views/monster-selector';
43 import { renderFight, renderFightPreRound, renderRoundDetails } from './views/fight';
44 import { renderTravel, travelButton } from './views/travel';
45 import { renderChatMessage } from './views/chat';
46
47 // TEMP!
48 import { createMonsters } from '../../seeds/monsters';
49 import { createAllCitiesAndLocations } from '../../seeds/cities';
50 import { createShopItems, createShopEquipment } from '../../seeds/shop_items';
51 import { Item, PlayerItem, ShopItem } from 'shared/items';
52 import { equip, unequip } from './equipment';
53 import { HealthPotionSmall } from '../shared/items/health_potion';
54
55 dotenv();
56
57 otel.s();
58
59 const app = express();
60 const server = http.createServer(app);
61
62 app.use(express.static(join(__dirname, '..', '..', 'public')));
63 app.use(bodyParser.urlencoded({ extended: true }));
64 app.use(express.json());
65
66 const io = new Server(server);
67
68 const cache = new Map<string, any>();
69 const chatHistory: Message[] = [];
70
71 app.use((req, res, next) => {
72   console.log(req.method, req.url);
73   next();
74 });
75
76 const fightRateLimiter = rateLimit({
77   windowMs: parseInt(process.env.RATE_LIMIT_WINDOW || '30000'),
78   max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '20'),
79   standardHeaders: true,
80   legacyHeaders: false,
81   handler: (req, res, next, options) => {
82     logger.log(`Blocked request: [${req.headers['x-authtoken']}: ${req.method} ${req.path}]`);
83     res.status(options.statusCode).send(options.message);
84   }
85 });
86
87 async function bootstrapSocket(socket: Socket, player: Player) {
88   // ref to get the socket id for a particular player
89   cache.set(`socket:${player.id}`, socket.id);
90   // ref to get the player object
91   cache.set(`token:${player.id}`, player);
92   cache.set(`socket-lookup:${socket.id}`, {
93     id: player.id,
94     username: player.username
95   });
96
97   socket.emit('authToken', player.id);
98
99   socket.emit('chat', renderChatMessage(broadcastMessage('server', `${player.username} just logged in`)));
100 }
101
102 function uniqueConnectedUsers(): Set<string> {
103   const users = new Set<string>();
104
105   io.sockets.sockets.forEach((socket) => {
106     users.add(cache.get(`socket-lookup:${socket.id}`).username);
107   });
108
109   return users;
110 }
111
112 io.on('connection', async socket => {
113   logger.log(`socket ${socket.id} connected, authToken: ${socket.handshake.headers['x-authtoken']}`);
114
115   let authToken = socket.handshake.headers['x-authtoken'].toString() === 'null' ? null : socket.handshake.headers['x-authtoken'].toString();
116
117   let player: Player;
118   if(authToken) {
119     logger.log(`Attempting to load player with id ${authToken}`);
120     player = await loadPlayer(authToken);
121   }
122   if(!player) {
123     logger.log(`Creating player`);
124     player = await createPlayer();
125     authToken = player.id;
126     socket.handshake.headers['x-authtoken'] = authToken;
127   }
128
129   logger.log(`Socket [${socket.id}] auth token: ${player.id}`);
130
131   bootstrapSocket(socket, player);
132
133   socket.on('disconnect', () => {
134     console.log(`Player ${player.username} left`);
135     cache.delete(`socket-lookup:${socket.id}`);
136
137     io.emit('status', `${uniqueConnectedUsers().size} Online (v${version})`);
138   });
139
140
141   io.emit('status', `${uniqueConnectedUsers().size} Online (v${version})`);
142   // this is a special event to let the client know it can start 
143   // requesting data
144   socket.emit('ready');
145 });
146
147
148 app.use(healerRouter);
149 app.use(professionRouter);
150 app.use(repairRouter);
151
152
153 app.get('/chat/history', authEndpoint, async (req: Request, res: Response) => {
154   let html = chatHistory.map(renderChatMessage);
155
156   res.send(html.join("\n"));
157 });
158
159 app.post('/chat', authEndpoint, async (req: Request, res: Response) => {
160   const msg = req.body.message.trim();
161
162   if(!msg || !msg.length) {
163     res.sendStatus(204);
164     return;
165   }
166
167   let message: Message;
168   if(msg.startsWith('/server lmnop')) {
169     try {
170       if(msg === '/server lmnop refresh-monsters') {
171         await createMonsters();
172         message = broadcastMessage('server', 'Monster refresh!');
173       }
174       else if(msg === '/server lmnop refresh-cities') {
175         await createAllCitiesAndLocations();
176         message = broadcastMessage('server', 'Cities, Locations, and Paths refreshed!');
177       }
178       else if(msg === '/server lmnop refresh-shops') {
179         await createShopItems();
180         await createShopEquipment();
181         message = broadcastMessage('server', 'Refresh shop items');
182       }
183       else {
184         const str = msg.split('/server lmnop ')[1];
185         if(str) {
186           message = broadcastMessage('server', str);
187         }
188       }
189     }
190     catch(e) {
191       io.to(cache.get(`socket:${req.player.id}`)).emit('chat', renderChatMessage(broadcastMessage('server', e.message)));
192     }
193   }
194   else if(msg === '/online') {
195     const users = Array.from(uniqueConnectedUsers().values());
196     // send to specific user
197     const message = broadcastMessage('server', `Online Players: [${users.join(", ")}]`);
198     io.to(cache.get(`socket:${req.player.id}`)).emit('chat', renderChatMessage(message));
199     res.sendStatus(204);
200   }
201   else {
202     message = broadcastMessage(req.player.username, xss(msg, {
203       whiteList: {}
204     }));
205     chatHistory.push(message);
206     chatHistory.slice(-10);
207   }
208
209   if(message) {
210     io.emit('chat', renderChatMessage(message));
211     res.sendStatus(204);
212   }
213 });
214
215 app.get('/player', authEndpoint, async (req: Request, res: Response) => {
216   const equipment = await getEquippedItems(req.player.id);
217   res.send(renderPlayerBar(req.player) + renderProfilePage(req.player, equipment));
218 });
219
220 app.post('/player/stat/:stat', authEndpoint, async (req: Request, res: Response) => {
221   const equipment = await getEquippedItems(req.player.id);
222   const stat = req.params.stat;
223   if(!['strength', 'constitution', 'dexterity', 'intelligence'].includes(stat)) {
224     res.send(Alert.ErrorAlert(`Sorry, that's not a valid stat to increase`));
225     return;
226   }
227
228   if(req.player.stat_points <= 0) {
229     res.send(Alert.ErrorAlert(`Sorry, you don't have enough stat points`));
230     return;
231   }
232
233   req.player.stat_points -= 1;
234   req.player[stat]++;
235
236   req.player.hp = maxHp(req.player.constitution, req.player.level);
237   req.player.vigor = maxVigor(req.player.constitution, req.player.level);
238   updatePlayer(req.player);
239
240   res.send(renderPlayerBar(req.player) + renderProfilePage(req.player, equipment));
241 });
242
243 app.get('/player/skills', authEndpoint, async (req: Request, res: Response) => {
244   const skills = await getPlayerSkills(req.player.id);
245
246   res.send(renderSkills(skills));
247 });
248
249 app.get('/player/inventory', authEndpoint, async (req: Request, res: Response) => {
250   const [inventory, items] = await Promise.all([
251     getInventory(req.player.id),
252     getPlayersItems(req.player.id)
253   ]);
254
255   res.send(renderInventoryPage(inventory, items));
256 });
257
258 app.post('/player/equip/:item_id/:slot', authEndpoint, blockPlayerInFight, async (req: Request, res: Response) => {
259   const inventoryItem = await getInventoryItem(req.player.id, req.params.item_id);
260   const equippedItems = await getEquippedItems(req.player.id);
261   const requestedSlot = req.params.slot;
262   let desiredSlot: EquipmentSlot = inventoryItem.equipment_slot;
263
264   try {
265     // handes the situation where you're trying to equip an item 
266     // that can be equipped to any hand
267     if(inventoryItem.equipment_slot === 'ANY_HAND') {
268       if(requestedSlot === 'LEFT_HAND' || requestedSlot === 'RIGHT_HAND') {
269         // get the players current equipment in that slot!
270         if(equippedItems.some(v => {
271           return v.equipment_slot === requestedSlot || v.equipment_slot === 'TWO_HANDED';
272         })) {
273           throw new Error();
274         }
275         else {
276           desiredSlot = requestedSlot;
277         }
278       }
279     }
280
281     if(requestedSlot === 'TWO_HANDED') {
282       if(equippedItems.some(v => {
283         return v.equipment_slot === 'LEFT_HAND' || v.equipment_slot === 'RIGHT_HAND';
284       })) {
285         throw new Error();
286       }
287     }
288
289
290     await equip(req.player.id, inventoryItem, desiredSlot);
291     const socketId = cache.get(`socket:${req.player.id}`).toString();
292     io.to(socketId).emit('updatePlayer', req.player);
293     io.to(socketId).emit('alert', {
294       type: 'success',
295       text: `You equipped your ${inventoryItem.name}`
296     });
297   }
298   catch(e) {
299     logger.log(e);
300   }
301
302   const [inventory, items] = await Promise.all([
303     getInventory(req.player.id),
304     getPlayersItems(req.player.id)
305   ]);
306
307   res.send(renderInventoryPage(inventory, items, inventoryItem.type) + renderPlayerBar(req.player));
308 });
309
310 app.post('/player/unequip/:item_id', authEndpoint, blockPlayerInFight, async (req: Request, res: Response) => {
311   const [item, ] = await Promise.all([
312     getInventoryItem(req.player.id, req.params.item_id),
313     unequip(req.player.id, req.params.item_id)
314   ]);
315
316   const [inventory, items] = await Promise.all([
317     getInventory(req.player.id),
318     getPlayersItems(req.player.id)
319   ]);
320
321   res.send(renderInventoryPage(inventory, items, item.type) + renderPlayerBar(req.player));
322 });
323
324 app.get('/player/explore', authEndpoint, async (req: Request, res: Response) => {
325   const fight = await loadMonsterFromFight(req.player.id);
326   const travelPlan = await getTravelPlan(req.player.id);
327   let closestTown = req.player.city_id;
328
329   if(travelPlan) {
330       closestTown = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
331   }
332
333   if(fight) {
334     const location = await getMonsterLocation(fight.ref_id);
335
336     res.send(renderPlayerBar(req.player) + renderFightPreRound(fight, true, location, closestTown));
337   }
338   else {
339     if(travelPlan) {
340       // traveling!
341       const chanceToSeeMonster = random(0, 100);
342       const things: any[] = [];
343       if(chanceToSeeMonster <= 30) {
344         const monster = await getRandomMonster([closestTown]);
345         things.push(monster);
346       }
347
348       // STEP_DELAY
349       const nextAction = cache[`step:${req.player.id}`] || 0;
350
351       res.send(renderPlayerBar(req.player) + renderTravel({
352         things,
353         nextAction,
354         closestTown: closestTown,
355         walkingText: '',
356         travelPlan
357       }));
358     }
359     else {
360       // display the city info!
361       const [city, locations, paths] = await Promise.all([
362         getCityDetails(req.player.city_id),
363         getAllServices(req.player.city_id),
364         getAllPaths(req.player.city_id)
365       ]);
366
367       res.send(renderPlayerBar(req.player) + await renderMap({city, locations, paths}, closestTown));
368     }
369
370   }
371 });
372
373 // used to purchase equipment from a particular shop
374 app.put('/location/:location_id/equipment/:item_id', authEndpoint, async (req: Request, res: Response) => {
375   const item = await getShopEquipment(parseInt(req.params.item_id), parseInt(req.params.location_id));
376
377   if(!item) {
378     logger.log(`Invalid item [${req.params.item_id}]`);
379     return res.sendStatus(400);
380   }
381
382   if(req.player.gold < item.cost) {
383     res.send(Alert.ErrorAlert(`Sorry, you need at least ${item.cost.toLocaleString()}G to purchase this.`));
384     return;
385   }
386
387   req.player.gold -= item.cost;
388
389   await updatePlayer(req.player);
390   await addInventoryItem(req.player.id, item);
391
392   res.send(renderPlayerBar(req.player) + Alert.SuccessAlert(`You purchased ${item.name}`));
393 });
394
395 // used to purchase items from a particular shop
396 app.put('/location/:location_id/items/:item_id', authEndpoint, async (req: Request, res: Response) => {
397   const item: (ShopItem & Item) = await getItemFromShop(parseInt(req.params.item_id), parseInt(req.params.location_id));
398
399   if(!item) {
400     logger.log(`Invalid item [${req.params.item_id}]`);
401     return res.sendStatus(400);
402   }
403
404   if(req.player.gold < item.price_per_unit) {
405     res.send(Alert.ErrorAlert(`Sorry, you need at least ${item.price_per_unit.toLocaleString()}G to purchase this.`));
406     return;
407   }
408
409   req.player.gold -= item.price_per_unit;
410
411   await updatePlayer(req.player);
412   await givePlayerItem(req.player.id, item.id, 1);
413
414   res.send(renderPlayerBar(req.player) + Alert.SuccessAlert(`You purchased a ${item.name}`));
415 });
416
417 // used to display equipment modals in a store, validates that 
418 // the equipment is actually in this store before displaying 
419 // the modal
420 app.get('/location/:location_id/equipment/:item_id/overview', authEndpoint, async (req: Request, res: Response) => {
421   const equipment = await getShopEquipment(parseInt(req.params.item_id), parseInt(req.params.location_id));
422
423   if(!equipment) {
424     logger.log(`Invalid equipment [${req.params.item_id}]`);
425     return res.sendStatus(400);
426   }
427
428   let html = `
429 <dialog>
430   <div class="item-modal-overview">
431     <div class="icon">
432       <img src="${equipment.icon ? `/assets/img/icons/equipment/${equipment.icon}` : 'https://via.placeholder.com/64x64'}" title="${equipment.name}" alt="${equipment.name}"> 
433     </div>
434     <div>
435       ${renderEquipmentDetails(equipment, req.player)}
436     </div>
437   </div>
438   <div class="actions">
439     <button hx-put="/location/${equipment.location_id}/equipment/${equipment.id}" formmethod="dialog" value="cancel" class="green">Buy</button>
440     <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
441   </div>
442 </dialog>
443 `;
444
445   res.send(html);
446 });
447
448 // used to display item modals in a store, validates that 
449 // the item is actually in this store before displaying 
450 // the modal
451 app.get('/location/:location_id/items/:item_id/overview', authEndpoint, async (req: Request, res: Response) => {
452   const item: (ShopItem & Item) = await getItemFromShop(parseInt(req.params.item_id), parseInt(req.params.location_id));
453
454   if(!item) {
455     logger.log(`Invalid item [${req.params.item_id}]`);
456     return res.sendStatus(400);
457   }
458
459   let html = `
460 <dialog>
461   <div class="item-modal-overview">
462     <div class="icon">
463       <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}" alt="${item.name}"> 
464     </div>
465     <div>
466       <h4>${item.name}</h4>
467       <p>${item.description}</p>
468     </div>
469   </div>
470   <div class="actions">
471     <button hx-put="/location/${item.location_id}/items/${item.id}" formmethod="dialog" value="cancel" class="red">Buy</button>
472     <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
473   </div>
474 </dialog>
475 `;
476
477   res.send(html);
478 });
479
480 app.put('/item/:item_id', authEndpoint, async (req: Request, res: Response) => {
481   const item: PlayerItem = await getItemFromPlayer(req.player.id, parseInt(req.params.item_id));
482
483   if(!item) {
484     console.log(`Can't find item [${req.params.item_id}]`);
485     return;
486   }
487
488   if(item.amount < 1) {
489     res.send(Alert.ErrorAlert(`You dont have enough ${item.name}`));
490     return;
491   }
492
493   item.amount -= 1;
494
495   switch(item.effect_name) {
496     case 'heal_small':
497       const hpGain = HealthPotionSmall.effect(req.player);
498
499       req.player.hp += hpGain;
500
501       if(req.player.hp > maxHp(req.player.constitution, req.player.level)) {
502         req.player.hp = maxHp(req.player.constitution, req.player.level);
503       }
504     break;
505   }
506
507   await updateItemCount(req.player.id, item.item_id, -1);
508   await updatePlayer(req.player);
509
510   const inventory = await getInventory(req.player.id);
511   const items = await getPlayersItems(req.player.id);
512
513   res.send(
514     [
515       renderPlayerBar(req.player),
516       renderInventoryPage(inventory, items, 'ITEMS'),
517       Alert.SuccessAlert(`You used the ${item.name}`)
518     ].join("")
519   );
520
521 });
522
523 app.get('/modal/items/:item_id', authEndpoint, async (req: Request, res: Response) => {
524   const item: PlayerItem = await getItemFromPlayer(req.player.id, parseInt(req.params.item_id));
525
526   if(!item) {
527     logger.log(`Invalid item [${req.params.item_id}]`);
528     return res.sendStatus(400);
529   }
530
531   let html = `
532 <dialog>
533   <div class="item-modal-overview">
534     <div class="icon">
535       <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}" alt="${item.name}"> 
536     </div>
537     <div>
538       <h4>${item.name}</h4>
539       <p>${item.description}</p>
540     </div>
541   </div>
542   <div class="actions">
543     <button hx-put="/item/${item.item_id}" formmethod="dialog" value="cancel" hx-target="#inventory" class="red">Use</button>
544     <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
545   </div>
546 </dialog>
547 `;
548
549   res.send(html);
550 });
551
552 app.get('/city/stores/city:stores/:location_id', authEndpoint, async (req: Request, res: Response) => {
553   const location = await getService(parseInt(req.params.location_id));
554
555   if(!location || location.city_id !== req.player.city_id) {
556     logger.log(`Invalid location: [${req.params.location_id}]`);
557     res.sendStatus(400);
558   }
559   const [shopEquipment, shopItems] = await Promise.all([
560     listShopItems({location_id: location.id}),
561     getShopItems(location.id),
562   ]);
563
564   const html = await renderStore(shopEquipment, shopItems, req.player, location);
565
566   res.send(html);
567 });
568
569 app.get('/city/explore/city:explore/:location_id', authEndpoint, async (req: Request, res: Response) => {
570   const location = await getService(parseInt(req.params.location_id));
571   if(!location || location.city_id !== req.player.city_id) {
572
573     logger.log(`Invalid location: [${req.params.location_id}]`);
574     res.sendStatus(400);
575   }
576
577   const monsters: Monster[] = await getMonsterList(location.id);
578   res.send(renderOnlyMonsterSelector(monsters, 0, location));
579 });
580
581 app.post('/travel', authEndpoint, async (req: Request, res: Response) => {
582   const destination_id = parseInt(req.body.destination_id);
583
584   if(!destination_id || isNaN(destination_id)) {
585     logger.log(`Invalid destination_id [${req.body.destination_id}]`);
586     return res.sendStatus(400);
587   }
588
589   const travelPlan = travel(req.player, req.body.destination_id);
590
591   res.json(travelPlan);
592 });
593
594 app.post('/fight/turn', authEndpoint, async (req: Request, res: Response) => {
595   const fightBlockKey = `fightturn:${req.player.id}`;
596
597   if(cache[fightBlockKey] && cache[fightBlockKey] > Date.now()) {
598     res.status(429).send(Alert.ErrorAlert('Hmm, you are fight too quickly'));
599     return;
600
601   }
602
603   cache[fightBlockKey] = Date.now() + CONSTANT.FIGHT_ATTACK_DELAY;
604   const monster = await loadMonsterFromFight(req.player.id);
605
606   if(!monster) {
607     res.send(Alert.ErrorAlert('Not in a fight'));
608     return;
609   }
610
611   const fightData  = await fightRound(req.player, monster, {
612     action: req.body.action
613   });
614
615
616   let html = renderFight(
617     monster,
618     renderRoundDetails(fightData.roundData),
619     fightData.roundData.winner === 'in-progress',
620     cache[fightBlockKey]
621   );
622
623   if(fightData.roundData.winner !== 'in-progress') {
624     delete cache[fightBlockKey];
625   }
626
627   if(fightData.monsters.length && monster.fight_trigger === 'explore') {
628     html += renderMonsterSelector(fightData.monsters, monster.ref_id);
629   }
630
631   let travelSection = '';
632   if(monster.fight_trigger === 'travel' && fightData.roundData.winner === 'player') {
633     // you're travellinga dn you won.. display the keep walking!
634     const travelPlan = await getTravelPlan(req.player.id);
635     const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
636     travelSection = travelButton(0);
637   }
638
639   const playerBar = renderPlayerBar(fightData.player);
640
641   res.send(html + travelSection + playerBar);
642 });
643
644 app.post('/fight', fightRateLimiter, authEndpoint, async (req: Request, res: Response) => {
645   if(req.player.hp <= 0) {
646     logger.log(`Player didn\'t have enough hp`);
647     return res.sendStatus(400);
648   }
649
650   const monsterId: number = req.body.monsterId;
651   const fightTrigger: FightTrigger = req.body.fightTrigger ?? 'travel';
652
653   if(!monsterId) {
654     logger.log(`Missing monster Id ${monsterId}`);
655     return res.sendStatus(400);
656   }
657
658   if(!fightTrigger || !['travel', 'explore'].includes(fightTrigger)) {
659     logger.log(`Invalid fight trigger [${fightTrigger}]`);
660     return res.sendStatus(400);
661   }
662
663   const monster = await loadMonster(monsterId);
664
665   if(!monster) {
666     logger.log(`Couldnt find monster for ${monsterId}`);
667     return res.sendStatus(400);
668   }
669
670   const fight = await createFight(req.player.id, monster, fightTrigger);
671   const location = await getService(monster.location_id);
672
673   res.send(renderFightPreRound(fight, true, location, location.city_id));
674 });
675
676 app.post('/travel/step', authEndpoint, async (req: Request, res: Response) => {
677   const stepTimerKey = `step:${req.player.id}`;
678
679   const travelPlan = await getTravelPlan(req.player.id);
680   if(!travelPlan) {
681     res.send(Alert.ErrorAlert('You don\'t have a travel plan'));
682     return;
683   }
684
685   if(cache[stepTimerKey]) {
686     if(cache[stepTimerKey] > Date.now()) {
687       res.send(Alert.ErrorAlert('Hmm.. travelling too quickly'));
688       return;
689     }
690   }
691
692   travelPlan.current_position++;
693
694   if(travelPlan.current_position >= travelPlan.total_distance) {
695     const travel = await completeTravel(req.player.id);
696
697     req.player.city_id = travel.destination_id;
698     await movePlayer(travel.destination_id, req.player.id);
699
700     const [city, locations, paths] = await Promise.all([
701       getCityDetails(travel.destination_id),
702       getAllServices(travel.destination_id),
703       getAllPaths(travel.destination_id)
704     ]);
705
706     delete cache[stepTimerKey];
707     res.send(await renderMap({city, locations, paths}, req.player.city_id));
708   }
709   else {
710     const walkingText: string[] = [
711       'You take a step forward',
712       'You keep moving'
713     ];
714     // update existing plan..
715     // decide if they will run into anything
716     const travelPlan = await stepForward(req.player.id);
717
718     const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
719
720     const chanceToSeeMonster = random(0, 100);
721     const things: any[] = [];
722     if(chanceToSeeMonster <= 30) {
723       const monster = await getRandomMonster([closest]);
724       things.push(monster);
725     }
726
727     // STEP_DELAY
728     const nextAction = Date.now() + CONSTANT.STEP_DELAY;
729
730     cache[stepTimerKey] = nextAction;
731
732     res.send(renderTravel({
733       things,
734       nextAction,
735       closestTown: closest,
736       walkingText: sample(walkingText),
737       travelPlan
738     }));
739
740   }
741 });
742
743 app.post('/travel/return-to-source', authEndpoint, async (req: Request, res: Response) => {
744   // puts the player back in their starting town
745   // doesn't matter if they don't have one
746   // redirect them!
747   await clearTravelPlan(req.player.id);
748
749   const fight = await loadMonsterFromFight(req.player.id);
750   if(fight) {
751     // go to the fight screen
752     const location = await getMonsterLocation(fight.ref_id);
753
754     res.send(renderPlayerBar(req.player) + renderFightPreRound(fight, true, location, req.player.city_id));
755   }
756   else {
757     const [city, locations, paths] = await Promise.all([
758       getCityDetails(req.player.city_id),
759       getAllServices(req.player.city_id),
760       getAllPaths(req.player.city_id)
761     ]);
762
763     res.send(renderPlayerBar(req.player) + await renderMap({city, locations, paths}, req.player.city_id));
764
765   }
766
767 });
768
769 app.post('/travel/:destination_id', authEndpoint, async (req: Request, res: Response) => {
770 if(req.player.hp <= 0) {
771     logger.log(`Player didn\'t have enough hp`);
772     res.send(Alert.ErrorAlert('Sorry, you need some HP to start travelling.'));
773     return;
774   }
775
776   const destination = await getCityDetails(parseInt(req.params.destination_id));
777
778   if(!destination) {
779     res.send(Alert.ErrorAlert(`Thats not a valid desination`));
780     return;
781   }
782
783   const travelPlan = await travel(req.player, destination.id);
784
785   res.send(renderTravel({
786     things: [],
787     nextAction: 0,
788     walkingText: '',
789     closestTown: req.player.city_id,
790     travelPlan
791   }));
792 });
793
794 app.get('/settings', authEndpoint, async (req: Request, res: Response) => {
795   let warning = '';
796   let html = '';
797   if(req.player.account_type === 'session') {
798     warning += `<div class="alert error">If you log out without signing up first, this account is lost forever.</div>`;
799   }
800
801   html += '<a href="#" hx-post="/logout">Logout</a>';
802   res.send(warning + html);
803 });
804
805 app.post('/logout', authEndpoint, async (req: Request, res: Response) => {
806   // ref to get the socket id for a particular player
807   cache.delete(`socket:${req.player.id}`);
808   // ref to get the player object
809   cache.delete(`token:${req.player.id}`);
810
811   logger.log(`${req.player.username} logged out`);
812
813   res.send('logout');
814 });
815
816
817 app.post('/auth', async (req: Request, res: Response) => {
818   if(req.body.authType === 'login') {
819     loginHandler(req, res);
820   }
821   else if(req.body.authType === 'signup') { 
822     signupHandler(req, res);
823   }
824   else {
825     logger.log(`Invalid auth type [${req.body.authType}]`);
826     res.sendStatus(400);
827   }
828 });
829
830
831 async function signupHandler(req: Request, res: Response) {
832   const {username, password} = req.body;
833   const authToken = req.headers['x-authtoken'];
834
835   if(!username || !password || !authToken) {
836     res.send(Alert.ErrorAlert('Invalid username/password'));
837     return;
838   }
839
840   try {
841     const player = await loadPlayer(authToken.toString());
842     logger.log(`Attempted claim for ${player.username}`);
843
844     await signup(authToken.toString(), username, password);
845
846     await db('players').where({id: player.id}).update({
847       account_type: 'auth',
848       username: username
849     });
850
851     logger.log(`${username} claimed ${player.username}`);
852
853     io.emit('chat', broadcastMessage('server', `${player.username} is now ${username}`));
854
855     res.setHeader('hx-refresh', 'true');
856     res.sendStatus(200);
857   }
858   catch(e) {
859     logger.log(e);
860     if(e?.constraint === 'players_username_unique') {
861       res.send(Alert.ErrorAlert('That username is already taken'));
862     }
863     else {
864       res.send(Alert.ErrorAlert('Please try again'));
865     }
866   }
867 }
868
869 async function loginHandler (req: Request, res: Response) {
870   const {username, password} = req.body;
871   if(!username || !username.length) {
872     res.send(Alert.ErrorAlert('Invalid username'));
873     return;
874   }
875   if(!password || !password.length) {
876     res.send(Alert.ErrorAlert('Invalid password'));
877     return;
878   }
879
880   try {
881     const player = await login(username, password);
882     io.sockets.sockets.forEach(socket => {
883       if(socket.handshake.headers['x-authtoken'] === req.headers['x-authtoken']) {
884         bootstrapSocket(socket, player);
885       }
886     });
887     res.sendStatus(204);
888   }
889   catch(e) {
890     console.log(e);
891     res.send(Alert.ErrorAlert('That user doesn\'t exist'));
892   }
893 }
894
895 server.listen(process.env.API_PORT, () => {
896   logger.log(`Listening on port ${process.env.API_PORT}`);
897 });