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