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