chore(release): 0.3.1
[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, 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 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
93   socket.emit('authToken', player.id);
94
95   socket.emit('chat', renderChatMessage(broadcastMessage('server', `${player.username} just logged in`)));
96 }
97
98 io.on('connection', async socket => {
99   logger.log(`socket ${socket.id} connected, authToken: ${socket.handshake.headers['x-authtoken']}`);
100
101   let authToken = socket.handshake.headers['x-authtoken'].toString() === 'null' ? null : socket.handshake.headers['x-authtoken'].toString();
102
103   let player: Player;
104   if(authToken) {
105     logger.log(`Attempting to load player with id ${authToken}`);
106     player = await loadPlayer(authToken);
107   }
108   if(!player) {
109     logger.log(`Creating player`);
110     player = await createPlayer();
111     authToken = player.id;
112     socket.handshake.headers['x-authtoken'] = authToken;
113   }
114
115   logger.log(`Socket [${socket.id}] auth token: ${player.id}`);
116
117   bootstrapSocket(socket, player);
118
119   socket.on('disconnect', () => {
120     console.log(`Player ${player.username} left`);
121     io.emit('status', `${io.sockets.sockets.size} Online (v${version})`);
122   });
123
124
125   io.emit('status', `${io.sockets.sockets.size} Online (v${version})`);
126   // this is a special event to let the client know it can start 
127   // requesting data
128   socket.emit('ready');
129 });
130
131
132 app.use(healerRouter);
133 app.use(professionRouter);
134 app.use(repairRouter);
135
136
137 app.get('/chat/history', authEndpoint, async (req: AuthRequest, res: Response) => {
138   let html = chatHistory.map(renderChatMessage);
139
140   res.send(html.join("\n"));
141 });
142
143 app.post('/chat', authEndpoint, async (req: AuthRequest, res: Response) => {
144   const msg = req.body.message.trim();
145
146   if(!msg || !msg.length) {
147     res.sendStatus(204);
148     return;
149   }
150
151   let message: Message;
152   if(msg.startsWith('/server lmnop')) {
153     if(msg === '/server lmnop refresh-monsters') {
154       await createMonsters();
155       message = broadcastMessage('server', 'Monster refresh!');
156     }
157     else if(msg === '/server lmnop refresh-cities') {
158       await createAllCitiesAndLocations();
159       message = broadcastMessage('server', 'Cities, Locations, and Paths refreshed!');
160     }
161     else if(msg === '/server lmnop refresh-shops') {
162       await createShopItems();
163       await createShopEquipment();
164       message = broadcastMessage('server', 'Refresh shop items');
165     }
166     else {
167       const str = msg.split('/server lmnop ')[1];
168       if(str) {
169         message = broadcastMessage('server', str);
170       }
171     }
172   }
173   else {
174     message = broadcastMessage(req.player.username, xss(msg, {
175       whiteList: {}
176     }));
177     chatHistory.push(message);
178     chatHistory.slice(-10);
179   }
180
181   if(message) {
182     io.emit('chat', renderChatMessage(message));
183     res.sendStatus(204);
184   }
185 });
186
187 app.get('/player', authEndpoint, async (req: AuthRequest, res: Response) => {
188   const equipment = await getEquippedItems(req.player.id);
189   res.send(renderPlayerBar(req.player) + renderProfilePage(req.player, equipment));
190 });
191
192 app.post('/player/stat/:stat', authEndpoint, async (req: AuthRequest, res: Response) => {
193   const equipment = await getEquippedItems(req.player.id);
194   const stat = req.params.stat;
195   if(!['strength', 'constitution', 'dexterity', 'intelligence'].includes(stat)) {
196     res.send(Alert.ErrorAlert(`Sorry, that's not a valid stat to increase`));
197     return;
198   }
199
200   if(req.player.stat_points <= 0) {
201     res.send(Alert.ErrorAlert(`Sorry, you don't have enough stat points`));
202     return;
203   }
204
205   req.player.stat_points -= 1;
206   req.player[stat]++;
207
208   req.player.hp = maxHp(req.player.constitution, req.player.level);
209   req.player.vigor = maxVigor(req.player.constitution, req.player.level);
210   updatePlayer(req.player);
211
212   res.send(renderPlayerBar(req.player) + renderProfilePage(req.player, equipment));
213 });
214
215 app.get('/player/skills', authEndpoint, async (req: AuthRequest, res: Response) => {
216   const skills = await getPlayerSkills(req.player.id);
217
218   res.send(renderSkills(skills));
219 });
220
221 app.get('/player/inventory', authEndpoint, async (req: AuthRequest, res: Response) => {
222   const [inventory, items] = await Promise.all([
223     getInventory(req.player.id),
224     getPlayersItems(req.player.id)
225   ]);
226
227   res.send(renderInventoryPage(inventory, items));
228 });
229
230 app.post('/player/equip/:item_id/:slot', authEndpoint, blockPlayerInFight, async (req: AuthRequest, res: Response) => {
231   const inventoryItem = await getInventoryItem(req.player.id, req.params.item_id);
232   const equippedItems = await getEquippedItems(req.player.id);
233   const requestedSlot = req.params.slot;
234   let desiredSlot: EquipmentSlot = inventoryItem.equipment_slot;
235
236   try {
237     // handes the situation where you're trying to equip an item 
238     // that can be equipped to any hand
239     if(inventoryItem.equipment_slot === 'ANY_HAND') {
240       if(requestedSlot === 'LEFT_HAND' || requestedSlot === 'RIGHT_HAND') {
241         // get the players current equipment in that slot!
242         if(equippedItems.some(v => {
243           return v.equipment_slot === requestedSlot || v.equipment_slot === 'TWO_HANDED';
244         })) {
245           throw new Error();
246         }
247         else {
248           desiredSlot = requestedSlot;
249         }
250       }
251     }
252
253     if(requestedSlot === 'TWO_HANDED') {
254       if(equippedItems.some(v => {
255         return v.equipment_slot === 'LEFT_HAND' || v.equipment_slot === 'RIGHT_HAND';
256       })) {
257         throw new Error();
258       }
259     }
260
261
262     await equip(req.player.id, inventoryItem, desiredSlot);
263     const socketId = cache.get(`socket:${req.player.id}`).toString();
264     io.to(socketId).emit('updatePlayer', req.player);
265     io.to(socketId).emit('alert', {
266       type: 'success',
267       text: `You equipped your ${inventoryItem.name}`
268     });
269   }
270   catch(e) {
271     logger.log(e);
272   }
273
274   const [inventory, items] = await Promise.all([
275     getInventory(req.player.id),
276     getPlayersItems(req.player.id)
277   ]);
278
279   res.send(renderInventoryPage(inventory, items, inventoryItem.type) + renderPlayerBar(req.player));
280 });
281
282 app.post('/player/unequip/:item_id', authEndpoint, blockPlayerInFight, async (req: AuthRequest, res: Response) => {
283   const [item, ] = await Promise.all([
284     getInventoryItem(req.player.id, req.params.item_id),
285     unequip(req.player.id, req.params.item_id)
286   ]);
287
288   const [inventory, items] = await Promise.all([
289     getInventory(req.player.id),
290     getPlayersItems(req.player.id)
291   ]);
292
293   res.send(renderInventoryPage(inventory, items, item.type) + renderPlayerBar(req.player));
294 });
295
296 app.get('/player/explore', authEndpoint, async (req: AuthRequest, res: Response) => {
297   const fight = await loadMonsterFromFight(req.player.id);
298   const travelPlan = await getTravelPlan(req.player.id);
299   let closestTown = req.player.city_id;
300
301   if(travelPlan) {
302       closestTown = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
303   }
304
305   if(fight) {
306     const data: MonsterForFight = {
307       id: fight.id,
308       hp: fight.hp,
309       maxHp: fight.maxHp,
310       name: fight.name,
311       level: fight.level,
312       fight_trigger: fight.fight_trigger
313     };
314     const location = await getMonsterLocation(fight.ref_id);
315
316
317     res.send(renderPlayerBar(req.player) + renderFightPreRound(data, true, location, closestTown));
318   }
319   else {
320     if(travelPlan) {
321       // traveling!
322       const chanceToSeeMonster = random(0, 100);
323       const things: any[] = [];
324       if(chanceToSeeMonster <= 30) {
325         const monster = await getRandomMonster([closestTown]);
326         things.push(monster);
327       }
328
329       // STEP_DELAY
330       const nextAction = cache[`step:${req.player.id}`] || 0;
331
332       res.send(renderPlayerBar(req.player) + renderTravel({
333         things,
334         nextAction,
335         closestTown: closestTown,
336         walkingText: '',
337         travelPlan
338       }));
339     }
340     else {
341       // display the city info!
342       const [city, locations, paths] = await Promise.all([
343         getCityDetails(req.player.city_id),
344         getAllServices(req.player.city_id),
345         getAllPaths(req.player.city_id)
346       ]);
347
348       res.send(renderPlayerBar(req.player) + await renderMap({city, locations, paths}, closestTown));
349     }
350
351   }
352 });
353
354 // used to purchase equipment from a particular shop
355 app.put('/location/:location_id/equipment/:item_id', authEndpoint, async (req: AuthRequest, res: Response) => {
356   const item = await getShopEquipment(parseInt(req.params.item_id), parseInt(req.params.location_id));
357
358   if(!item) {
359     logger.log(`Invalid item [${req.params.item_id}]`);
360     return res.sendStatus(400);
361   }
362
363   if(req.player.gold < item.cost) {
364     res.send(Alert.ErrorAlert(`Sorry, you need at least ${item.cost.toLocaleString()}G to purchase this.`));
365     return;
366   }
367
368   req.player.gold -= item.cost;
369
370   await updatePlayer(req.player);
371   await addInventoryItem(req.player.id, item);
372
373   res.send(renderPlayerBar(req.player) + 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   res.send(renderPlayerBar(req.player) + Alert.SuccessAlert(`You purchased a ${item.name}`));
396 });
397
398 // used to display equipment modals in a store, validates that 
399 // the equipment is actually in this store before displaying 
400 // the modal
401 app.get('/location/:location_id/equipment/:item_id/overview', authEndpoint, async (req: AuthRequest, res: Response) => {
402   const equipment = await getShopEquipment(parseInt(req.params.item_id), parseInt(req.params.location_id));
403
404   if(!equipment) {
405     logger.log(`Invalid equipment [${req.params.item_id}]`);
406     return res.sendStatus(400);
407   }
408
409   let html = `
410 <dialog>
411   <div class="item-modal-overview">
412     <div class="icon">
413       <img src="${equipment.icon ? `/assets/img/icons/equipment/${equipment.icon}` : 'https://via.placeholder.com/64x64'}" title="${equipment.name}" alt="${equipment.name}"> 
414     </div>
415     <div>
416       ${renderEquipmentDetails(equipment, req.player)}
417     </div>
418   </div>
419   <div class="actions">
420     <button hx-put="/location/${equipment.location_id}/equipment/${equipment.id}" formmethod="dialog" value="cancel" class="green">Buy</button>
421     <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
422   </div>
423 </dialog>
424 `;
425
426   res.send(html);
427 });
428
429 // used to display item modals in a store, validates that 
430 // the item is actually in this store before displaying 
431 // the modal
432 app.get('/location/:location_id/items/:item_id/overview', authEndpoint, async (req: AuthRequest, res: Response) => {
433   const item: (ShopItem & Item) = await getItemFromShop(parseInt(req.params.item_id), parseInt(req.params.location_id));
434
435   if(!item) {
436     logger.log(`Invalid item [${req.params.item_id}]`);
437     return res.sendStatus(400);
438   }
439
440   let html = `
441 <dialog>
442   <div class="item-modal-overview">
443     <div class="icon">
444       <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}" alt="${item.name}"> 
445     </div>
446     <div>
447       <h4>${item.name}</h4>
448       <p>${item.description}</p>
449     </div>
450   </div>
451   <div class="actions">
452     <button hx-put="/location/${item.location_id}/items/${item.id}" formmethod="dialog" value="cancel" class="red">Buy</button>
453     <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
454   </div>
455 </dialog>
456 `;
457
458   res.send(html);
459 });
460
461 app.put('/item/:item_id', authEndpoint, async (req: AuthRequest, res: Response) => {
462   const item: PlayerItem = await getItemFromPlayer(req.player.id, parseInt(req.params.item_id));
463
464   if(!item) {
465     console.log(`Can't find item [${req.params.item_id}]`);
466     return;
467   }
468
469   if(item.amount < 1) {
470     res.send(Alert.ErrorAlert(`You dont have enough ${item.name}`));
471     return;
472   }
473
474   item.amount -= 1;
475
476   switch(item.effect_name) {
477     case 'heal_small':
478       const hpGain = HealthPotionSmall.effect(req.player);
479
480       req.player.hp += hpGain;
481
482       if(req.player.hp > maxHp(req.player.constitution, req.player.level)) {
483         req.player.hp = maxHp(req.player.constitution, req.player.level);
484       }
485     break;
486   }
487
488   await updateItemCount(req.player.id, item.item_id, -1);
489   await updatePlayer(req.player);
490
491   const inventory = await getInventory(req.player.id);
492   const items = await getPlayersItems(req.player.id);
493
494   res.send(
495     [
496       renderPlayerBar(req.player),
497       renderInventoryPage(inventory, items, 'ITEMS'),
498       Alert.SuccessAlert(`You used the ${item.name}`)
499     ].join("")
500   );
501
502 });
503
504 app.get('/modal/items/:item_id', authEndpoint, async (req: AuthRequest, res: Response) => {
505   const item: PlayerItem = await getItemFromPlayer(req.player.id, parseInt(req.params.item_id));
506
507   if(!item) {
508     logger.log(`Invalid item [${req.params.item_id}]`);
509     return res.sendStatus(400);
510   }
511
512   let html = `
513 <dialog>
514   <div class="item-modal-overview">
515     <div class="icon">
516       <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}" alt="${item.name}"> 
517     </div>
518     <div>
519       <h4>${item.name}</h4>
520       <p>${item.description}</p>
521     </div>
522   </div>
523   <div class="actions">
524     <button hx-put="/item/${item.item_id}" formmethod="dialog" value="cancel" hx-target="#inventory" class="red">Use</button>
525     <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
526   </div>
527 </dialog>
528 `;
529
530   res.send(html);
531 });
532
533 app.get('/city/stores/city:stores/:location_id', authEndpoint, async (req: AuthRequest, res: Response) => {
534   const location = await getService(parseInt(req.params.location_id));
535
536   if(!location || location.city_id !== req.player.city_id) {
537     logger.log(`Invalid location: [${req.params.location_id}]`);
538     res.sendStatus(400);
539   }
540   const [shopEquipment, shopItems] = await Promise.all([
541     listShopItems({location_id: location.id}),
542     getShopItems(location.id),
543   ]);
544
545   const html = await renderStore(shopEquipment, shopItems, req.player, location);
546
547   res.send(html);
548 });
549
550 app.get('/city/explore/city:explore/:location_id', authEndpoint, async (req: AuthRequest, res: Response) => {
551   const location = await getService(parseInt(req.params.location_id));
552   if(!location || location.city_id !== req.player.city_id) {
553
554     logger.log(`Invalid location: [${req.params.location_id}]`);
555     res.sendStatus(400);
556   }
557
558   const monsters: Monster[] = await getMonsterList(location.id);
559   res.send(renderOnlyMonsterSelector(monsters, 0, location));
560 });
561
562 app.post('/travel', authEndpoint, async (req: AuthRequest, res: Response) => {
563   const destination_id = parseInt(req.body.destination_id);
564
565   if(!destination_id || isNaN(destination_id)) {
566     logger.log(`Invalid destination_id [${req.body.destination_id}]`);
567     return res.sendStatus(400);
568   }
569
570   const travelPlan = travel(req.player, req.body.destination_id);
571
572   res.json(travelPlan);
573 });
574
575 app.post('/fight/turn', authEndpoint, async (req: AuthRequest, res: Response) => {
576   const fightBlockKey = `fightturn:${req.player.id}`;
577
578   if(cache[fightBlockKey] && cache[fightBlockKey] > Date.now()) {
579     res.status(429).send(Alert.ErrorAlert('Hmm, you are fight too quickly'));
580     return;
581
582   }
583
584   cache[fightBlockKey] = Date.now() + CONSTANT.FIGHT_ATTACK_DELAY;
585   const monster = await loadMonsterWithFaction(req.player.id);
586
587   if(!monster) {
588     res.send(Alert.ErrorAlert('Not in a fight'));
589     return;
590   }
591
592   const fightData  = await fightRound(req.player, monster, {
593     action: req.body.action,
594     target: req.body.fightTarget
595   });
596
597
598   let html = renderFight(
599     monster,
600     renderRoundDetails(fightData.roundData),
601     fightData.roundData.winner === 'in-progress',
602     cache[fightBlockKey]
603   );
604
605   if(fightData.roundData.winner !== 'in-progress') {
606     delete cache[fightBlockKey];
607   }
608
609   if(fightData.monsters.length && monster.fight_trigger === 'explore') {
610     html += renderMonsterSelector(fightData.monsters, monster.ref_id);
611   }
612
613   let travelSection = '';
614   if(monster.fight_trigger === 'travel' && fightData.roundData.winner === 'player') {
615     // you're travellinga dn you won.. display the keep walking!
616     const travelPlan = await getTravelPlan(req.player.id);
617     const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
618     travelSection = travelButton(0);
619   }
620
621   const playerBar = renderPlayerBar(fightData.player);
622
623   res.send(html + travelSection + playerBar);
624 });
625
626 app.post('/fight', fightRateLimiter, authEndpoint, async (req: AuthRequest, res: Response) => {
627   if(req.player.hp <= 0) {
628     logger.log(`Player didn\'t have enough hp`);
629     return res.sendStatus(400);
630   }
631
632   const monsterId: number = req.body.monsterId;
633   const fightTrigger: FightTrigger = req.body.fightTrigger ?? 'travel';
634
635   if(!monsterId) {
636     logger.log(`Missing monster Id ${monsterId}`);
637     return res.sendStatus(400);
638   }
639
640   if(!fightTrigger || !['travel', 'explore'].includes(fightTrigger)) {
641     logger.log(`Invalid fight trigger [${fightTrigger}]`);
642     return res.sendStatus(400);
643   }
644
645   const monster = await loadMonster(monsterId);
646
647   if(!monster) {
648     logger.log(`Couldnt find monster for ${monsterId}`);
649     return res.sendStatus(400);
650   }
651
652   const fight = await createFight(req.player.id, monster, fightTrigger);
653   const location = await getService(monster.location_id);
654
655
656   const data: MonsterForFight = {
657     id: fight.id,
658     hp: fight.hp,
659     maxHp: fight.maxHp,
660     name: fight.name,
661     level: fight.level,
662     fight_trigger: fight.fight_trigger
663   };
664
665   res.send(renderFightPreRound(data, true, location, location.city_id));
666 });
667
668 app.post('/travel/step', authEndpoint, async (req: AuthRequest, res: Response) => {
669   const stepTimerKey = `step:${req.player.id}`;
670
671   const travelPlan = await getTravelPlan(req.player.id);
672   if(!travelPlan) {
673     res.send(Alert.ErrorAlert('You don\'t have a travel plan'));
674     return;
675   }
676
677   if(cache[stepTimerKey]) {
678     if(cache[stepTimerKey] > Date.now()) {
679       res.send(Alert.ErrorAlert('Hmm.. travelling too quickly'));
680       return;
681     }
682   }
683
684   travelPlan.current_position++;
685
686   if(travelPlan.current_position >= travelPlan.total_distance) {
687     const travel = await completeTravel(req.player.id);
688
689     req.player.city_id = travel.destination_id;
690     await movePlayer(travel.destination_id, req.player.id);
691
692     const [city, locations, paths] = await Promise.all([
693       getCityDetails(travel.destination_id),
694       getAllServices(travel.destination_id),
695       getAllPaths(travel.destination_id)
696     ]);
697
698     delete cache[stepTimerKey];
699     res.send(await renderMap({city, locations, paths}, req.player.city_id));
700   }
701   else {
702     const walkingText: string[] = [
703       'You take a step forward',
704       'You keep moving'
705     ];
706     // update existing plan..
707     // decide if they will run into anything
708     const travelPlan = await stepForward(req.player.id);
709
710     const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
711
712     const chanceToSeeMonster = random(0, 100);
713     const things: any[] = [];
714     if(chanceToSeeMonster <= 30) {
715       const monster = await getRandomMonster([closest]);
716       things.push(monster);
717     }
718
719     // STEP_DELAY
720     const nextAction = Date.now() + CONSTANT.STEP_DELAY;
721
722     cache[stepTimerKey] = nextAction;
723
724     res.send(renderTravel({
725       things,
726       nextAction,
727       closestTown: closest,
728       walkingText: sample(walkingText),
729       travelPlan
730     }));
731
732   }
733 });
734
735 app.post('/travel/return-to-source', authEndpoint, async (req: AuthRequest, res: Response) => {
736   // puts the player back in their starting town
737   // doesn't matter if they don't have one
738   // redirect them!
739   await clearTravelPlan(req.player.id);
740
741   const fight = await loadMonsterFromFight(req.player.id);
742   if(fight) {
743     // go to the fight screen
744     const data: MonsterForFight = {
745       id: fight.id,
746       hp: fight.hp,
747       maxHp: fight.maxHp,
748       name: fight.name,
749       level: fight.level,
750       fight_trigger: fight.fight_trigger
751     };
752     const location = await getMonsterLocation(fight.ref_id);
753
754     res.send(renderPlayerBar(req.player) + renderFightPreRound(data, 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: AuthRequest, 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: AuthRequest, 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: AuthRequest, 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 });