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 {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 {FightTrigger, MonsterForFight} from '../shared/monsters';
17 import {getShopItem } from './shopItem';
18 import {EquippedItemDetails} from '../shared/equipped';
19 import {ArmourEquipmentSlot, EquipmentSlot} from '../shared/inventory';
20 import { clearTravelPlan, getAllPaths, getAllServices, getCityDetails, getTravelPlan, travel } 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';
28 import { createMonsters } from '../../seeds/monsters';
29 import { createAllCitiesAndLocations } from '../../seeds/cities';
30 import { createShopItems } from '../../seeds/shop_items';
36 const app = express();
37 const server = http.createServer(app);
39 app.use(express.static(join(__dirname, '..', '..', 'public')));
40 app.use(express.json());
42 const io = new Server(server);
44 const cache = new Map<string, any>();
45 const chatHistory: Message[] = [];
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,
58 socket.emit('calc:ap', {ap});
61 function setServerStats() {
62 io.emit('server-stats', {
63 onlinePlayers: io.sockets.sockets.size
67 setTimeout(setServerStats, 5000);
69 io.on('connection', async socket => {
70 logger.log(`socket ${socket.id} connected, authToken: ${socket.handshake.headers['x-authtoken']}`);
72 let authToken = socket.handshake.headers['x-authtoken'].toString() === 'null' ? null : socket.handshake.headers['x-authtoken'].toString();
76 logger.log(`Attempting to load player with id ${authToken}`);
77 player = await loadPlayer(authToken);
80 logger.log(`Creating player`);
81 player = await createPlayer();
82 authToken = player.id;
83 socket.handshake.headers['x-authtoken'] = authToken;
86 logger.log(`Socket [${socket.id}] auth token: ${player.id}`);
88 // ref to get the socket id for a particular player
89 cache.set(`socket:${player.id}`, socket.id);
90 // ref to get the player object
91 cache.set(`token:${player.id}`, player);
97 socket.emit('authToken', player.id);
98 socket.emit('player', player);
100 const inventory = await getEquippedItems(player.id);
101 calcAp(inventory, socket);
105 io.emit('chathistory', chatHistory);
107 socket.emit('chat', broadcastMessage('server', `${player.username} just logged in`));
109 socket.on('disconnect', () => {
110 console.log(`Player ${player.username} left`);
113 socket.on('chat', async (msg: string) => {
116 let message: Message;
117 if(msg.startsWith('/server lmnop')) {
118 if(msg === '/server lmnop refresh-monsters') {
119 await createMonsters();
120 message = broadcastMessage('server', 'Monster refresh!');
122 else if(msg === '/server lmnop refresh-cities') {
123 await createAllCitiesAndLocations();
124 message = broadcastMessage('server', 'Cities, Locations, and Paths refreshed!');
126 else if(msg === '/server lmnop refresh-shops') {
127 await createShopItems();
128 message = broadcastMessage('server', 'Refresh shop items');
131 const str = msg.split('/server lmnop ')[1];
133 message = broadcastMessage('server', str);
138 const authToken = socket.handshake.headers['x-authtoken'];
139 if(cache.has(`token:${authToken}`)) {
140 const player = cache.get(`token:${authToken}`);
141 message = broadcastMessage(player.username, msg);
144 logger.log(`Missing cache for [token:${authToken}]`);
149 chatHistory.push(message);
150 chatHistory.slice(-10);
151 io.emit('chat', message);
154 logger.log(`Unset message`);
159 socket.on('purchase', async (data) => {
160 const authToken = socket.handshake.headers['x-authtoken'];
161 if(!cache.has(`token:${authToken}`)) {
164 const player = cache.get(`token:${authToken}`);
165 const shopItem = await getShopItem(data.id);
167 if(shopItem && player) {
168 if(player.gold < shopItem.cost) {
169 socket.emit('alert', {
171 text: `You dont have enough gold to buy the ${shopItem.name}`
176 player.gold -= shopItem.cost;
177 await updatePlayer(player);
178 await addInventoryItem(player.id, shopItem);
180 socket.emit('alert', {
182 text: `You bought the ${shopItem.name}`
185 socket.emit('updatePlayer', player);
189 _.each(EventList, event => {
190 logger.log(`Bound event listener: ${event.eventName}`);
191 socket.on(event.eventName, event.handler.bind(null, {
199 socket.on('skills', async () => {
200 const player = cache.get(`token:${socket.handshake.headers['x-authtoken']}`);
204 const skills = await getPlayerSkills(player.id);
205 socket.emit('skills', {skills});
208 socket.on('inventory', async () => {
209 const player = cache.get(`token:${socket.handshake.headers['x-authtoken']}`);
213 const inventory = await getInventory(player.id);
214 socket.emit('inventory', {
219 socket.on('logout', async () => {
220 // clear this player from the cache!
221 const player = cache.get(`token:${socket.handshake.headers['x-authtoken']}`);
223 logger.log(`Player ${player.username} logged out`);
226 logger.log(`Invalid user logout`);
230 socket.on('fight', async (data: {action: 'attack' | 'cast' | 'flee', target: 'head' | 'body' | 'arms' | 'legs'}) => {
231 const authToken = `token:${socket.handshake.headers['x-authtoken']}`;
232 const player = cache.get(authToken);
234 logger.log(`Invalid token for fight`)
237 const monster = await loadMonsterWithFaction(player.id);
238 const playerSkills = await getPlayerSkillsAsObject(player.id);
239 const roundData: FightRound = {
242 winner: 'in-progress',
243 fightTrigger: monster.fight_trigger,
251 const equippedItems = await getEquippedItems(player.id);
253 // we only use this if the player successfully defeated the monster
254 // they were fighting, then we load the other monsters in this area
255 // so they can "fight again"
256 let potentialMonsters: MonsterForFight[] = [];
259 * cumulative chance of head/arms/body/legs
263 * we use the factor to decide how many decimal places
267 const monsterTarget = [0.2, 0.4, 0.9, 1];
268 const targets: ArmourEquipmentSlot[] = ['HEAD', 'CHEST', 'ARMS', 'LEGS'];
270 const rand = Math.ceil(Math.random() * factor);
271 let target: ArmourEquipmentSlot = 'CHEST';
272 monsterTarget.forEach((i, idx) => {
273 if (rand > (i * factor)) {
274 target = targets[idx] as ArmourEquipmentSlot;
287 const equipment: Map<EquipmentSlot, EquippedItemDetails> = new Map<EquipmentSlot, EquippedItemDetails>();
288 const weapons: EquippedItemDetails[] = [];
289 let anyDamageSpells: boolean = false;
290 equippedItems.forEach(item => {
291 if(item.type === 'ARMOUR') {
292 equipment.set(item.equipment_slot, item);
294 else if(item.type === 'WEAPON') {
297 else if(item.type === 'SPELL') {
298 if(item.affectedSkills.includes('destruction_magic')) {
299 anyDamageSpells = true;
304 boost.strength += item.boosts.strength;
305 boost.constitution += item.boosts.constitution;
306 boost.dexterity += item.boosts.dexterity;
307 boost.intelligence += item.boosts.intelligence;
309 if(item.type === 'SPELL' && item.affectedSkills.includes('restoration_magic')) {
310 boost.hp += item.boosts.damage;
313 boost.damage += item.boosts.damage;
317 // if you flee'd, then we want to check your dex vs. the monsters
318 // but we want to give you the item/weapon boosts you need
319 // if not then you're going to get hit.
320 if(data.action === 'flee') {
321 roundData.roundDetails.push(`You managed to escape from the ${monster.name}!`)
322 roundData.winner = 'monster';
323 await clearFight(player.id);
325 socket.emit('fight-over', {roundData, monsters: []});
329 const attackType = data.action === 'attack' ? 'physical' : 'magical';
330 const primaryStat = data.action === 'attack' ? player.strength : player.intelligence;
331 const boostStat = data.action === 'attack' ? boost.strength : boost.intelligence;
333 const playerDamage = Math.floor(((primaryStat + boostStat) * 1.3) + boost.damage);
334 const skillsUsed: Record<SkillID | any, number> = {};
335 let hpHealAfterMasteries: number = -1;
336 let playerDamageAfterMasteries: number = 0;
338 weapons.forEach(item => {
339 item.affectedSkills.forEach(id => {
340 if(id === 'restoration_magic') {
341 if(hpHealAfterMasteries < 0) {
342 hpHealAfterMasteries = 0;
344 hpHealAfterMasteries += Skills.get(id).effect(playerSkills.get(id));
347 playerDamageAfterMasteries += playerDamage * Skills.get(id).effect(playerSkills.get(id));
350 if(!skillsUsed[id]) {
357 await updatePlayerSkills(player.id, skillsUsed);
359 const playerFinalDamage = (data.action === 'cast' && !anyDamageSpells) ? 0 : Math.floor(playerDamage + playerDamageAfterMasteries);
360 const playerFinalHeal = Math.floor(boost.hp + hpHealAfterMasteries);
362 roundData.roundDetails.push(`You targeted the monsters ${data.target.toUpperCase()} with ${attackType} damage!`);
363 let armourKey: string;
364 switch(data.target) {
366 armourKey = 'armsAp';
369 armourKey = 'helmAp';
372 armourKey = 'legsAp';
375 armourKey = 'chestAp';
379 if(monster[armourKey] && monster[armourKey] > 0) {
380 monster[armourKey] -= playerFinalDamage;
382 roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
383 if(monster[armourKey] < 0) {
384 roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
385 roundData.roundDetails.push(`You dealt ${monster[armourKey] * -1} damage to their HP`);
386 monster.hp += monster[armourKey];
387 monster[armourKey] = 0;
391 roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
392 monster.hp -= playerFinalDamage;
395 if(monster.hp <= 0) {
396 roundData.monster.hp = 0;
397 roundData.winner = 'player';
399 roundData.rewards.exp = monster.exp;
400 roundData.rewards.gold = monster.gold;
402 player.gold += monster.gold;
403 player.exp += monster.exp;
405 if(player.exp >= expToLevel(player.level + 1)) {
406 player.exp -= expToLevel(player.level + 1)
408 roundData.rewards.levelIncrease = true;
409 let statPointsGained = 1;
411 if(player.profession !== 'Wanderer') {
412 statPointsGained = 2;
415 player.stat_points += statPointsGained;
417 roundData.roundDetails.push(`You gained ${statPointsGained} stat points!`);
419 player.hp = maxHp(player.constitution, player.level);
421 // get the monster location if it was an EXPLORED fight
422 if(roundData.fightTrigger === 'explore') {
423 const rawMonster = await loadMonster(monster.ref_id);
424 const monsterList = await getMonsterList(rawMonster.location_id);
425 potentialMonsters = monsterList.map(monster => {
429 level: monster.level,
431 maxHp: monster.maxHp,
432 fight_trigger: 'explore'
437 await clearFight(player.id);
438 await updatePlayer(player);
439 cache.set(authToken, player);
440 socket.emit('fight-over', {roundData, monsters: potentialMonsters});
444 roundData.roundDetails.push(`The ${monster.name} targeted your ${target}!`);
445 if(equipment.has(target)) {
446 const item = equipment.get(target);
448 const mitigationPercentage = item.boosts.damage_mitigation || 0;
449 const damageAfterMitigation = Math.floor(monster.strength * ((100-mitigationPercentage)/100));
451 item.currentAp -= damageAfterMitigation;
453 if(item.currentAp < 0) {
454 roundData.roundDetails.push(`Your ${item.name} amour was destroyed`);
455 roundData.roundDetails.push(`The ${monster.name} hit your HP for ${item.currentAp * -1} damage!`);
456 player.hp += item.currentAp;
458 await deleteInventoryItem(player.id, item.item_id);
461 roundData.roundDetails.push(`Your ${target} took ${damageAfterMitigation} damage!`);
462 await updateAp(player.id, item.item_id, item.currentAp, item.maxAp);
467 roundData.roundDetails.push(`The ${monster.name} hit you for ${monster.strength} damage`);
468 player.hp -= monster.strength;
471 if(playerFinalHeal > 0) {
472 player.hp += playerFinalHeal;
473 if(player.hp > maxHp(player.constitution, player.level)) {
474 player.hp = maxHp(player.constitution, player.level);
476 roundData.roundDetails.push(`You healed for ${playerFinalHeal} HP`);
479 // update the players inventory for this item!
483 roundData.winner = 'monster';
485 roundData.roundDetails.push(`You were killed by the ${monster.name}`);
487 await clearFight(player.id);
488 await updatePlayer(player);
489 await clearTravelPlan(player.id);
491 cache.set(authToken, player);
493 socket.emit('fight-over', {roundData, monsters: []});
497 await updatePlayer(player);
498 await saveFightState(player.id, monster);
499 cache.set(authToken, player);
501 calcAp(equippedItems, socket);
502 socket.emit('fight-round', roundData);
505 // this is a special event to let the client know it can start
507 socket.emit('ready');
510 function authEndpoint(req: Request, res: Response, next: any) {
511 const authToken = req.headers['x-authtoken'];
513 logger.log(`Invalid auth token ${authToken}`);
521 app.get('/city/:id', async (req: Request, res: Response) => {
522 const id = parseInt(req.params.id);
523 if(!id || isNaN(id)) {
524 return res.sendStatus(400);
526 const [city, locations, paths] = await Promise.all([
532 res.json({city, locations, paths});
535 app.get('/state', authEndpoint, async (req: Request, res: Response) => {
536 const authToken = req.headers['x-authtoken'].toString();
537 const player: Player = await loadPlayer(authToken)
538 let closestTown: number = player.city_id;
541 logger.log(`Couldnt find player with id ${authToken}`);
542 return res.sendStatus(400);
545 const fight = await loadMonsterFromFight(player.id);
547 // check if the player is exploring somewhere!
548 const travelPlan = await getTravelPlan(player.id);
551 closestTown = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.source_id : travelPlan.destination_id;
555 fight: fight || null,
557 travel: travelPlan ? {
560 walkingText: 'You keep walking...'
568 app.post('/travel', authEndpoint, async (req: Request, res: Response) => {
569 const authToken = req.headers['x-authtoken'].toString();
570 const player: Player = await loadPlayer(authToken)
571 const destination_id = parseInt(req.body.destination_id);
574 logger.log(`Couldnt find player with id ${authToken}`);
575 return res.sendStatus(400);
578 if(!destination_id || isNaN(destination_id)) {
579 logger.log(`Invalid destination_id [${req.body.destination_id}]`);
580 return res.sendStatus(400);
583 const travelPlan = travel(player, req.body.destination_id);
585 res.json(travelPlan);
589 app.post('/fight', authEndpoint, async (req: Request, res: Response) => {
590 const authToken = req.headers['x-authtoken'].toString();
591 const player: Player = await loadPlayer(authToken)
594 logger.log(`Couldnt find player with id ${authToken}`);
595 return res.sendStatus(400);
599 logger.log(`Player didn\'t have enough hp`);
600 return res.sendStatus(400);
603 const monsterId: number = req.body.monsterId;
604 const fightTrigger: FightTrigger = req.body.fightTrigger;
608 logger.log(`Missing monster Id ${monsterId}`);
609 return res.sendStatus(400);
612 if(!fightTrigger || !['travel', 'explore'].includes(fightTrigger)) {
613 logger.log(`Invalid fight trigger [${fightTrigger}]`);
614 return res.sendStatus(400);
617 const monster = await loadMonster(monsterId);
620 logger.log(`Couldnt find monster for ${monsterId}`);
621 return res.sendStatus(400);
624 const fight = await createFight(player.id, monster, fightTrigger);
626 const data: MonsterForFight = {
632 fight_trigger: fight.fight_trigger
637 app.post('/signup', async (req: Request, res: Response) => {
638 const {username, password} = req.body;
639 const authToken = req.headers['x-authtoken'];
641 if(!username || !password || !authToken) {
648 const player = await loadPlayer(authToken.toString());
649 logger.log(`Attempted claim for ${player.username}`);
651 await signup(authToken.toString(), username, password);
653 await db('players').where({id: player.id}).update({
654 account_type: 'auth',
658 logger.log(`Player claimed ${player.username} => ${username}`);
660 io.emit('chat', broadcastMessage('server', `${player.username} is now ${username}`));
662 player.username = username;
663 player.account_type = 'auth';
671 if(e?.constraint === 'players_username_unique') {
673 error: 'That username is already taken.'
677 res.send({error: 'Please try again'}).status(500);
682 app.post('/login', async (req: Request, res: Response) => {
683 const {username, password} = req.body;
685 const player = await login(username, password);
690 res.json({error: 'That user doesnt exist'}).status(500);
694 server.listen(process.env.API_PORT, () => {
695 logger.log(`Listening on port ${process.env.API_PORT}`);