chore(release): 0.1.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 { 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     if(data.target === 'arms') {
325       if(monster.armsAp > 0) {
326         monster.armsAp -= playerFinalDamage;
327
328         roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
329         if(monster.armsAp < 0) {
330
331           roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
332           roundData.roundDetails.push(`You dealt ${monster.armsAp * -1} damage to their HP`);
333           monster.hp += monster.armsAp;
334           monster.armsAp = 0;
335         }
336       }
337       else {
338         roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
339         monster.hp -= playerFinalDamage;
340       }
341     }
342     else if (data.target === 'head') {
343       if(monster.helmAp > 0) {
344         monster.helmAp -= playerFinalDamage;
345
346         roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
347         if(monster.helmAp < 0) {
348
349           roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
350           roundData.roundDetails.push(`You dealt ${monster.armsAp * 1} damage to their HP`);
351           monster.hp += monster.helmAp;
352           monster.helmAp = 0;
353         }
354       }
355       else {
356         roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
357         monster.hp -= playerFinalDamage;
358       }
359     }
360     else if(data.target === 'legs') {
361       if(monster.legsAp > 0) {
362         monster.legsAp -= playerFinalDamage;
363
364         roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
365         if(monster.legsAp < 0) {
366
367           roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
368           roundData.roundDetails.push(`You dealt ${monster.legsAp * 1} damage to their HP`);
369           monster.hp += monster.legsAp;
370           monster.legsAp = 0;
371         }
372       }
373       else {
374         roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
375         monster.hp -= playerFinalDamage;
376       }
377     }
378     else {
379       if(monster.chestAp > 0) {
380         monster.chestAp -= playerFinalDamage;
381
382         roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
383
384         if(monster.chestAp < 0) {
385           roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
386           roundData.roundDetails.push(`You dealt ${monster.chestAp * 1} damage to their HP`);
387           monster.hp += monster.chestAp;
388           monster.chestAp = 0;
389         }
390       }
391       else {
392         roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
393         monster.hp -= playerFinalDamage;
394       }
395     }
396
397     if(monster.hp <= 0) {
398       roundData.monster.hp = 0;
399       roundData.winner = 'player';
400
401       roundData.rewards.exp = monster.exp;
402       roundData.rewards.gold = monster.gold;
403
404       player.gold += monster.gold;
405       player.exp += monster.exp;
406
407       if(player.exp >= expToLevel(player.level + 1)) {
408         player.exp -= expToLevel(player.level + 1)
409         player.level++;
410         roundData.rewards.levelIncrease = true;
411         let statPointsGained = 1;
412
413         if(player.profession !== 'Wanderer') {
414           statPointsGained = 2;
415         }
416
417         player.stat_points += statPointsGained;
418
419         roundData.roundDetails.push(`You gained ${statPointsGained} stat points!`);
420
421         player.hp = maxHp(player.constitution, player.level);
422       }
423       // get the monster location!
424       const rawMonster = await loadMonster(monster.ref_id);
425       const monsterList  = await getMonsterList(rawMonster.location_id);
426       potentialMonsters = monsterList.map(monster => {
427         return {
428           id: monster.id,
429           name: monster.name,
430           level: monster.level,
431           hp: monster.hp,
432           maxHp: monster.maxHp
433         }
434       });
435
436       await clearFight(player.id);
437       await updatePlayer(player);
438       socket.emit('fight-over', {roundData, monsters: potentialMonsters});
439       return;
440     }
441
442     roundData.roundDetails.push(`The ${monster.name} targeted your ${target}!`);
443     if(equipment.has(target)) {
444       const item = equipment.get(target);
445       // apply mitigation!
446       const mitigationPercentage = item.boosts.damage_mitigation || 0;
447       const damageAfterMitigation = Math.floor(monster.strength * ((100-mitigationPercentage)/100));
448
449       item.currentAp -= damageAfterMitigation;
450
451       if(item.currentAp < 0) {
452         roundData.roundDetails.push(`Your ${item.name} amour was destroyed`);
453         roundData.roundDetails.push(`The ${monster.name} hit your HP for ${item.currentAp * -1} damage!`);
454         player.hp += item.currentAp;
455         item.currentAp = 0;
456         await deleteInventoryItem(player.id, item.item_id);
457       }
458       else {
459         roundData.roundDetails.push(`Your ${target} took ${damageAfterMitigation} damage!`);
460         await updateAp(player.id, item.item_id, item.currentAp, item.maxAp);
461       }
462
463     }
464     else {
465       roundData.roundDetails.push(`The ${monster.name} hit you for ${monster.strength} damage`);
466       player.hp -= monster.strength;
467     }
468
469     if(playerFinalHeal > 0) {
470       player.hp += playerFinalHeal;
471       if(player.hp > maxHp(player.constitution, player.level)) {
472         player.hp = maxHp(player.constitution, player.level);
473       }
474       roundData.roundDetails.push(`You healed for ${playerFinalHeal} HP`);
475     }
476
477     // update the players inventory for this item!
478
479     if(player.hp <= 0) {
480       player.hp = 0;
481       roundData.winner = 'monster';
482
483       roundData.roundDetails.push(`You were killed by the ${monster.name}`);
484
485       await clearFight(player.id);
486       await updatePlayer(player);
487
488       socket.emit('fight-over', {roundData, monsters: []});
489       return;
490     }
491
492     await updatePlayer(player);
493     await saveFightState(player.id, monster);
494
495     calcAp(equippedItems, socket);
496     socket.emit('fight-round', roundData);
497   });
498
499   // this is a special event to let the client know it can start 
500   // requesting data
501   socket.emit('ready');
502 });
503
504 function authEndpoint(req: Request, res: Response, next: any) {
505   const authToken = req.headers['x-authtoken'];
506   if(!authToken) {
507     logger.log(`Invalid auth token ${authToken}`);
508     res.sendStatus(400)
509   }
510   else {
511     next()
512   }
513 }
514
515 app.get('/city/:id', async (req: Request, res: Response) => {
516   const id = parseInt(req.params.id);
517   if(!id || isNaN(id)) {
518     return res.sendStatus(400);
519   }
520   const [city, locations, paths] = await Promise.all([
521     getCityDetails(id),
522     getAllServices(id),
523     getAllPaths(id)
524   ]);
525
526   res.json({city, locations, paths});
527 });
528
529 app.get('/fight', authEndpoint, async (req: Request, res: Response) => {
530   const authToken = req.headers['x-authtoken'].toString();
531   const player: Player = await loadPlayer(authToken)
532
533   if(!player) {
534     logger.log(`Couldnt find player with id ${authToken}`);
535     return res.sendStatus(400);
536   }
537
538   const fight = await loadMonsterFromFight(player.id);
539
540   res.json(fight || null);
541
542 });
543
544 app.post('/fight', authEndpoint, async (req: Request, res: Response) => {
545   const authToken = req.headers['x-authtoken'].toString();
546   const player: Player = await loadPlayer(authToken)
547
548   if(!player) {
549     logger.log(`Couldnt find player with id ${authToken}`);
550     return res.sendStatus(400);
551   }
552
553   if(player.hp <= 0) {
554     logger.log(`Player didn\'t have enough hp`);
555     return res.sendStatus(400);
556   }
557
558   const monsterId: number = req.body.monsterId;
559
560   if(!monsterId) {
561     logger.log(`Missing monster Id ${monsterId}`);
562     return res.sendStatus(400);
563   }
564
565   const monster = await loadMonster(monsterId);
566
567   if(!monster) {
568     logger.log(`Couldnt find monster for ${monsterId}`);
569     return res.sendStatus(400);
570   }
571
572   const fight = await createFight(player.id, monster);
573
574   const data: MonsterForFight = {
575     id: fight.id,
576     hp: fight.hp,
577     maxHp: fight.maxHp,
578     name: fight.name,
579     level: fight.level
580   };
581   res.json(data);
582 });
583
584 app.post('/signup', async (req: Request, res: Response) => {
585   const {username, password} = req.body;
586   const authToken = req.headers['x-authtoken'];
587
588   if(!username || !password || !authToken) {
589     res.sendStatus(400);
590     return;
591   }
592
593
594   try {
595     const player = await loadPlayer(authToken.toString());
596     console.log(`Attempted claim for ${player.username}`);
597
598     await signup(authToken.toString(), username, password);
599
600     await db('players').where({id: player.id}).update({
601       account_type: 'auth',
602       username: username
603     });
604
605     console.log(`Player claimed ${player.username} => ${username}`);
606
607     io.emit('chat', broadcastMessage('server', `${player.username} is now ${username}`));
608
609     player.username = username;
610     player.account_type = 'auth';
611
612     res.json({
613       player
614     });
615   }
616   catch(e) {
617     console.log(e);
618     if(e?.constraint === 'players_username_unique') {
619       res.send({
620         error: 'That username is already taken.'
621       }).status(500);
622     }
623     else {
624       res.send({error: 'Please try again'}).status(500);
625     }
626   }
627 });
628
629 app.post('/login', async (req: Request, res: Response) => {
630   const {username, password} = req.body;
631   try {
632     const player = await login(username, password);
633     res.json({player});
634   }
635   catch(e) {
636     console.log(e);
637     res.json({error: 'That user doesnt exist'}).status(500);
638   }
639 });
640
641 server.listen(process.env.API_PORT, () => {
642   logger.log(`Listening on port ${process.env.API_PORT}`);
643 });