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