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