chore(release): 0.2.5
[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
8 import http from 'http';
9 import { Server, Socket } from 'socket.io';
10 import { logger } from './lib/logger';
11 import { loadPlayer, createPlayer, updatePlayer, movePlayer } from './player';
12 import { random, sample } from 'lodash';
13 import {broadcastMessage, Message} from '../shared/message';
14 import {expToLevel, maxHp, Player} from '../shared/player';
15 import {clearFight, createFight, getMonsterList, getRandomMonster, loadMonster, loadMonsterFromFight, loadMonsterWithFaction, saveFightState} from './monster';
16 import {FightRound} from '../shared/fight';
17 import {addInventoryItem, deleteInventoryItem, getEquippedItems, getInventory, getInventoryItem, updateAp} from './inventory';
18 import { getItemFromPlayer, getItemFromShop, getPlayersItems, getShopItems, givePlayerItem, updateItemCount } from './items';
19 import {FightTrigger, Monster, MonsterForFight, MonsterWithFaction} from '../shared/monsters';
20 import {getShopEquipment, getShopItem, listShopItems } from './shopEquipment';
21 import {EquippedItemDetails} from '../shared/equipped';
22 import {ArmourEquipmentSlot, EquipmentSlot} from '../shared/inventory';
23 import { clearTravelPlan, completeTravel, getAllPaths, getAllServices, getCityDetails, getService, getTravelPlan, stepForward, travel } from './map';
24 import { signup, login, authEndpoint } from './auth';
25 import {db} from './lib/db';
26 import { getPlayerSkills, getPlayerSkillsAsObject, updatePlayerSkills } from './skills';
27 import {SkillID, Skills} from '../shared/skills';
28
29 import  { router as healerRouter } from './locations/healer';
30
31 import * as Alert from './views/alert';
32 import { renderPlayerBar } from './views/player-bar'
33 import { renderEquipmentDetails, renderStore } from './views/stores';
34 import { renderMap } from './views/map';
35 import { renderProfilePage } from './views/profile';
36 import { renderSkills } from './views/skills';
37 import { renderInventoryPage } from './views/inventory';
38 import { renderMonsterSelector } from './views/monster-selector';
39 import { renderFight, renderRoundDetails } from './views/fight';
40 import { renderTravel, travelButton } from './views/travel';
41 import { renderChatMessage } from './views/chat';
42
43 // TEMP!
44 import { createMonsters } from '../../seeds/monsters';
45 import { createAllCitiesAndLocations } from '../../seeds/cities';
46 import { createShopItems } from '../../seeds/shop_items';
47 import { Item, PlayerItem, ShopItem } from 'shared/items';
48 import { equip, unequip } from './equipment';
49 import { HealthPotionSmall } from '../shared/items/health_potion';
50
51 dotenv();
52
53 otel.s();
54
55 const app = express();
56 const server = http.createServer(app);
57
58 app.use(express.static(join(__dirname, '..', '..', 'public')));
59 app.use(bodyParser.urlencoded({ extended: true }));
60 app.use(express.json());
61
62 const io = new Server(server);
63
64 const cache = new Map<string, any>();
65 const chatHistory: Message[] = [];
66
67 app.use((req, res, next) => {
68   console.log(req.method, req.url);
69   next();
70 });
71
72 io.on('connection', async socket => {
73   logger.log(`socket ${socket.id} connected, authToken: ${socket.handshake.headers['x-authtoken']}`);
74
75   let authToken = socket.handshake.headers['x-authtoken'].toString() === 'null' ? null : socket.handshake.headers['x-authtoken'].toString();
76
77   let player: Player;
78   if(authToken) {
79     logger.log(`Attempting to load player with id ${authToken}`);
80     player = await loadPlayer(authToken);
81   }
82   if(!player) {
83     logger.log(`Creating player`);
84     player = await createPlayer();
85     authToken = player.id;
86     socket.handshake.headers['x-authtoken'] = authToken;
87   }
88
89   logger.log(`Socket [${socket.id}] auth token: ${player.id}`);
90
91   // ref to get the socket id for a particular player
92   cache.set(`socket:${player.id}`, socket.id);
93   // ref to get the player object
94   cache.set(`token:${player.id}`, player);
95
96   socket.emit('authToken', player.id);
97
98   socket.emit('chat', broadcastMessage('server', `${player.username} just logged in`));
99
100   socket.on('disconnect', () => {
101     console.log(`Player ${player.username} left`);
102   });
103
104   socket.on('logout', async () => {
105     // clear this player from the cache!
106     const player = cache.get(`token:${socket.handshake.headers['x-authtoken']}`);
107     if(player) {
108       logger.log(`Player ${player.username} logged out`);
109     }
110     else {
111       logger.log(`Invalid user logout`);
112     }
113   });
114
115   // this is a special event to let the client know it can start 
116   // requesting data
117   socket.emit('ready');
118 });
119
120 async function fightRound(player: Player, monster: MonsterWithFaction,  data: {action: 'attack' | 'cast' | 'flee', target: 'head' | 'body' | 'arms' | 'legs'}) {
121   const playerSkills = await getPlayerSkillsAsObject(player.id);
122   const roundData: FightRound = {
123     monster,
124     player,
125     winner: 'in-progress',
126     fightTrigger: monster.fight_trigger,
127     roundDetails: [],
128     rewards: {
129       exp: 0,
130       gold: 0,
131       levelIncrease: false
132     }
133   };
134   const equippedItems = await getEquippedItems(player.id);
135
136   // we only use this if the player successfully defeated the monster 
137   // they were fighting, then we load the other monsters in this area 
138   // so they can "fight again"
139   let potentialMonsters: MonsterForFight[] = [];
140
141   /*
142    * cumulative chance of head/arms/body/legs
143    * 0 -> 0.2 = head
144    * 0.21 -> 0.4 = arms
145    *
146    * we use the factor to decide how many decimal places 
147    * we care about
148    */
149   const factor = 100;
150   const monsterTarget = [0.2, 0.4, 0.9, 1];
151   const targets: ArmourEquipmentSlot[] = ['HEAD', 'CHEST', 'ARMS', 'LEGS'];
152   // calc weighted
153   const rand = Math.ceil(Math.random() * factor);
154   let target: ArmourEquipmentSlot = 'CHEST';
155   monsterTarget.forEach((i, idx) => {
156     if (rand > (i * factor)) {
157       target = targets[idx] as ArmourEquipmentSlot;
158     }
159   });
160
161   const boost = {
162     strength: 0,
163     constitution: 0,
164     dexterity: 0,
165     intelligence: 0,
166     damage: 0,
167     hp: 0,
168   };
169
170   const equipment: Map<EquipmentSlot, EquippedItemDetails> = new Map<EquipmentSlot, EquippedItemDetails>();
171   const weapons: EquippedItemDetails[] = [];
172   let anyDamageSpells: boolean = false;
173   equippedItems.forEach(item => {
174     if(item.type === 'ARMOUR') {
175       equipment.set(item.equipment_slot, item);
176     }
177     else if(item.type === 'WEAPON') {
178       weapons.push(item);
179     }
180     else if(item.type === 'SPELL') {
181       if(item.affectedSkills.includes('destruction_magic')) {
182         anyDamageSpells = true;
183       }
184       weapons.push(item);
185     }
186
187     boost.strength += item.boosts.strength;
188     boost.constitution += item.boosts.constitution;
189     boost.dexterity += item.boosts.dexterity;
190     boost.intelligence += item.boosts.intelligence;
191
192     if(item.type === 'SPELL' && item.affectedSkills.includes('restoration_magic')) {
193       boost.hp += item.boosts.damage;
194     }
195     else {
196       boost.damage += item.boosts.damage;
197     }
198   });
199
200   // if you flee'd, then we want to check your dex vs. the monsters
201   // but we want to give you the item/weapon boosts you need
202   // if not then you're going to get hit.
203   if(data.action === 'flee') {
204     roundData.roundDetails.push(`You managed to escape from the ${monster.name}!`)
205     roundData.winner = 'monster';
206     await clearFight(player.id);
207
208     return { roundData, monsters: [], player };
209   }
210
211   const attackType = data.action === 'attack' ? 'physical' : 'magical';
212   const primaryStat = data.action === 'attack' ? player.strength : player.intelligence;
213   const boostStat = data.action === 'attack' ? boost.strength : boost.intelligence;
214
215   const playerDamage = Math.floor(((primaryStat + boostStat) * 1.3) + boost.damage);
216   const skillsUsed: Record<SkillID | any, number> = {};
217   let hpHealAfterMasteries: number = -1;
218   let playerDamageAfterMasteries: number = 0;
219   // apply masteries!
220   weapons.forEach(item => {
221     item.affectedSkills.forEach(id => {
222       if(id === 'restoration_magic') {
223         if(hpHealAfterMasteries < 0) {
224           hpHealAfterMasteries = 0;
225         }
226         hpHealAfterMasteries += Skills.get(id).effect(playerSkills.get(id));
227       }
228       else {
229         playerDamageAfterMasteries += playerDamage * Skills.get(id).effect(playerSkills.get(id));
230       }
231
232       if(!skillsUsed[id]) {
233         skillsUsed[id] = 0;
234       }
235       skillsUsed[id]++;
236     });
237   });
238
239   await updatePlayerSkills(player.id, skillsUsed);
240
241   const playerFinalDamage = (data.action === 'cast' && !anyDamageSpells) ? 0 : Math.floor(playerDamage + playerDamageAfterMasteries);
242   const playerFinalHeal = Math.floor(boost.hp + hpHealAfterMasteries);
243
244   roundData.roundDetails.push(`You targeted the monsters ${data.target.toUpperCase()} with ${attackType} damage!`);
245   let armourKey: string;
246   switch(data.target) {
247     case 'arms':
248       armourKey = 'armsAp';
249       break;
250     case 'head':
251       armourKey = 'helmAp';
252       break;
253     case 'legs':
254       armourKey = 'legsAp';
255       break;
256     case 'body':
257       armourKey = 'chestAp';
258       break;
259   }
260
261   if(monster[armourKey] && monster[armourKey] > 0) {
262     monster[armourKey] -= playerFinalDamage;
263
264     roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
265     if(monster[armourKey] < 0) {
266       roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
267       roundData.roundDetails.push(`You dealt ${monster[armourKey] * -1} damage to their HP`);
268       monster.hp += monster[armourKey];
269       monster[armourKey] = 0;
270     }
271   }
272   else {
273     roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
274     monster.hp -= playerFinalDamage;
275   }
276
277   if(monster.hp <= 0) {
278     roundData.monster.hp = 0;
279     roundData.winner = 'player';
280
281     roundData.rewards.exp = monster.exp;
282     roundData.rewards.gold = monster.gold;
283
284     player.gold += monster.gold;
285     player.exp += monster.exp;
286
287     if(player.exp >= expToLevel(player.level + 1)) {
288       player.exp -= expToLevel(player.level + 1)
289       player.level++;
290       roundData.rewards.levelIncrease = true;
291       let statPointsGained = 1;
292
293       if(player.profession !== 'Wanderer') {
294         statPointsGained = 2;
295       }
296
297       player.stat_points += statPointsGained;
298
299       roundData.roundDetails.push(`You gained ${statPointsGained} stat points!`);
300
301       player.hp = maxHp(player.constitution, player.level);
302     }
303     // get the monster location if it was an EXPLORED fight
304     if(roundData.fightTrigger === 'explore') {
305       const rawMonster = await loadMonster(monster.ref_id);
306       const monsterList  = await getMonsterList(rawMonster.location_id);
307       potentialMonsters = monsterList.map(monster => {
308         return {
309           id: monster.id,
310           name: monster.name,
311           level: monster.level,
312           hp: monster.hp,
313           maxHp: monster.maxHp,
314           fight_trigger: 'explore'
315         }
316       });
317     }
318
319     await clearFight(player.id);
320     await updatePlayer(player);
321     return { roundData, monsters: potentialMonsters, player };
322   }
323
324   roundData.roundDetails.push(`The ${monster.name} targeted your ${target}!`);
325   if(equipment.has(target)) {
326     const item = equipment.get(target);
327     // apply mitigation!
328     const mitigationPercentage = item.boosts.damage_mitigation || 0;
329     const damageAfterMitigation = Math.floor(monster.strength * ((100-mitigationPercentage)/100));
330
331     item.currentAp -= damageAfterMitigation;
332
333     if(item.currentAp < 0) {
334       roundData.roundDetails.push(`Your ${item.name} amour was destroyed`);
335       roundData.roundDetails.push(`The ${monster.name} hit your HP for ${item.currentAp * -1} damage!`);
336       player.hp += item.currentAp;
337       item.currentAp = 0;
338       await deleteInventoryItem(player.id, item.item_id);
339     }
340     else {
341       roundData.roundDetails.push(`Your ${target} took ${damageAfterMitigation} damage!`);
342       await updateAp(player.id, item.item_id, item.currentAp, item.maxAp);
343     }
344
345   }
346   else {
347     roundData.roundDetails.push(`The ${monster.name} hit you for ${monster.strength} damage`);
348     player.hp -= monster.strength;
349   }
350
351   if(playerFinalHeal > 0) {
352     player.hp += playerFinalHeal;
353     if(player.hp > maxHp(player.constitution, player.level)) {
354       player.hp = maxHp(player.constitution, player.level);
355     }
356     roundData.roundDetails.push(`You healed for ${playerFinalHeal} HP`);
357   }
358
359   // update the players inventory for this item!
360
361   if(player.hp <= 0) {
362     player.hp = 0;
363     roundData.winner = 'monster';
364
365     roundData.roundDetails.push(`You were killed by the ${monster.name}`);
366
367     await clearFight(player.id);
368     await updatePlayer(player);
369     await clearTravelPlan(player.id);
370
371     return { roundData, monsters: [], player};
372   }
373
374   await updatePlayer(player);
375   await saveFightState(player.id, monster);
376
377   return { roundData, monsters: [], player};
378 };
379
380 app.use(healerRouter);
381
382
383 app.get('/chat/history', authEndpoint, async (req: Request, res: Response) => {
384   const authToken = req.headers['x-authtoken'].toString();
385   const player: Player = await loadPlayer(authToken)
386
387   if(!player) {
388     logger.log(`Couldnt find player with id ${authToken}`);
389     return res.sendStatus(400);
390   }
391
392   let html = chatHistory.map(renderChatMessage);
393
394   res.send(html.join("\n"));
395 });
396
397 app.post('/chat', authEndpoint, async (req: Request, res: Response) => {
398   const authToken = req.headers['x-authtoken'].toString();
399   const player: Player = await loadPlayer(authToken)
400
401   if(!player) {
402     logger.log(`Couldnt find player with id ${authToken}`);
403     return res.sendStatus(400);
404   }
405
406   const msg = req.body.message.trim();
407
408   if(!msg || !msg.length) {
409     res.sendStatus(204);
410     return;
411   }
412
413   let message: Message;
414   if(msg.startsWith('/server lmnop')) {
415     if(msg === '/server lmnop refresh-monsters') {
416       await createMonsters();
417       message = broadcastMessage('server', 'Monster refresh!');
418     }
419     else if(msg === '/server lmnop refresh-cities') {
420       await createAllCitiesAndLocations();
421       message = broadcastMessage('server', 'Cities, Locations, and Paths refreshed!');
422     }
423     else if(msg === '/server lmnop refresh-shops') {
424       await createShopItems();
425       message = broadcastMessage('server', 'Refresh shop items');
426     }
427     else {
428       const str = msg.split('/server lmnop ')[1];
429       if(str) {
430         message = broadcastMessage('server', str);
431       }
432     }
433
434
435   }
436   else {
437     message = broadcastMessage(player.username, msg);
438     chatHistory.push(message);
439     chatHistory.slice(-10);
440
441     io.emit('chat', message);
442     res.sendStatus(204);
443   }
444
445   if(message) {
446     io.emit('chat', message);
447   }
448
449 });
450
451 app.get('/player', authEndpoint, async (req: Request, res: Response) => {
452   const authToken = req.headers['x-authtoken'].toString();
453   const player: Player = await loadPlayer(authToken)
454
455   if(!player) {
456     logger.log(`Couldnt find player with id ${authToken}`);
457     return res.sendStatus(400);
458   }
459
460   const inventory = await getEquippedItems(player.id);
461
462   res.send(renderPlayerBar(player, inventory) + (await renderProfilePage(player)));
463 });
464
465 app.get('/player/skills', authEndpoint, async (req: Request, res: Response) => {
466   const authToken = req.headers['x-authtoken'].toString();
467   const player: Player = await loadPlayer(authToken)
468
469   if(!player) {
470     logger.log(`Couldnt find player with id ${authToken}`);
471     return res.sendStatus(400);
472   }
473
474   const skills = await getPlayerSkills(player.id);
475
476   res.send(renderSkills(skills));
477 });
478
479 app.get('/player/inventory', authEndpoint, async (req: Request, res: Response) => {
480   const authToken = req.headers['x-authtoken'].toString();
481   const player: Player = await loadPlayer(authToken)
482
483   if(!player) {
484     logger.log(`Couldnt find player with id ${authToken}`);
485     return res.sendStatus(400);
486   }
487
488   const [inventory, items] = await Promise.all([
489     getInventory(player.id),
490     getPlayersItems(player.id)
491   ]);
492
493   res.send(renderInventoryPage(inventory, items));
494 });
495
496 app.post('/player/equip/:item_id/:slot', authEndpoint, async (req: Request, res: Response) => {
497   const authToken = req.headers['x-authtoken'].toString();
498   const player: Player = await loadPlayer(authToken)
499
500   if(!player) {
501     logger.log(`Couldnt find player with id ${authToken}`);
502     return res.sendStatus(400);
503   }
504
505   const inventoryItem = await getInventoryItem(player.id, req.params.item_id);
506   const equippedItems = await getEquippedItems(player.id);
507   const requestedSlot = req.params.slot;
508   let desiredSlot: EquipmentSlot = inventoryItem.equipment_slot;
509
510   try {
511     // handes the situation where you're trying to equip an item 
512     // that can be equipped to any hand
513     if(inventoryItem.equipment_slot === 'ANY_HAND') {
514       if(requestedSlot === 'LEFT_HAND' || requestedSlot === 'RIGHT_HAND') {
515         // get the players current equipment in that slot!
516         if(equippedItems.some(v => {
517           return v.equipment_slot === requestedSlot || v.equipment_slot === 'TWO_HANDED';
518         })) {
519           throw new Error();
520         }
521         else {
522           desiredSlot = requestedSlot;
523         }
524       }
525     }
526
527     if(requestedSlot === 'TWO_HANDED') {
528       if(equippedItems.some(v => {
529         return v.equipment_slot === 'LEFT_HAND' || v.equipment_slot === 'RIGHT_HAND';
530       })) {
531         throw new Error();
532       }
533     }
534
535
536     await equip(player.id, inventoryItem, desiredSlot);
537     const socketId = cache.get(`socket:${player.id}`).toString();
538     io.to(socketId).emit('updatePlayer', player);
539     io.to(socketId).emit('alert', {
540       type: 'success',
541       text: `You equipped your ${inventoryItem.name}`
542     });
543   }
544   catch(e) {
545     logger.log(e);
546   }
547
548   const [inventory, items] = await Promise.all([
549     getInventory(player.id),
550     getPlayersItems(player.id)
551   ]);
552
553   res.send(renderInventoryPage(inventory, items, inventoryItem.type) + renderPlayerBar(player, inventory));
554 });
555
556 app.post('/player/unequip/:item_id', authEndpoint, async (req: Request, res: Response) => {
557   const authToken = req.headers['x-authtoken'].toString();
558   const player: Player = await loadPlayer(authToken)
559
560   if(!player) {
561     logger.log(`Couldnt find player with id ${authToken}`);
562     return res.sendStatus(400);
563   }
564
565   const [item, ] = await Promise.all([
566     getInventoryItem(player.id, req.params.item_id),
567     unequip(player.id, req.params.item_id)
568   ]);
569
570   const [inventory, items] = await Promise.all([
571     getInventory(player.id),
572     getPlayersItems(player.id)
573   ]);
574
575   res.send(renderInventoryPage(inventory, items, item.type) + renderPlayerBar(player, inventory));
576 });
577
578 app.get('/player/explore', authEndpoint, async (req: Request, res: Response) => {
579   const authToken = req.headers['x-authtoken'].toString();
580   const player: Player = await loadPlayer(authToken)
581
582   if(!player) {
583     logger.log(`Couldnt find player with id ${authToken}`);
584     return res.sendStatus(400);
585   }
586
587
588   const fight = await loadMonsterFromFight(player.id);
589   let closestTown = player.city_id;
590
591   if(fight) {
592     // ok lets display the fight screen!
593     const data: MonsterForFight = {
594       id: fight.id,
595       hp: fight.hp,
596       maxHp: fight.maxHp,
597       name: fight.name,
598       level: fight.level,
599       fight_trigger: fight.fight_trigger
600     };
601
602     res.send(renderFight(data));
603   }
604   else {
605     const travelPlan = await getTravelPlan(player.id);
606     if(travelPlan) {
607       // traveling!
608       const travelPlan = await getTravelPlan(player.id);
609
610       const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
611
612       const chanceToSeeMonster = random(0, 100);
613       const things: any[] = [];
614       if(chanceToSeeMonster <= 30) {
615         const monster = await getRandomMonster([closest]);
616         things.push(monster);
617       }
618
619       // STEP_DELAY
620       const nextAction = cache[`step:${player.id}`] || 0;
621
622       res.send(renderTravel({
623         things,
624         nextAction,
625         closestTown: closest,
626         walkingText: ''
627       }));
628     }
629     else {
630       // display the city info!
631       const [city, locations, paths] = await Promise.all([
632         getCityDetails(player.city_id),
633         getAllServices(player.city_id),
634         getAllPaths(player.city_id)
635       ]);
636
637       res.send(await renderMap({city, locations, paths}, closestTown));
638     }
639
640   }
641 });
642
643 // used to purchase equipment from a particular shop
644 app.put('/location/:location_id/equipment/:item_id', authEndpoint, async (req: Request, res: Response) => {
645   const authToken = req.headers['x-authtoken'].toString();
646   const player: Player = await loadPlayer(authToken)
647   if(!player) {
648     logger.log(`Couldnt find player with id ${authToken}`);
649     return res.sendStatus(400);
650   }
651
652   const item = await getShopEquipment(parseInt(req.params.item_id), parseInt(req.params.location_id));
653
654   if(!item) {
655     logger.log(`Invalid item [${req.params.item_id}]`);
656     return res.sendStatus(400);
657   }
658
659   if(player.gold < item.cost) {
660     res.send(Alert.ErrorAlert(`Sorry, you need at least ${item.cost.toLocaleString()}G to purchase this.`));
661     return;
662   }
663
664   player.gold -= item.cost;
665
666   await updatePlayer(player);
667   await addInventoryItem(player.id, item);
668
669   const equippedItems = await getEquippedItems(player.id);
670
671   res.send(renderPlayerBar(player, equippedItems) + Alert.SuccessAlert(`You purchased ${item.name}`));
672 });
673
674 // used to purchase items from a particular shop
675 app.put('/location/:location_id/items/:item_id', authEndpoint, async (req: Request, res: Response) => {
676   const authToken = req.headers['x-authtoken'].toString();
677   const player: Player = await loadPlayer(authToken)
678   if(!player) {
679     logger.log(`Couldnt find player with id ${authToken}`);
680     return res.sendStatus(400);
681   }
682
683   const item: (ShopItem & Item) = await getItemFromShop(parseInt(req.params.item_id), parseInt(req.params.location_id));
684
685   if(!item) {
686     logger.log(`Invalid item [${req.params.item_id}]`);
687     return res.sendStatus(400);
688   }
689
690   if(player.gold < item.price_per_unit) {
691     res.send(Alert.ErrorAlert(`Sorry, you need at least ${item.price_per_unit.toLocaleString()}G to purchase this.`));
692     return;
693   }
694
695   player.gold -= item.price_per_unit;
696
697   await updatePlayer(player);
698   await givePlayerItem(player.id, item.id, 1);
699
700   const equippedItems = await getEquippedItems(player.id);
701
702   res.send(renderPlayerBar(player, equippedItems) + Alert.SuccessAlert(`You purchased a ${item.name}`));
703 });
704
705 // used to display equipment modals in a store, validates that 
706 // the equipment is actually in this store before displaying 
707 // the modal
708 app.get('/location/:location_id/equipment/:item_id/overview', authEndpoint, async (req: Request, res: Response) => {
709   const authToken = req.headers['x-authtoken'].toString();
710   const player: Player = await loadPlayer(authToken)
711   if(!player) {
712     logger.log(`Couldnt find player with id ${authToken}`);
713     return res.sendStatus(400);
714   }
715
716   const equipment = await getShopEquipment(parseInt(req.params.item_id), parseInt(req.params.location_id));
717
718   if(!equipment) {
719     logger.log(`Invalid equipment [${req.params.item_id}]`);
720     return res.sendStatus(400);
721   }
722
723   let html = `
724 <dialog>
725   <div class="item-modal-overview">
726     <div class="icon">
727       <img src="https://via.placeholder.com/64x64" title="${equipment.name}" alt="${equipment.name}"> 
728     </div>
729     <div>
730       ${renderEquipmentDetails(equipment, player)}
731     </div>
732   </div>
733   <div class="actions">
734     <button hx-put="/location/${equipment.location_id}/equipment/${equipment.id}" formmethod="dialog" value="cancel">Buy</button>
735     <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
736   </div>
737 </dialog>
738 `;
739
740   res.send(html);
741 });
742
743 // used to display item modals in a store, validates that 
744 // the item is actually in this store before displaying 
745 // the modal
746 app.get('/location/:location_id/items/:item_id/overview', authEndpoint, async (req: Request, res: Response) => {
747   const authToken = req.headers['x-authtoken'].toString();
748   const player: Player = await loadPlayer(authToken)
749   if(!player) {
750     logger.log(`Couldnt find player with id ${authToken}`);
751     return res.sendStatus(400);
752   }
753
754   const item: (ShopItem & Item) = await getItemFromShop(parseInt(req.params.item_id), parseInt(req.params.location_id));
755
756   if(!item) {
757     logger.log(`Invalid item [${req.params.item_id}]`);
758     return res.sendStatus(400);
759   }
760
761   let html = `
762 <dialog>
763   <div class="item-modal-overview">
764     <div class="icon">
765       <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}" alt="${item.name}"> 
766     </div>
767     <div>
768       <h4>${item.name}</h4>
769       <p>${item.description}</p>
770     </div>
771   </div>
772   <div class="actions">
773     <button hx-put="/location/${item.location_id}/items/${item.id}" formmethod="dialog" value="cancel">Buy</button>
774     <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
775   </div>
776 </dialog>
777 `;
778
779   res.send(html);
780 });
781
782 app.put('/item/:item_id', authEndpoint, async (req: Request, res: Response) => {
783   const authToken = req.headers['x-authtoken'].toString();
784   const player: Player = await loadPlayer(authToken)
785   if(!player) {
786     logger.log(`Couldnt find player with id ${authToken}`);
787     return res.sendStatus(400);
788   }
789
790   const item: PlayerItem = await getItemFromPlayer(player.id, parseInt(req.params.item_id));
791
792   if(!item) {
793     console.log(`Can't find item [${req.params.item_id}]`);
794     return;
795   }
796
797   if(item.amount < 1) {
798     res.send(Alert.ErrorAlert(`You dont have enough ${item.name}`));
799     return;
800   }
801
802   item.amount -= 1;
803
804   switch(item.effect_name) {
805     case 'heal_small':
806       const hpGain = HealthPotionSmall.effect(player);
807
808       player.hp += hpGain;
809
810       if(player.hp > maxHp(player.constitution, player.level)) {
811         player.hp = maxHp(player.constitution, player.level);
812       }
813     break;
814   }
815
816   await updateItemCount(player.id, item.item_id, -1);
817   await updatePlayer(player);
818
819   const inventory = await getInventory(player.id);
820   const equippedItems = inventory.filter(i => i.is_equipped);
821   const items = await getPlayersItems(player.id);
822
823   res.send(
824     [
825       renderPlayerBar(player, equippedItems),
826       renderInventoryPage(inventory, items, 'ITEMS'),
827       Alert.SuccessAlert(`You used the ${item.name}`)
828     ].join("")
829   );
830
831 });
832
833 app.get('/modal/items/:item_id', authEndpoint, async (req: Request, res: Response) => {
834   const authToken = req.headers['x-authtoken'].toString();
835   const player: Player = await loadPlayer(authToken)
836   if(!player) {
837     logger.log(`Couldnt find player with id ${authToken}`);
838     return res.sendStatus(400);
839   }
840
841   const item: PlayerItem = await getItemFromPlayer(player.id, parseInt(req.params.item_id));
842
843   if(!item) {
844     logger.log(`Invalid item [${req.params.item_id}]`);
845     return res.sendStatus(400);
846   }
847
848   let html = `
849 <dialog>
850   <div class="item-modal-overview">
851     <div class="icon">
852       <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}" alt="${item.name}"> 
853     </div>
854     <div>
855       <h4>${item.name}</h4>
856       <p>${item.description}</p>
857     </div>
858   </div>
859   <div class="actions">
860     <button hx-put="/item/${item.item_id}" formmethod="dialog" value="cancel" hx-target="#inventory">Use</button>
861     <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
862   </div>
863 </dialog>
864 `;
865
866   res.send(html);
867 });
868
869 app.get('/city/stores/city:stores/:location_id', authEndpoint, async (req: Request, res: Response) => {
870   const authToken = req.headers['x-authtoken'].toString();
871   const player: Player = await loadPlayer(authToken)
872   if(!player) {
873     logger.log(`Couldnt find player with id ${authToken}`);
874     return res.sendStatus(400);
875   }
876
877   const location = await getService(parseInt(req.params.location_id));
878
879   if(!location || location.city_id !== player.city_id) {
880     logger.log(`Invalid location: [${req.params.location_id}]`);
881     res.sendStatus(400);
882   }
883   const [shopEquipment, shopItems] = await Promise.all([
884     listShopItems({location_id: location.id}),
885     getShopItems(location.id)
886   ]);
887
888   const html = await renderStore(shopEquipment, shopItems, player);
889
890   res.send(html);
891 });
892
893 app.get('/city/explore/city:explore/:location_id', authEndpoint, async (req: Request, res: Response) => {
894   const authToken = req.headers['x-authtoken'].toString();
895   const player: Player = await loadPlayer(authToken)
896   if(!player) {
897     logger.log(`Couldnt find player with id ${authToken}`);
898     return res.sendStatus(400);
899   }
900
901   const location = await getService(parseInt(req.params.location_id));
902   if(!location || location.city_id !== player.city_id) {
903
904     logger.log(`Invalid location: [${req.params.location_id}]`);
905     res.sendStatus(400);
906   }
907
908   const monsters: Monster[] = await getMonsterList(location.id);
909   res.send(renderMonsterSelector(monsters));
910 });
911
912 app.post('/travel', authEndpoint, async (req: Request, res: Response) => {
913   const authToken = req.headers['x-authtoken'].toString();
914   const player: Player = await loadPlayer(authToken)
915   const destination_id = parseInt(req.body.destination_id);
916
917   if(!player) {
918     logger.log(`Couldnt find player with id ${authToken}`);
919     return res.sendStatus(400);
920   }
921
922   if(!destination_id || isNaN(destination_id)) {
923     logger.log(`Invalid destination_id [${req.body.destination_id}]`);
924     return res.sendStatus(400);
925   }
926
927   const travelPlan = travel(player, req.body.destination_id);
928
929   res.json(travelPlan);
930 });
931
932 app.post('/fight/turn', authEndpoint, async (req: Request, res: Response) => {
933   const authToken = req.headers['x-authtoken'].toString();
934   const player: Player = await loadPlayer(authToken)
935
936   if(!player) {
937     logger.log(`Couldnt find player with id ${authToken}`);
938     return res.sendStatus(400);
939   }
940
941   const monster = await loadMonsterWithFaction(player.id);
942
943   if(!monster) {
944     res.send(Alert.ErrorAlert('Not in a fight'));
945     return;
946   }
947
948   const fightData  = await fightRound(player, monster, {
949     action: req.body.action,
950     target: req.body.fightTarget
951   });
952
953   let html = renderFight(
954     monster,
955     renderRoundDetails(fightData.roundData),
956     fightData.roundData.winner === 'in-progress'
957   );
958
959   if(fightData.monsters.length && monster.fight_trigger === 'explore') {
960     html += renderMonsterSelector(fightData.monsters, monster.ref_id);
961   }
962
963   let travelSection = '';
964   if(monster.fight_trigger === 'travel' && fightData.roundData.winner === 'player') {
965     // you're travellinga dn you won.. display the keep walking!
966     const travelPlan = await getTravelPlan(player.id);
967     const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
968     travelSection = travelButton(0);
969   }
970
971   const equippedItems = await getEquippedItems(player.id);
972   const playerBar = renderPlayerBar(fightData.player, equippedItems);
973
974   res.send(html + travelSection + playerBar);
975 });
976
977 app.post('/fight', authEndpoint, async (req: Request, res: Response) => {
978   const authToken = req.headers['x-authtoken'].toString();
979   const player: Player = await loadPlayer(authToken)
980
981   if(!player) {
982     logger.log(`Couldnt find player with id ${authToken}`);
983     return res.sendStatus(400);
984   }
985
986   if(player.hp <= 0) {
987     logger.log(`Player didn\'t have enough hp`);
988     return res.sendStatus(400);
989   }
990
991   const monsterId: number = req.body.monsterId;
992   const fightTrigger: FightTrigger = req.body.fightTrigger ?? 'travel';
993
994   if(!monsterId) {
995     logger.log(`Missing monster Id ${monsterId}`);
996     return res.sendStatus(400);
997   }
998
999   if(!fightTrigger || !['travel', 'explore'].includes(fightTrigger)) {
1000     logger.log(`Invalid fight trigger [${fightTrigger}]`);
1001     return res.sendStatus(400);
1002   }
1003
1004   const monster = await loadMonster(monsterId);
1005
1006   if(!monster) {
1007     logger.log(`Couldnt find monster for ${monsterId}`);
1008     return res.sendStatus(400);
1009   }
1010
1011   const fight = await createFight(player.id, monster, fightTrigger);
1012
1013
1014   const data: MonsterForFight = {
1015     id: fight.id,
1016     hp: fight.hp,
1017     maxHp: fight.maxHp,
1018     name: fight.name,
1019     level: fight.level,
1020     fight_trigger: fight.fight_trigger
1021   };
1022
1023   res.send(renderFight(data));
1024 });
1025
1026 app.post('/travel/step', authEndpoint, async (req: Request, res: Response) => {
1027   const authToken = req.headers['x-authtoken'].toString();
1028   const player: Player = await loadPlayer(authToken)
1029   const stepTimerKey = `step:${player.id}`;
1030
1031   if(!player) {
1032     logger.log(`Couldnt find player with id ${authToken}`);
1033     return res.sendStatus(400);
1034   }
1035
1036   const travelPlan = await getTravelPlan(player.id);
1037   if(!travelPlan) {
1038     res.send(Alert.ErrorAlert('You don\'t have a travel plan'));
1039     return;
1040   }
1041
1042   if(cache[stepTimerKey]) {
1043     if(cache[stepTimerKey] > Date.now()) {
1044       res.send(Alert.ErrorAlert('Hmm.. travelling too quickly'));
1045       return;
1046     }
1047   }
1048
1049   travelPlan.current_position++;
1050
1051   if(travelPlan.current_position >= travelPlan.total_distance) {
1052     const travel = await completeTravel(player.id);
1053
1054     player.city_id = travel.destination_id;
1055     await movePlayer(travel.destination_id, player.id);
1056
1057     const [city, locations, paths] = await Promise.all([
1058       getCityDetails(travel.destination_id),
1059       getAllServices(travel.destination_id),
1060       getAllPaths(travel.destination_id)
1061     ]);
1062
1063     delete cache[stepTimerKey];
1064     res.send(await renderMap({city, locations, paths}, player.city_id));
1065   }
1066   else {
1067     const walkingText: string[] = [
1068       'You take a step forward',
1069       'You keep moving'
1070     ];
1071     // update existing plan..
1072     // decide if they will run into anything
1073     const travelPlan = await stepForward(player.id);
1074
1075     const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
1076
1077     const chanceToSeeMonster = random(0, 100);
1078     const things: any[] = [];
1079     if(chanceToSeeMonster <= 30) {
1080       const monster = await getRandomMonster([closest]);
1081       things.push(monster);
1082     }
1083
1084     // STEP_DELAY
1085     const nextAction = Date.now() + 3000;
1086
1087     cache[stepTimerKey] = nextAction;
1088
1089     res.send(renderTravel({
1090       things,
1091       nextAction,
1092       closestTown: closest,
1093       walkingText: sample(walkingText)
1094     }));
1095
1096   }
1097 });
1098
1099 app.post('/travel/:destination_id', authEndpoint, async (req: Request, res: Response) => {
1100   const authToken = req.headers['x-authtoken'].toString();
1101   const player: Player = await loadPlayer(authToken)
1102
1103   if(!player) {
1104     logger.log(`Couldnt find player with id ${authToken}`);
1105     return res.sendStatus(400);
1106   }
1107
1108   if(player.hp <= 0) {
1109     logger.log(`Player didn\'t have enough hp`);
1110     res.send(Alert.ErrorAlert('Sorry, you need some HP to start travelling.'));
1111     return;
1112   }
1113
1114   const destination = await getCityDetails(parseInt(req.params.destination_id));
1115
1116   if(!destination) {
1117     res.send(Alert.ErrorAlert(`Thats not a valid desination`));
1118     return;
1119   }
1120
1121   await travel(player, destination.id);
1122
1123   res.send(renderTravel({
1124     things: [],
1125     nextAction: 0,
1126     walkingText: '',
1127     closestTown: player.city_id
1128   }));
1129 });
1130
1131
1132 app.post('/signup', async (req: Request, res: Response) => {
1133   const {username, password} = req.body;
1134   const authToken = req.headers['x-authtoken'];
1135
1136   if(!username || !password || !authToken) {
1137     res.sendStatus(400);
1138     return;
1139   }
1140
1141
1142   try {
1143     const player = await loadPlayer(authToken.toString());
1144     logger.log(`Attempted claim for ${player.username}`);
1145
1146     await signup(authToken.toString(), username, password);
1147
1148     await db('players').where({id: player.id}).update({
1149       account_type: 'auth',
1150       username: username
1151     });
1152
1153     logger.log(`Player claimed ${player.username} => ${username}`);
1154
1155     io.emit('chat', broadcastMessage('server', `${player.username} is now ${username}`));
1156
1157     player.username = username;
1158     player.account_type = 'auth';
1159
1160     res.json({
1161       player
1162     });
1163   }
1164   catch(e) {
1165     logger.log(e);
1166     if(e?.constraint === 'players_username_unique') {
1167       res.send({
1168         error: 'That username is already taken.'
1169       }).status(500);
1170     }
1171     else {
1172       res.send({error: 'Please try again'}).status(500);
1173     }
1174   }
1175 });
1176
1177 app.post('/login', async (req: Request, res: Response) => {
1178   const {username, password} = req.body;
1179   try {
1180     const player = await login(username, password);
1181     res.json({player});
1182   }
1183   catch(e) {
1184     console.log(e);
1185     res.json({error: 'That user doesnt exist'}).status(500);
1186   }
1187 });
1188
1189 app.get('/status', async (req: Request, res: Response) => {
1190   res.send(`
1191   <div id="server-stats" hx-trigger="load delay:15s" hx-get="/status" hx-swap="outerHTML">
1192     ${io.sockets.sockets.size} Online (v${version})
1193   </div>
1194 `);
1195 })
1196
1197 server.listen(process.env.API_PORT, () => {
1198   logger.log(`Listening on port ${process.env.API_PORT}`);
1199 });