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