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';
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: Record<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 const 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();
84 cache[`token:${player.id}`] = socket.id;
86 logger.log(`Socket [${socket.id}] auth token: ${player.id}`);
88 socket.emit('authToken', player.id);
89 socket.emit('player', player);
91 const inventory = await getEquippedItems(player.id);
92 calcAp(inventory, socket);
96 io.emit('chathistory', chatHistory);
98 socket.emit('chat', broadcastMessage('server', `${player.username} just logged in`));
100 socket.on('disconnect', () => {
101 console.log(`Player ${player.username} left`);
104 socket.on('chat', async (msg: string) => {
107 let message: Message;
108 if(msg.startsWith('/server lmnop')) {
109 if(msg === '/server lmnop refresh-monsters') {
110 await createMonsters();
111 message = broadcastMessage('server', 'Monster refresh!');
113 else if(msg === '/server lmnop refresh-cities') {
114 await createAllCitiesAndLocations();
115 message = broadcastMessage('server', 'Cities, Locations, and Paths refreshed!');
117 else if(msg === '/server lmnop refresh-shops') {
118 await createShopItems();
119 message = broadcastMessage('server', 'Refresh shop items');
122 const str = msg.split('/server lmnop ')[1];
124 message = broadcastMessage('server', str);
129 message = broadcastMessage(player.username, msg);
133 chatHistory.push(message);
134 chatHistory.slice(-10);
135 io.emit('chat', message);
140 socket.on('purchase', async (data) => {
141 const shopItem = await getShopItem(data.id);
144 if(player.gold < shopItem.cost) {
145 socket.emit('alert', {
147 text: `You dont have enough gold to buy the ${shopItem.name}`
152 player.gold -= shopItem.cost;
153 await updatePlayer(player);
154 await addInventoryItem(player.id, shopItem);
156 socket.emit('alert', {
158 text: `You bought the ${shopItem.name}`
161 socket.emit('updatePlayer', player);
166 _.each(EventList, event => {
167 logger.log(`Bound event listener: ${event.eventName}`);
168 socket.on(event.eventName, event.handler.bind(null, {
176 socket.on('skills', async () => {
177 const skills = await getPlayerSkills(player.id);
178 socket.emit('skills', {skills});
181 socket.on('inventory', async () => {
182 const inventory = await getInventory(player.id);
183 socket.emit('inventory', {
188 socket.on('fight', async (data: {action: 'attack' | 'cast' | 'flee', target: 'head' | 'body' | 'arms' | 'legs'}) => {
189 const monster = await loadMonsterWithFaction(player.id);
190 const playerSkills = await getPlayerSkillsAsObject(player.id);
191 const roundData: FightRound = {
194 winner: 'in-progress',
202 const equippedItems = await getEquippedItems(player.id);
204 // we only use this if the player successfully defeated the monster
205 // they were fighting, then we load the other monsters in this area
206 // so they can "fight again"
207 let potentialMonsters: MonsterForFight[] = [];
210 * cumulative chance of head/arms/body/legs
214 * we use the factor to decide how many decimal places
218 const monsterTarget = [0.2, 0.4, 0.9, 1];
219 const targets: ArmourEquipmentSlot[] = ['HEAD', 'CHEST', 'ARMS', 'LEGS'];
221 const rand = Math.ceil(Math.random() * factor);
222 let target: ArmourEquipmentSlot = 'CHEST';
223 monsterTarget.forEach((i, idx) => {
224 if (rand > (i * factor)) {
225 target = targets[idx] as ArmourEquipmentSlot;
238 const equipment: Map<EquipmentSlot, EquippedItemDetails> = new Map<EquipmentSlot, EquippedItemDetails>();
239 const weapons: EquippedItemDetails[] = [];
240 let anyDamageSpells: boolean = false;
241 equippedItems.forEach(item => {
242 if(item.type === 'ARMOUR') {
243 equipment.set(item.equipment_slot, item);
245 else if(item.type === 'WEAPON') {
248 else if(item.type === 'SPELL') {
249 if(item.affectedSkills.includes('destruction_magic')) {
250 anyDamageSpells = true;
255 boost.strength += item.boosts.strength;
256 boost.constitution += item.boosts.constitution;
257 boost.dexterity += item.boosts.dexterity;
258 boost.intelligence += item.boosts.intelligence;
260 if(item.type === 'SPELL' && item.affectedSkills.includes('restoration_magic')) {
261 boost.hp += item.boosts.damage;
264 boost.damage += item.boosts.damage;
268 // if you flee'd, then we want to check your dex vs. the monsters
269 // but we want to give you the item/weapon boosts you need
270 // if not then you're going to get hit.
271 if(data.action === 'flee') {
272 roundData.roundDetails.push(`You managed to escape from the ${monster.name}!`)
273 roundData.winner = 'monster';
274 await clearFight(player.id);
276 socket.emit('fight-over', {roundData, monsters: []});
280 const primaryStat = data.action === 'attack' ? player.strength : player.constitution;
281 const boostStat = data.action === 'attack' ? boost.strength : boost.constitution;
282 const attackType = data.action === 'attack' ? 'physical' : 'magical';
284 const playerDamage = Math.floor(((primaryStat + boostStat) * 1.3) + boost.damage);
285 const skillsUsed: Record<SkillID | any, number> = {};
286 let hpHealAfterMasteries: number = -1;
287 let playerDamageAfterMasteries: number = 0;
289 weapons.forEach(item => {
290 item.affectedSkills.forEach(id => {
291 if(id === 'restoration_magic') {
292 if(hpHealAfterMasteries < 0) {
293 hpHealAfterMasteries = 0;
295 hpHealAfterMasteries += Skills.get(id).effect(playerSkills.get(id));
298 playerDamageAfterMasteries += playerDamage * Skills.get(id).effect(playerSkills.get(id));
301 if(!skillsUsed[id]) {
308 await updatePlayerSkills(player.id, skillsUsed);
310 const playerFinalDamage = (attackType === 'magical' && !anyDamageSpells) ? 0 : Math.floor(playerDamage + playerDamageAfterMasteries);
311 const playerFinalHeal = Math.floor(boost.hp + hpHealAfterMasteries);
313 roundData.roundDetails.push(`You targeted the monsters ${data.target.toUpperCase()} with ${attackType} damage!`);
314 if(data.target === 'arms') {
315 if(monster.armsAp > 0) {
316 monster.armsAp -= playerFinalDamage;
318 roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
319 if(monster.armsAp < 0) {
321 roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
322 roundData.roundDetails.push(`You dealt ${monster.armsAp * -1} damage to their HP`);
323 monster.hp += monster.armsAp;
328 roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
329 monster.hp -= playerFinalDamage;
332 else if (data.target === 'head') {
333 if(monster.helmAp > 0) {
334 monster.helmAp -= playerFinalDamage;
336 roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
337 if(monster.helmAp < 0) {
339 roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
340 roundData.roundDetails.push(`You dealt ${monster.armsAp * 1} damage to their HP`);
341 monster.hp += monster.helmAp;
346 roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
347 monster.hp -= playerFinalDamage;
350 else if(data.target === 'legs') {
351 if(monster.legsAp > 0) {
352 monster.legsAp -= playerFinalDamage;
354 roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
355 if(monster.legsAp < 0) {
357 roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
358 roundData.roundDetails.push(`You dealt ${monster.legsAp * 1} damage to their HP`);
359 monster.hp += monster.legsAp;
364 roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
365 monster.hp -= playerFinalDamage;
369 if(monster.chestAp > 0) {
370 monster.chestAp -= playerFinalDamage;
372 roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
374 if(monster.chestAp < 0) {
375 roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
376 roundData.roundDetails.push(`You dealt ${monster.chestAp * 1} damage to their HP`);
377 monster.hp += monster.chestAp;
382 roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
383 monster.hp -= playerFinalDamage;
387 if(monster.hp <= 0) {
388 roundData.monster.hp = 0;
389 roundData.winner = 'player';
391 roundData.rewards.exp = monster.exp;
392 roundData.rewards.gold = monster.gold;
394 player.gold += monster.gold;
395 player.exp += monster.exp;
397 if(player.exp >= expToLevel(player.level + 1)) {
398 player.exp -= expToLevel(player.level + 1)
400 roundData.rewards.levelIncrease = true;
401 let statPointsGained = 1;
403 if(player.profession !== 'Wanderer') {
404 statPointsGained = 2;
407 player.stat_points += statPointsGained;
409 roundData.roundDetails.push(`You gained ${statPointsGained} stat points!`);
411 player.hp = maxHp(player.constitution, player.level);
413 // get the monster location!
414 const rawMonster = await loadMonster(monster.ref_id);
415 const monsterList = await getMonsterList(rawMonster.location_id);
416 potentialMonsters = monsterList.map(monster => {
420 level: monster.level,
426 await clearFight(player.id);
427 await updatePlayer(player);
428 socket.emit('fight-over', {roundData, monsters: potentialMonsters});
432 roundData.roundDetails.push(`The ${monster.name} targeted your ${target}!`);
433 if(equipment.has(target)) {
434 const item = equipment.get(target);
436 const mitigationPercentage = item.boosts.damage_mitigation || 0;
437 const damageAfterMitigation = Math.floor(monster.strength * ((100-mitigationPercentage)/100));
439 item.currentAp -= damageAfterMitigation;
441 if(item.currentAp < 0) {
442 roundData.roundDetails.push(`Your ${item.name} amour was destroyed`);
443 roundData.roundDetails.push(`The ${monster.name} hit your HP for ${item.currentAp * -1} damage!`);
444 player.hp += item.currentAp;
446 await deleteInventoryItem(player.id, item.item_id);
449 roundData.roundDetails.push(`Your ${target} took ${damageAfterMitigation} damage!`);
450 await updateAp(player.id, item.item_id, item.currentAp, item.maxAp);
455 roundData.roundDetails.push(`The ${monster.name} hit you for ${monster.strength} damage`);
456 player.hp -= monster.strength;
459 if(playerFinalHeal > 0) {
460 player.hp += playerFinalHeal;
461 if(player.hp > maxHp(player.constitution, player.level)) {
462 player.hp = maxHp(player.constitution, player.level);
464 roundData.roundDetails.push(`You healed for ${playerFinalHeal} HP`);
467 // update the players inventory for this item!
471 roundData.winner = 'monster';
473 roundData.roundDetails.push(`You were killed by the ${monster.name}`);
475 await clearFight(player.id);
476 await updatePlayer(player);
478 socket.emit('fight-over', {roundData, monsters: []});
482 await updatePlayer(player);
483 await saveFightState(player.id, monster);
485 calcAp(equippedItems, socket);
486 socket.emit('fight-round', roundData);
489 // this is a special event to let the client know it can start
491 socket.emit('ready');
494 function authEndpoint(req: Request, res: Response, next: any) {
495 const authToken = req.headers['x-authtoken'];
497 logger.log(`Invalid auth token ${authToken}`);
505 app.get('/city/:id', async (req: Request, res: Response) => {
506 const id = parseInt(req.params.id);
507 if(!id || isNaN(id)) {
508 return res.sendStatus(400);
510 const [city, locations, paths] = await Promise.all([
516 res.json({city, locations, paths});
519 app.get('/fight', authEndpoint, async (req: Request, res: Response) => {
520 const authToken = req.headers['x-authtoken'].toString();
521 const player: Player = await loadPlayer(authToken)
524 logger.log(`Couldnt find player with id ${authToken}`);
525 return res.sendStatus(400);
528 const fight = await loadMonsterFromFight(player.id);
530 res.json(fight || null);
534 app.post('/fight', authEndpoint, async (req: Request, res: Response) => {
535 const authToken = req.headers['x-authtoken'].toString();
536 const player: Player = await loadPlayer(authToken)
539 logger.log(`Couldnt find player with id ${authToken}`);
540 return res.sendStatus(400);
543 const monsterId: number = req.body.monsterId;
546 logger.log(`Missing monster Id ${monsterId}`);
547 return res.sendStatus(400);
550 const monster = await loadMonster(monsterId);
553 logger.log(`Couldnt find monster for ${monsterId}`);
554 return res.sendStatus(400);
557 const fight = await createFight(player.id, monster);
559 const data: MonsterForFight = {
569 app.post('/signup', async (req: Request, res: Response) => {
570 const {username, password} = req.body;
571 const authToken = req.headers['x-authtoken'];
573 if(!username || !password || !authToken) {
580 const player = await loadPlayer(authToken.toString());
581 console.log(`Attempted claim for ${player.username}`);
583 await signup(authToken.toString(), username, password);
585 await db('players').where({id: player.id}).update({
586 account_type: 'auth',
590 console.log(`Player claimed ${player.username} => ${username}`);
592 io.emit('chat', broadcastMessage('server', `${player.username} is now ${username}`));
594 player.username = username;
595 player.account_type = 'auth';
603 if(e?.constraint === 'players_username_unique') {
605 error: 'That username is already taken.'
609 res.send({error: 'Please try again'}).status(500);
614 app.post('/login', async (req: Request, res: Response) => {
615 const {username, password} = req.body;
617 const player = await login(username, password);
622 res.json({error: 'That user doesnt exist'}).status(500);
626 server.listen(process.env.API_PORT, () => {
627 logger.log(`Listening on port ${process.env.API_PORT}`);