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('chat', async (msg: string) => {
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!');
109 else if(msg === '/server lmnop refresh-cities') {
110 await createAllCitiesAndLocations();
111 message = broadcastMessage('server', 'Cities, Locations, and Paths refreshed!');
113 else if(msg === '/server lmnop refresh-shops') {
114 await createShopItems();
115 message = broadcastMessage('server', 'Refresh shop items');
118 const str = msg.split('/server lmnop ')[1];
120 message = broadcastMessage('server', str);
125 message = broadcastMessage(player.username, msg);
129 chatHistory.push(message);
130 chatHistory.slice(-10);
131 io.emit('chat', message);
136 socket.on('purchase', async (data) => {
137 const shopItem = await getShopItem(data.id);
140 if(player.gold < shopItem.cost) {
141 socket.emit('alert', {
143 text: `You dont have enough gold to buy the ${shopItem.name}`
148 player.gold -= shopItem.cost;
149 await updatePlayer(player);
150 await addInventoryItem(player.id, shopItem);
152 socket.emit('alert', {
154 text: `You bought the ${shopItem.name}`
157 socket.emit('updatePlayer', player);
162 _.each(EventList, event => {
163 logger.log(`Bound event listener: ${event.eventName}`);
164 socket.on(event.eventName, event.handler.bind(null, {
172 socket.on('skills', async () => {
173 const skills = await getPlayerSkills(player.id);
174 socket.emit('skills', {skills});
177 socket.on('inventory', async () => {
178 const inventory = await getInventory(player.id);
179 socket.emit('inventory', {
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 = {
190 winner: 'in-progress',
198 const equippedItems = await getEquippedItems(player.id);
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[] = [];
206 * cumulative chance of head/arms/body/legs
210 * we use the factor to decide how many decimal places
214 const monsterTarget = [0.2, 0.4, 0.9, 1];
215 const targets: ArmourEquipmentSlot[] = ['HEAD', 'CHEST', 'ARMS', 'LEGS'];
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;
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);
241 else if(item.type === 'WEAPON') {
244 else if(item.type === 'SPELL') {
245 if(item.affectedSkills.includes('destruction_magic')) {
246 anyDamageSpells = true;
251 boost.strength += item.boosts.strength;
252 boost.constitution += item.boosts.constitution;
253 boost.dexterity += item.boosts.dexterity;
254 boost.intelligence += item.boosts.intelligence;
256 if(item.type === 'SPELL' && item.affectedSkills.includes('restoration_magic')) {
257 boost.hp += item.boosts.damage;
260 boost.damage += item.boosts.damage;
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);
272 socket.emit('fight-over', {roundData, monsters: []});
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';
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;
285 weapons.forEach(item => {
286 item.affectedSkills.forEach(id => {
287 if(id === 'restoration_magic') {
288 if(hpHealAfterMasteries < 0) {
289 hpHealAfterMasteries = 0;
291 hpHealAfterMasteries += Skills.get(id).effect(playerSkills.get(id));
294 playerDamageAfterMasteries += playerDamage * Skills.get(id).effect(playerSkills.get(id));
297 if(!skillsUsed[id]) {
304 await updatePlayerSkills(player.id, skillsUsed);
306 const playerFinalDamage = (attackType === 'magical' && !anyDamageSpells) ? 0 : Math.floor(playerDamage + playerDamageAfterMasteries);
307 const playerFinalHeal = Math.floor(boost.hp + hpHealAfterMasteries);
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;
314 roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
315 if(monster.armsAp < 0) {
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;
324 roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
325 monster.hp -= playerFinalDamage;
328 else if (data.target === 'head') {
329 if(monster.helmAp > 0) {
330 monster.helmAp -= playerFinalDamage;
332 roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
333 if(monster.helmAp < 0) {
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;
342 roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
343 monster.hp -= playerFinalDamage;
346 else if(data.target === 'legs') {
347 if(monster.legsAp > 0) {
348 monster.legsAp -= playerFinalDamage;
350 roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
351 if(monster.legsAp < 0) {
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;
360 roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
361 monster.hp -= playerFinalDamage;
365 if(monster.chestAp > 0) {
366 monster.chestAp -= playerFinalDamage;
368 roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
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;
378 roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
379 monster.hp -= playerFinalDamage;
383 if(monster.hp <= 0) {
384 roundData.monster.hp = 0;
385 roundData.winner = 'player';
387 roundData.rewards.exp = monster.exp;
388 roundData.rewards.gold = monster.gold;
390 player.gold += monster.gold;
391 player.exp += monster.exp;
393 if(player.exp >= expToLevel(player.level + 1)) {
394 player.exp -= expToLevel(player.level + 1)
396 roundData.rewards.levelIncrease = true;
398 _.each(professionList[player.profession].onLevelUpStatIncrease(player.level), (v, stat) => {
400 roundData.roundDetails.push(`You gained +${v} ${stat}`);
405 player.hp = maxHp(player.constitution, player.level);
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 => {
414 level: monster.level,
420 await clearFight(player.id);
421 await updatePlayer(player);
422 socket.emit('fight-over', {roundData, monsters: potentialMonsters});
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;
435 await deleteInventoryItem(player.id, item.item_id);
438 roundData.roundDetails.push(`Your ${target} took ${monster.strength} damage!`);
439 await updateAp(player.id, item.item_id, item.currentAp, item.maxAp);
444 roundData.roundDetails.push(`The ${monster.name} hit you for ${monster.strength} damage`);
445 player.hp -= monster.strength;
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);
453 roundData.roundDetails.push(`You healed for ${playerFinalHeal} HP`);
456 // update the players inventory for this item!
460 roundData.winner = 'monster';
462 roundData.roundDetails.push(`You were killed by the ${monster.name}`);
464 await clearFight(player.id);
465 await updatePlayer(player);
467 socket.emit('fight-over', {roundData, monsters: []});
471 await updatePlayer(player);
472 await saveFightState(player.id, monster);
474 calcAp(equippedItems, socket);
475 socket.emit('fight-round', roundData);
478 // this is a special event to let the client know it can start
480 socket.emit('ready');
483 function authEndpoint(req: Request, res: Response, next: any) {
484 const authToken = req.headers['x-authtoken'];
486 logger.log(`Invalid auth token ${authToken}`);
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);
499 const [city, locations, paths] = await Promise.all([
505 res.json({city, locations, paths});
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)
513 logger.log(`Couldnt find player with id ${authToken}`);
514 return res.sendStatus(400);
517 const fight = await loadMonsterFromFight(player.id);
519 res.json(fight || null);
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)
528 logger.log(`Couldnt find player with id ${authToken}`);
529 return res.sendStatus(400);
532 const monsterId: number = req.body.monsterId;
535 logger.log(`Missing monster Id ${monsterId}`);
536 return res.sendStatus(400);
539 const monster = await loadMonster(monsterId);
542 logger.log(`Couldnt find monster for ${monsterId}`);
543 return res.sendStatus(400);
546 const fight = await createFight(player.id, monster);
548 const data: MonsterForFight = {
558 app.post('/signup', async (req: Request, res: Response) => {
559 const {username, password} = req.body;
560 const authToken = req.headers['x-authtoken'];
562 if(!username || !password || !authToken) {
569 const player = await loadPlayer(authToken.toString());
570 console.log(`Attempted claim for ${player.username}`);
572 await signup(authToken.toString(), username, password);
574 await db('players').where({id: player.id}).update({
575 account_type: 'auth',
579 console.log(`Player claimed ${player.username} => ${username}`);
581 io.emit('chat', broadcastMessage('server', `${player.username} is now ${username}`));
583 player.username = username;
584 player.account_type = 'auth';
592 if(e?.constraint === 'players_username_unique') {
594 error: 'That username is already taken.'
598 res.send({error: 'Please try again'}).status(500);
603 app.post('/login', async (req: Request, res: Response) => {
604 const {username, password} = req.body;
606 const player = await login(username, password);
611 res.json({error: 'That user doesnt exist'}).status(500);
615 server.listen(process.env.API_PORT, () => {
616 logger.log(`Listening on port ${process.env.API_PORT}`);