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