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