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