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