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