chore(release): 0.2.1
[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 http from 'http';
7 import { Server, Socket } from 'socket.io';
8 import { logger } from './lib/logger';
9 import { loadPlayer, createPlayer, updatePlayer } from './player';
10 import * as _ from 'lodash';
11 import {broadcastMessage, Message} from '../shared/message';
12 import {expToLevel, maxHp, Player} from '../shared/player';
13 import {clearFight, createFight, getMonsterList, loadMonster, loadMonsterFromFight, loadMonsterWithFaction, saveFightState} from './monster';
14 import {FightRound} from '../shared/fight';
15 import {addInventoryItem, deleteInventoryItem, getEquippedItems, getInventory, updateAp} from './inventory';
16 import {FightTrigger, MonsterForFight} from '../shared/monsters';
17 import {getShopItem } from './shopItem';
18 import {EquippedItemDetails} from '../shared/equipped';
19 import {ArmourEquipmentSlot, EquipmentSlot} from '../shared/inventory';
20 import { clearTravelPlan, getAllPaths, getAllServices, getCityDetails, getTravelPlan, travel } from './map';
21 import { signup, login } from './auth';
22 import {db} from './lib/db';
23 import { getPlayerSkills, getPlayerSkillsAsObject, updatePlayerSkills } from './skills';
24 import {SkillID, Skills} from '../shared/skills';
25 import * as EventList from '../events/server';
26
27 // TEMP!
28 import { createMonsters } from '../../seeds/monsters';
29 import { createAllCitiesAndLocations } from '../../seeds/cities';
30 import { createShopItems } from '../../seeds/shop_items';
31
32 dotenv();
33
34 otel.s();
35
36 const app = express();
37 const server = http.createServer(app);
38
39 app.use(express.static(join(__dirname, '..', '..', 'public')));
40 app.use(express.json());
41
42 const io = new Server(server);
43
44 const cache = new Map<string, any>();
45 const chatHistory: Message[] = [];
46
47 function calcAp(inventoryItem: EquippedItemDetails[], socket: Socket) {
48   const ap: Record<any | EquipmentSlot, {currentAp: number, maxAp: number}> = {};
49   inventoryItem.forEach(item => {
50     if(item.is_equipped && item.type === 'ARMOUR') {
51       ap[item.equipment_slot] = {
52         currentAp: item.currentAp,
53         maxAp: item.maxAp
54       };
55     }
56   });
57
58   socket.emit('calc:ap', {ap});
59 }
60
61 function setServerStats() {
62   io.emit('server-stats', {
63     onlinePlayers: io.sockets.sockets.size
64   });
65 }
66
67 setTimeout(setServerStats, 5000);
68
69 io.on('connection', async socket => {
70   logger.log(`socket ${socket.id} connected, authToken: ${socket.handshake.headers['x-authtoken']}`);
71
72   let authToken = socket.handshake.headers['x-authtoken'].toString() === 'null' ? null : socket.handshake.headers['x-authtoken'].toString();
73
74   let player: Player;
75   if(authToken) {
76     logger.log(`Attempting to load player with id ${authToken}`);
77     player = await loadPlayer(authToken);
78   }
79   if(!player) {
80     logger.log(`Creating player`);
81     player = await createPlayer();
82     authToken = player.id;
83     socket.handshake.headers['x-authtoken'] = authToken;
84   }
85
86   logger.log(`Socket [${socket.id}] auth token: ${player.id}`);
87
88   // ref to get the socket id for a particular player
89   cache.set(`socket:${player.id}`, socket.id);
90   // ref to get the player object
91   cache.set(`token:${player.id}`, player);
92
93   socket.emit('init', {
94     version
95   });
96
97   socket.emit('authToken', player.id);
98   socket.emit('player', player);
99
100   const inventory = await getEquippedItems(player.id);
101   calcAp(inventory, socket);
102
103   setServerStats();
104
105   io.emit('chathistory', chatHistory);
106
107   socket.emit('chat', broadcastMessage('server', `${player.username} just logged in`));
108
109   socket.on('disconnect', () => {
110     console.log(`Player ${player.username} left`);
111   });
112
113   socket.on('chat', async (msg: string) => {
114     if(msg.length > 0) {
115
116       let message: Message;
117       if(msg.startsWith('/server lmnop')) {
118         if(msg === '/server lmnop refresh-monsters') {
119           await createMonsters();
120           message = broadcastMessage('server', 'Monster refresh!');
121         }
122         else if(msg === '/server lmnop refresh-cities') {
123           await createAllCitiesAndLocations();
124           message = broadcastMessage('server', 'Cities, Locations, and Paths refreshed!');
125         }
126         else if(msg === '/server lmnop refresh-shops') {
127           await createShopItems();
128           message = broadcastMessage('server', 'Refresh shop items');
129         }
130         else {
131           const str = msg.split('/server lmnop ')[1];
132           if(str) {
133             message = broadcastMessage('server', str);
134           }
135         }
136       }
137       else {
138         const authToken = socket.handshake.headers['x-authtoken'];
139         if(cache.has(`token:${authToken}`)) {
140           const player = cache.get(`token:${authToken}`);
141           message = broadcastMessage(player.username, msg);
142         }
143         else {
144           logger.log(`Missing cache for [token:${authToken}]`);
145         }
146       }
147
148       if(message) {
149         chatHistory.push(message);
150         chatHistory.slice(-10);
151         io.emit('chat', message);
152       }
153       else {
154         logger.log(`Unset message`);
155       }
156     }
157   });
158
159   socket.on('purchase', async (data) => {
160     const authToken = socket.handshake.headers['x-authtoken'];
161     if(!cache.has(`token:${authToken}`)) {
162       return;
163     }
164     const player = cache.get(`token:${authToken}`);
165     const shopItem = await getShopItem(data.id);
166
167     if(shopItem && player) {
168       if(player.gold < shopItem.cost) {
169         socket.emit('alert', {
170           type: 'error',
171           text: `You dont have enough gold to buy the ${shopItem.name}`
172         });
173         return;
174       }
175
176       player.gold -= shopItem.cost;
177       await updatePlayer(player);
178       await addInventoryItem(player.id, shopItem);
179
180       socket.emit('alert', {
181         type: 'success',
182         text: `You bought the ${shopItem.name}`
183       });
184
185       socket.emit('updatePlayer', player);
186     }
187   });
188
189   _.each(EventList, event => {
190     logger.log(`Bound event listener: ${event.eventName}`);
191     socket.on(event.eventName, event.handler.bind(null, {
192       socket,
193       io,
194       player,
195       cache
196     }));
197   });
198
199   socket.on('skills', async () => {
200     const player = cache.get(`token:${socket.handshake.headers['x-authtoken']}`);
201     if(!player) {
202       return;
203     }
204     const skills = await getPlayerSkills(player.id);
205     socket.emit('skills', {skills});
206   });
207
208   socket.on('inventory', async () => {
209     const player = cache.get(`token:${socket.handshake.headers['x-authtoken']}`);
210     if(!player) {
211       return;
212     }
213     const inventory = await getInventory(player.id);
214     socket.emit('inventory', {
215       inventory
216     });
217   });
218
219   socket.on('logout', async () => {
220     // clear this player from the cache!
221     const player = cache.get(`token:${socket.handshake.headers['x-authtoken']}`);
222     if(player) {
223       logger.log(`Player ${player.username} logged out`);
224     }
225     else {
226       logger.log(`Invalid user logout`);
227     }
228   });
229
230   socket.on('fight', async (data: {action: 'attack' | 'cast' | 'flee', target: 'head' | 'body' | 'arms' | 'legs'}) => {
231     const authToken = `token:${socket.handshake.headers['x-authtoken']}`;
232     const player = cache.get(authToken);
233     if(!player) {
234       logger.log(`Invalid token for fight`)
235       return;
236     }
237     const monster = await loadMonsterWithFaction(player.id);
238     const playerSkills = await getPlayerSkillsAsObject(player.id);
239     const roundData: FightRound = {
240       monster,
241       player,
242       winner: 'in-progress',
243       fightTrigger: monster.fight_trigger,
244       roundDetails: [],
245       rewards: {
246         exp: 0,
247         gold: 0,
248         levelIncrease: false
249       }
250     };
251     const equippedItems = await getEquippedItems(player.id);
252
253     // we only use this if the player successfully defeated the monster 
254     // they were fighting, then we load the other monsters in this area 
255     // so they can "fight again"
256     let potentialMonsters: MonsterForFight[] = [];
257
258     /*
259      * cumulative chance of head/arms/body/legs
260      * 0 -> 0.2 = head
261      * 0.21 -> 0.4 = arms
262      *
263      * we use the factor to decide how many decimal places 
264      * we care about
265      */
266     const factor = 100;
267     const monsterTarget = [0.2, 0.4, 0.9, 1];
268     const targets: ArmourEquipmentSlot[] = ['HEAD', 'CHEST', 'ARMS', 'LEGS'];
269     // calc weighted
270     const rand = Math.ceil(Math.random() * factor);
271     let target: ArmourEquipmentSlot = 'CHEST';
272      monsterTarget.forEach((i, idx) => {
273       if (rand > (i * factor)) {
274         target = targets[idx] as ArmourEquipmentSlot;
275       }
276     });
277
278     const boost = {
279       strength: 0,
280       constitution: 0,
281       dexterity: 0,
282       intelligence: 0,
283       damage: 0,
284       hp: 0,
285     };
286
287     const equipment: Map<EquipmentSlot, EquippedItemDetails> = new Map<EquipmentSlot, EquippedItemDetails>();
288     const weapons: EquippedItemDetails[] = [];
289     let anyDamageSpells: boolean = false;
290     equippedItems.forEach(item => {
291       if(item.type === 'ARMOUR') {
292         equipment.set(item.equipment_slot, item);
293       }
294       else if(item.type === 'WEAPON') {
295         weapons.push(item);
296       }
297       else if(item.type === 'SPELL') {
298         if(item.affectedSkills.includes('destruction_magic')) {
299           anyDamageSpells = true;
300         }
301         weapons.push(item);
302       }
303
304       boost.strength += item.boosts.strength;
305       boost.constitution += item.boosts.constitution;
306       boost.dexterity += item.boosts.dexterity;
307       boost.intelligence += item.boosts.intelligence;
308
309       if(item.type === 'SPELL' && item.affectedSkills.includes('restoration_magic')) {
310         boost.hp += item.boosts.damage;
311       }
312       else {
313         boost.damage += item.boosts.damage;
314       }
315     });
316
317     // if you flee'd, then we want to check your dex vs. the monsters
318     // but we want to give you the item/weapon boosts you need
319     // if not then you're going to get hit.
320     if(data.action === 'flee') {
321       roundData.roundDetails.push(`You managed to escape from the ${monster.name}!`)
322       roundData.winner = 'monster';
323       await clearFight(player.id);
324
325       socket.emit('fight-over', {roundData, monsters: []});
326       return;
327     }
328
329     const attackType = data.action === 'attack' ? 'physical' : 'magical';
330     const primaryStat = data.action === 'attack' ? player.strength : player.intelligence;
331     const boostStat = data.action === 'attack' ? boost.strength : boost.intelligence;
332
333     const playerDamage = Math.floor(((primaryStat + boostStat) * 1.3) + boost.damage);
334     const skillsUsed: Record<SkillID | any, number> = {};
335     let hpHealAfterMasteries: number = -1;
336     let playerDamageAfterMasteries: number = 0;
337     // apply masteries!
338     weapons.forEach(item => {
339       item.affectedSkills.forEach(id => {
340         if(id === 'restoration_magic') {
341           if(hpHealAfterMasteries < 0) {
342             hpHealAfterMasteries = 0;
343           }
344           hpHealAfterMasteries += Skills.get(id).effect(playerSkills.get(id));
345         }
346         else {
347           playerDamageAfterMasteries += playerDamage * Skills.get(id).effect(playerSkills.get(id));
348         }
349
350         if(!skillsUsed[id]) {
351           skillsUsed[id] = 0;
352         }
353         skillsUsed[id]++;
354       });
355     });
356
357     await updatePlayerSkills(player.id, skillsUsed);
358
359     const playerFinalDamage = (data.action === 'cast' && !anyDamageSpells) ? 0 : Math.floor(playerDamage + playerDamageAfterMasteries);
360     const playerFinalHeal = Math.floor(boost.hp + hpHealAfterMasteries);
361
362     roundData.roundDetails.push(`You targeted the monsters ${data.target.toUpperCase()} with ${attackType} damage!`);
363     let armourKey: string;
364     switch(data.target) {
365       case 'arms':
366         armourKey = 'armsAp';
367       break;
368       case 'head':
369         armourKey = 'helmAp';
370       break;
371       case 'legs':
372         armourKey = 'legsAp';
373       break;
374       case 'body':
375         armourKey = 'chestAp';
376       break;
377     }
378
379     if(monster[armourKey] && monster[armourKey] > 0) {
380       monster[armourKey] -= playerFinalDamage;
381
382       roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
383       if(monster[armourKey] < 0) {
384         roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
385         roundData.roundDetails.push(`You dealt ${monster[armourKey] * -1} damage to their HP`);
386         monster.hp += monster[armourKey];
387         monster[armourKey] = 0;
388       }
389     }
390     else {
391       roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
392       monster.hp -= playerFinalDamage;
393     }
394
395     if(monster.hp <= 0) {
396       roundData.monster.hp = 0;
397       roundData.winner = 'player';
398
399       roundData.rewards.exp = monster.exp;
400       roundData.rewards.gold = monster.gold;
401
402       player.gold += monster.gold;
403       player.exp += monster.exp;
404
405       if(player.exp >= expToLevel(player.level + 1)) {
406         player.exp -= expToLevel(player.level + 1)
407         player.level++;
408         roundData.rewards.levelIncrease = true;
409         let statPointsGained = 1;
410
411         if(player.profession !== 'Wanderer') {
412           statPointsGained = 2;
413         }
414
415         player.stat_points += statPointsGained;
416
417         roundData.roundDetails.push(`You gained ${statPointsGained} stat points!`);
418
419         player.hp = maxHp(player.constitution, player.level);
420       }
421       // get the monster location if it was an EXPLORED fight
422       if(roundData.fightTrigger === 'explore') {
423         const rawMonster = await loadMonster(monster.ref_id);
424         const monsterList  = await getMonsterList(rawMonster.location_id);
425         potentialMonsters = monsterList.map(monster => {
426           return {
427             id: monster.id,
428             name: monster.name,
429             level: monster.level,
430             hp: monster.hp,
431             maxHp: monster.maxHp,
432             fight_trigger: 'explore'
433           }
434         });
435       }
436
437       await clearFight(player.id);
438       await updatePlayer(player);
439       cache.set(authToken, player);
440       socket.emit('fight-over', {roundData, monsters: potentialMonsters});
441       return;
442     }
443
444     roundData.roundDetails.push(`The ${monster.name} targeted your ${target}!`);
445     if(equipment.has(target)) {
446       const item = equipment.get(target);
447       // apply mitigation!
448       const mitigationPercentage = item.boosts.damage_mitigation || 0;
449       const damageAfterMitigation = Math.floor(monster.strength * ((100-mitigationPercentage)/100));
450
451       item.currentAp -= damageAfterMitigation;
452
453       if(item.currentAp < 0) {
454         roundData.roundDetails.push(`Your ${item.name} amour was destroyed`);
455         roundData.roundDetails.push(`The ${monster.name} hit your HP for ${item.currentAp * -1} damage!`);
456         player.hp += item.currentAp;
457         item.currentAp = 0;
458         await deleteInventoryItem(player.id, item.item_id);
459       }
460       else {
461         roundData.roundDetails.push(`Your ${target} took ${damageAfterMitigation} damage!`);
462         await updateAp(player.id, item.item_id, item.currentAp, item.maxAp);
463       }
464
465     }
466     else {
467       roundData.roundDetails.push(`The ${monster.name} hit you for ${monster.strength} damage`);
468       player.hp -= monster.strength;
469     }
470
471     if(playerFinalHeal > 0) {
472       player.hp += playerFinalHeal;
473       if(player.hp > maxHp(player.constitution, player.level)) {
474         player.hp = maxHp(player.constitution, player.level);
475       }
476       roundData.roundDetails.push(`You healed for ${playerFinalHeal} HP`);
477     }
478
479     // update the players inventory for this item!
480
481     if(player.hp <= 0) {
482       player.hp = 0;
483       roundData.winner = 'monster';
484
485       roundData.roundDetails.push(`You were killed by the ${monster.name}`);
486
487       await clearFight(player.id);
488       await updatePlayer(player);
489       await clearTravelPlan(player.id);
490
491       cache.set(authToken, player);
492
493       socket.emit('fight-over', {roundData, monsters: []});
494       return;
495     }
496
497     await updatePlayer(player);
498     await saveFightState(player.id, monster);
499     cache.set(authToken, player);
500
501     calcAp(equippedItems, socket);
502     socket.emit('fight-round', roundData);
503   });
504
505   // this is a special event to let the client know it can start 
506   // requesting data
507   socket.emit('ready');
508 });
509
510 function authEndpoint(req: Request, res: Response, next: any) {
511   const authToken = req.headers['x-authtoken'];
512   if(!authToken) {
513     logger.log(`Invalid auth token ${authToken}`);
514     res.sendStatus(400)
515   }
516   else {
517     next()
518   }
519 }
520
521 app.get('/city/:id', async (req: Request, res: Response) => {
522   const id = parseInt(req.params.id);
523   if(!id || isNaN(id)) {
524     return res.sendStatus(400);
525   }
526   const [city, locations, paths] = await Promise.all([
527     getCityDetails(id),
528     getAllServices(id),
529     getAllPaths(id)
530   ]);
531
532   res.json({city, locations, paths});
533 });
534
535 app.get('/state', authEndpoint, async (req: Request, res: Response) => {
536   const authToken = req.headers['x-authtoken'].toString();
537   const player: Player = await loadPlayer(authToken)
538   let closestTown: number = player.city_id;
539
540   if(!player) {
541     logger.log(`Couldnt find player with id ${authToken}`);
542     return res.sendStatus(400);
543   }
544
545   const fight = await loadMonsterFromFight(player.id);
546
547   // check if the player is exploring somewhere!
548   const travelPlan = await getTravelPlan(player.id);
549
550   if(travelPlan) {
551     closestTown = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.source_id : travelPlan.destination_id;
552   }
553   
554   const state = {
555     fight: fight || null,
556     closestTown,
557     travel: travelPlan ? {
558       things: [],
559       closestTown,
560       walkingText: 'You keep walking...'
561     } : null
562   };
563
564   res.json(state);
565
566 });
567
568 app.post('/travel', authEndpoint, async (req: Request, res: Response) => {
569   const authToken = req.headers['x-authtoken'].toString();
570   const player: Player = await loadPlayer(authToken)
571   const destination_id = parseInt(req.body.destination_id);
572
573   if(!player) {
574     logger.log(`Couldnt find player with id ${authToken}`);
575     return res.sendStatus(400);
576   }
577
578   if(!destination_id || isNaN(destination_id)) {
579     logger.log(`Invalid destination_id [${req.body.destination_id}]`);
580     return res.sendStatus(400);
581   }
582
583   const travelPlan = travel(player, req.body.destination_id);
584
585   res.json(travelPlan);
586 });
587
588
589 app.post('/fight', authEndpoint, async (req: Request, res: Response) => {
590   const authToken = req.headers['x-authtoken'].toString();
591   const player: Player = await loadPlayer(authToken)
592
593   if(!player) {
594     logger.log(`Couldnt find player with id ${authToken}`);
595     return res.sendStatus(400);
596   }
597
598   if(player.hp <= 0) {
599     logger.log(`Player didn\'t have enough hp`);
600     return res.sendStatus(400);
601   }
602
603   const monsterId: number = req.body.monsterId;
604   const fightTrigger: FightTrigger = req.body.fightTrigger;
605
606
607   if(!monsterId) {
608     logger.log(`Missing monster Id ${monsterId}`);
609     return res.sendStatus(400);
610   }
611
612   if(!fightTrigger || !['travel', 'explore'].includes(fightTrigger)) {
613     logger.log(`Invalid fight trigger [${fightTrigger}]`);
614     return res.sendStatus(400);
615   }
616
617   const monster = await loadMonster(monsterId);
618
619   if(!monster) {
620     logger.log(`Couldnt find monster for ${monsterId}`);
621     return res.sendStatus(400);
622   }
623
624   const fight = await createFight(player.id, monster, fightTrigger);
625
626   const data: MonsterForFight = {
627     id: fight.id,
628     hp: fight.hp,
629     maxHp: fight.maxHp,
630     name: fight.name,
631     level: fight.level,
632     fight_trigger: fight.fight_trigger
633   };
634   res.json(data);
635 });
636
637 app.post('/signup', async (req: Request, res: Response) => {
638   const {username, password} = req.body;
639   const authToken = req.headers['x-authtoken'];
640
641   if(!username || !password || !authToken) {
642     res.sendStatus(400);
643     return;
644   }
645
646
647   try {
648     const player = await loadPlayer(authToken.toString());
649     logger.log(`Attempted claim for ${player.username}`);
650
651     await signup(authToken.toString(), username, password);
652
653     await db('players').where({id: player.id}).update({
654       account_type: 'auth',
655       username: username
656     });
657
658     logger.log(`Player claimed ${player.username} => ${username}`);
659
660     io.emit('chat', broadcastMessage('server', `${player.username} is now ${username}`));
661
662     player.username = username;
663     player.account_type = 'auth';
664
665     res.json({
666       player
667     });
668   }
669   catch(e) {
670     logger.log(e);
671     if(e?.constraint === 'players_username_unique') {
672       res.send({
673         error: 'That username is already taken.'
674       }).status(500);
675     }
676     else {
677       res.send({error: 'Please try again'}).status(500);
678     }
679   }
680 });
681
682 app.post('/login', async (req: Request, res: Response) => {
683   const {username, password} = req.body;
684   try {
685     const player = await login(username, password);
686     res.json({player});
687   }
688   catch(e) {
689     console.log(e);
690     res.json({error: 'That user doesnt exist'}).status(500);
691   }
692 });
693
694 server.listen(process.env.API_PORT, () => {
695   logger.log(`Listening on port ${process.env.API_PORT}`);
696 });