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