chore(release): 0.0.3
[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       // apply mitigation!
430       const mitigationPercentage = item.boosts.damage_mitigation || 0;
431       const damageAfterMitigation = Math.floor(monster.strength * ((100-mitigationPercentage)/100));
432
433       item.currentAp -= damageAfterMitigation;
434
435       if(item.currentAp < 0) {
436         roundData.roundDetails.push(`Your ${item.name} amour was destroyed`);
437         roundData.roundDetails.push(`The ${monster.name} hit your HP for ${item.currentAp * -1} damage!`);
438         player.hp += item.currentAp;
439         item.currentAp = 0;
440         await deleteInventoryItem(player.id, item.item_id);
441       }
442       else {
443         roundData.roundDetails.push(`Your ${target} took ${damageAfterMitigation} damage!`);
444         await updateAp(player.id, item.item_id, item.currentAp, item.maxAp);
445       }
446
447     }
448     else {
449       roundData.roundDetails.push(`The ${monster.name} hit you for ${monster.strength} damage`);
450       player.hp -= monster.strength;
451     }
452
453     if(playerFinalHeal > 0) {
454       player.hp += playerFinalHeal;
455       if(player.hp > maxHp(player.constitution, player.level)) {
456         player.hp = maxHp(player.constitution, player.level);
457       }
458       roundData.roundDetails.push(`You healed for ${playerFinalHeal} HP`);
459     }
460
461     // update the players inventory for this item!
462
463     if(player.hp <= 0) {
464       player.hp = 0;
465       roundData.winner = 'monster';
466
467       roundData.roundDetails.push(`You were killed by the ${monster.name}`);
468
469       await clearFight(player.id);
470       await updatePlayer(player);
471
472       socket.emit('fight-over', {roundData, monsters: []});
473       return;
474     }
475
476     await updatePlayer(player);
477     await saveFightState(player.id, monster);
478
479     calcAp(equippedItems, socket);
480     socket.emit('fight-round', roundData);
481   });
482
483   // this is a special event to let the client know it can start 
484   // requesting data
485   socket.emit('ready');
486 });
487
488 function authEndpoint(req: Request, res: Response, next: any) {
489   const authToken = req.headers['x-authtoken'];
490   if(!authToken) {
491     logger.log(`Invalid auth token ${authToken}`);
492     res.sendStatus(400)
493   }
494   else {
495     next()
496   }
497 }
498
499 app.get('/city/:id', async (req: Request, res: Response) => {
500   const id = parseInt(req.params.id);
501   if(!id || isNaN(id)) {
502     return res.sendStatus(400);
503   }
504   const [city, locations, paths] = await Promise.all([
505     getCityDetails(id),
506     getAllServices(id),
507     getAllPaths(id)
508   ]);
509
510   res.json({city, locations, paths});
511 });
512
513 app.get('/fight', authEndpoint, async (req: Request, res: Response) => {
514   const authToken = req.headers['x-authtoken'].toString();
515   const player: Player = await loadPlayer(authToken)
516
517   if(!player) {
518     logger.log(`Couldnt find player with id ${authToken}`);
519     return res.sendStatus(400);
520   }
521
522   const fight = await loadMonsterFromFight(player.id);
523
524   res.json(fight || null);
525
526 });
527
528 app.post('/fight', authEndpoint, async (req: Request, res: Response) => {
529   const authToken = req.headers['x-authtoken'].toString();
530   const player: Player = await loadPlayer(authToken)
531
532   if(!player) {
533     logger.log(`Couldnt find player with id ${authToken}`);
534     return res.sendStatus(400);
535   }
536
537   const monsterId: number = req.body.monsterId;
538
539   if(!monsterId) {
540     logger.log(`Missing monster Id ${monsterId}`);
541     return res.sendStatus(400);
542   }
543
544   const monster = await loadMonster(monsterId);
545
546   if(!monster) {
547     logger.log(`Couldnt find monster for ${monsterId}`);
548     return res.sendStatus(400);
549   }
550
551   const fight = await createFight(player.id, monster);
552
553   const data: MonsterForFight = {
554     id: fight.id,
555     hp: fight.hp,
556     maxHp: fight.maxHp,
557     name: fight.name,
558     level: fight.level
559   };
560   res.json(data);
561 });
562
563 app.post('/signup', async (req: Request, res: Response) => {
564   const {username, password} = req.body;
565   const authToken = req.headers['x-authtoken'];
566
567   if(!username || !password || !authToken) {
568     res.sendStatus(400);
569     return;
570   }
571
572
573   try {
574     const player = await loadPlayer(authToken.toString());
575     console.log(`Attempted claim for ${player.username}`);
576
577     await signup(authToken.toString(), username, password);
578
579     await db('players').where({id: player.id}).update({
580       account_type: 'auth',
581       username: username
582     });
583
584     console.log(`Player claimed ${player.username} => ${username}`);
585
586     io.emit('chat', broadcastMessage('server', `${player.username} is now ${username}`));
587
588     player.username = username;
589     player.account_type = 'auth';
590
591     res.json({
592       player
593     });
594   }
595   catch(e) {
596     console.log(e);
597     if(e?.constraint === 'players_username_unique') {
598       res.send({
599         error: 'That username is already taken.'
600       }).status(500);
601     }
602     else {
603       res.send({error: 'Please try again'}).status(500);
604     }
605   }
606 });
607
608 app.post('/login', async (req: Request, res: Response) => {
609   const {username, password} = req.body;
610   try {
611     const player = await login(username, password);
612     res.json({player});
613   }
614   catch(e) {
615     console.log(e);
616     res.json({error: 'That user doesnt exist'}).status(500);
617   }
618 });
619
620 server.listen(process.env.API_PORT, () => {
621   logger.log(`Listening on port ${process.env.API_PORT}`);
622 });