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