import * as otel from './tracing';
+import { version } from "../../package.json";
import { config as dotenv } from 'dotenv';
import { join } from 'path';
import express, {Request, Response} from 'express';
+import bodyParser from 'body-parser';
+import xss from 'xss';
+import { rateLimit } from 'express-rate-limit';
+
import http from 'http';
import { Server, Socket } from 'socket.io';
+import * as CONSTANT from '../shared/constants';
import { logger } from './lib/logger';
-import { loadPlayer, createPlayer, updatePlayer } from './player';
-import * as _ from 'lodash';
+import { loadPlayer, createPlayer, updatePlayer, movePlayer } from './player';
+import { random, sample } from 'lodash';
import {broadcastMessage, Message} from '../shared/message';
-import {expToLevel, maxHp, Player} from '../shared/player';
-import { professionList } from '../shared/profession';
-import {clearFight, createFight, getMonsterList, loadMonster, loadMonsterFromFight, loadMonsterWithFaction, saveFightState} from './monster';
-import {FightRound} from '../shared/fight';
-import {addInventoryItem, deleteInventoryItem, getEquippedItems, getInventory, updateAp} from './inventory';
-import {Monster, MonsterForFight, MonsterForList} from '../shared/monsters';
-import {getShopItem } from './shopItem';
-import {EquippedItemDetails} from '../shared/equipped';
-import {ArmourEquipmentSlot, EquipmentSlot} from '../shared/inventory';
-import { getAllPaths, getAllServices, getCityDetails } from './map';
-import { signup, login } from './auth';
+import {maxHp, Player} from '../shared/player';
+import {createFight, getMonsterList, getMonsterLocation, getRandomMonster, loadMonster, loadMonsterFromFight, loadMonsterWithFaction} from './monster';
+import {addInventoryItem, getEquippedItems, getInventory, getInventoryItem} from './inventory';
+import { getItemFromPlayer, getItemFromShop, getPlayersItems, getShopItems, givePlayerItem, updateItemCount } from './items';
+import {FightTrigger, Monster, MonsterForFight} from '../shared/monsters';
+import {getShopEquipment, listShopItems } from './shopEquipment';
+import {EquipmentSlot} from '../shared/inventory';
+import { clearTravelPlan, completeTravel, getAllPaths, getAllServices, getCityDetails, getService, getTravelPlan, stepForward, travel } from './map';
+import { signup, login, authEndpoint, AuthRequest } from './auth';
import {db} from './lib/db';
-import { getPlayerSkills, getPlayerSkillsAsObject, updatePlayerSkills } from './skills';
-import {SkillID, Skills} from '../shared/skills';
-import * as EventList from '../events/server';
+import { getPlayerSkills} from './skills';
+
+import { fightRound, blockPlayerInFight } from './fight';
+
+import { router as healerRouter } from './locations/healer';
+import { router as professionRouter } from './locations/recruiter';
+
+import * as Alert from './views/alert';
+import { renderPlayerBar } from './views/player-bar'
+import { renderEquipmentDetails, renderStore } from './views/stores';
+import { renderMap } from './views/map';
+import { renderProfilePage } from './views/profile';
+import { renderSkills } from './views/skills';
+import { renderInventoryPage } from './views/inventory';
+import { renderMonsterSelector, renderOnlyMonsterSelector } from './views/monster-selector';
+import { renderFight, renderFightPreRound, renderRoundDetails } from './views/fight';
+import { renderTravel, travelButton } from './views/travel';
+import { renderChatMessage } from './views/chat';
// TEMP!
import { createMonsters } from '../../seeds/monsters';
import { createAllCitiesAndLocations } from '../../seeds/cities';
-import { createShopItems } from '../../seeds/shop_items';
+import { createShopItems, createShopEquipment } from '../../seeds/shop_items';
+import { Item, PlayerItem, ShopItem } from 'shared/items';
+import { equip, unequip } from './equipment';
+import { HealthPotionSmall } from '../shared/items/health_potion';
dotenv();
const server = http.createServer(app);
app.use(express.static(join(__dirname, '..', '..', 'public')));
+app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.json());
const io = new Server(server);
-const cache: Record<string, any> = {};
+const cache = new Map<string, any>();
const chatHistory: Message[] = [];
-function calcAp(inventoryItem: EquippedItemDetails[], socket: Socket) {
- const ap: Record<any | EquipmentSlot, {currentAp: number, maxAp: number}> = {};
- inventoryItem.forEach(item => {
- if(item.is_equipped && item.type === 'ARMOUR') {
- ap[item.equipment_slot] = {
- currentAp: item.currentAp,
- maxAp: item.maxAp
- };
- }
- });
+app.use((req, res, next) => {
+ console.log(req.method, req.url);
+ next();
+});
- socket.emit('calc:ap', {ap});
-}
+const fightRateLimiter = rateLimit({
+ windowMs: parseInt(process.env.RATE_LIMIT_WINDOW || '30000'),
+ max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '20'),
+ standardHeaders: true,
+ legacyHeaders: false,
+ handler: (req, res, next, options) => {
+ logger.log(`Blocked request: [${req.headers['x-authtoken']}: ${req.method} ${req.path}]`);
+ res.status(options.statusCode).send(options.message);
+ }
+});
-function setServerStats() {
- io.emit('server-stats', {
- onlinePlayers: io.sockets.sockets.size
- });
-}
+async function bootstrapSocket(socket: Socket, player: Player) {
+ // ref to get the socket id for a particular player
+ cache.set(`socket:${player.id}`, socket.id);
+ // ref to get the player object
+ cache.set(`token:${player.id}`, player);
-async function socketSendMonsterList(location_id: number, socket: Socket) {
- const monsters: Monster[] = await getMonsterList(location_id);
- let data: MonsterForList[] = monsters.map(monster => {
- return {
- id: monster.id,
- name: monster.name,
- level: monster.level
- }
- });
+ socket.emit('authToken', player.id);
- socket.emit('explore:fights', data);
+ socket.emit('chat', renderChatMessage(broadcastMessage('server', `${player.username} just logged in`)));
}
-setTimeout(setServerStats, 5000);
-
io.on('connection', async socket => {
logger.log(`socket ${socket.id} connected, authToken: ${socket.handshake.headers['x-authtoken']}`);
- const authToken = socket.handshake.headers['x-authtoken'].toString() === 'null' ? null : socket.handshake.headers['x-authtoken'].toString();
+ let authToken = socket.handshake.headers['x-authtoken'].toString() === 'null' ? null : socket.handshake.headers['x-authtoken'].toString();
let player: Player;
if(authToken) {
if(!player) {
logger.log(`Creating player`);
player = await createPlayer();
+ authToken = player.id;
+ socket.handshake.headers['x-authtoken'] = authToken;
}
- cache[`token:${player.id}`] = socket.id;
-
logger.log(`Socket [${socket.id}] auth token: ${player.id}`);
- socket.emit('authToken', player.id);
- socket.emit('player', player);
+ bootstrapSocket(socket, player);
- const inventory = await getEquippedItems(player.id);
- calcAp(inventory, socket);
+ socket.on('disconnect', () => {
+ console.log(`Player ${player.username} left`);
+ io.emit('status', `${io.sockets.sockets.size} Online (v${version})`);
+ });
- setServerStats();
- io.emit('chathistory', chatHistory);
+ io.emit('status', `${io.sockets.sockets.size} Online (v${version})`);
+ // this is a special event to let the client know it can start
+ // requesting data
+ socket.emit('ready');
+});
- socket.emit('chat', broadcastMessage('server', `${player.username} just logged in`));
- socket.on('chat', async (msg: string) => {
- if(msg.length > 0) {
+app.use(healerRouter);
+app.use(professionRouter);
- let message: Message;
- if(msg.startsWith('/server lmnop')) {
- if(msg === '/server lmnop refresh-monsters') {
- await createMonsters();
- message = broadcastMessage('server', 'Monster refresh!');
- }
- else if(msg === '/server lmnop refresh-cities') {
- await createAllCitiesAndLocations();
- message = broadcastMessage('server', 'Cities, Locations, and Paths refreshed!');
- }
- else if(msg === '/server lmnop refresh-shops') {
- await createShopItems();
- message = broadcastMessage('server', 'Refresh shop items');
- }
- else {
- const str = msg.split('/server lmnop ')[1];
- if(str) {
- message = broadcastMessage('server', str);
- }
- }
- }
- else {
- message = broadcastMessage(player.username, msg);
- }
- if(message) {
- chatHistory.push(message);
- chatHistory.slice(-10);
- io.emit('chat', message);
- }
- }
- });
+app.get('/chat/history', authEndpoint, async (req: AuthRequest, res: Response) => {
+ let html = chatHistory.map(renderChatMessage);
- socket.on('calc:ap', async () => {
- const items = await getEquippedItems(player.id);
- calcAp(items, socket);
- });
+ res.send(html.join("\n"));
+});
- socket.on('purchase', async (data) => {
- const shopItem = await getShopItem(data.id);
+app.post('/chat', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const msg = req.body.message.trim();
- if(shopItem) {
- if(player.gold < shopItem.cost) {
- socket.emit('alert', {
- type: 'error',
- text: `You dont have enough gold to buy the ${shopItem.name}`
- });
- return;
- }
+ if(!msg || !msg.length) {
+ res.sendStatus(204);
+ return;
+ }
- player.gold -= shopItem.cost;
- await updatePlayer(player);
- await addInventoryItem(player.id, shopItem);
+ let message: Message;
+ if(msg.startsWith('/server lmnop')) {
+ if(msg === '/server lmnop refresh-monsters') {
+ await createMonsters();
+ message = broadcastMessage('server', 'Monster refresh!');
+ }
+ else if(msg === '/server lmnop refresh-cities') {
+ await createAllCitiesAndLocations();
+ message = broadcastMessage('server', 'Cities, Locations, and Paths refreshed!');
+ }
+ else if(msg === '/server lmnop refresh-shops') {
+ await createShopItems();
+ await createShopEquipment();
+ message = broadcastMessage('server', 'Refresh shop items');
+ }
+ else {
+ const str = msg.split('/server lmnop ')[1];
+ if(str) {
+ message = broadcastMessage('server', str);
+ }
+ }
+ }
+ else {
+ message = broadcastMessage(req.player.username, xss(msg, {
+ whiteList: {}
+ }));
+ chatHistory.push(message);
+ chatHistory.slice(-10);
+ }
- socket.emit('alert', {
- type: 'success',
- text: `You bought the ${shopItem.name}`
- });
+ if(message) {
+ io.emit('chat', renderChatMessage(message));
+ res.sendStatus(204);
+ }
+});
- socket.emit('updatePlayer', player);
- }
+app.get('/player', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const inventory = await getEquippedItems(req.player.id);
- });
+ res.send(renderPlayerBar(req.player, inventory) + renderProfilePage(req.player));
+});
- _.each(EventList, event => {
- logger.log(`Bound event listener: ${event.eventName}`);
- socket.on(event.eventName, event.handler.bind(null, {
- socket,
- io,
- player,
- cache
- }));
- });
+app.post('/player/stat/:stat', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const stat = req.params.stat;
+ if(!['strength', 'constitution', 'dexterity', 'intelligence'].includes(stat)) {
+ res.send(Alert.ErrorAlert(`Sorry, that's not a valid stat to increase`));
+ return;
+ }
- socket.on('skills', async () => {
- const skills = await getPlayerSkills(player.id);
- socket.emit('skills', {skills});
- });
+ if(req.player.stat_points <= 0) {
+ res.send(Alert.ErrorAlert(`Sorry, you don't have enough stat points`));
+ return;
+ }
- socket.on('inventory', async () => {
- const inventory = await getInventory(player.id);
- socket.emit('inventory', {
- inventory
- });
- });
+ req.player.stat_points -= 1;
+ req.player[stat]++;
- socket.on('fight', async (data: {action: 'attack' | 'cast' | 'flee', target: 'head' | 'body' | 'arms' | 'legs'}) => {
- const monster = await loadMonsterWithFaction(player.id);
- const playerSkills = await getPlayerSkillsAsObject(player.id);
- const roundData: FightRound = {
- monster,
- player,
- winner: 'in-progress',
- roundDetails: [],
- rewards: {
- exp: 0,
- gold: 0,
- levelIncrease: false
- }
- };
- const equippedItems = await getEquippedItems(player.id);
-
- // we only use this if the player successfully defeated the monster
- // they were fighting, then we load the other monsters in this area
- // so they can "fight again"
- let potentialMonsters: MonsterForFight[] = [];
-
- /*
- * cumulative chance of head/arms/body/legs
- * 0 -> 0.2 = head
- * 0.21 -> 0.4 = arms
- *
- * we use the factor to decide how many decimal places
- * we care about
- */
- const factor = 100;
- const monsterTarget = [0.2, 0.4, 0.9, 1];
- const targets: ArmourEquipmentSlot[] = ['HEAD', 'CHEST', 'ARMS', 'LEGS'];
- // calc weighted
- const rand = Math.ceil(Math.random() * factor);
- let target: ArmourEquipmentSlot = 'CHEST';
- monsterTarget.forEach((i, idx) => {
- if (rand > (i * factor)) {
- target = targets[idx] as ArmourEquipmentSlot;
- }
- });
+ updatePlayer(req.player);
- const boost = {
- strength: 0,
- constitution: 0,
- dexterity: 0,
- intelligence: 0,
- damage: 0,
- hp: 0,
- };
+ const equippedItems = await getEquippedItems(req.player.id);
+ res.send(renderPlayerBar(req.player, equippedItems) + renderProfilePage(req.player));
+});
- const equipment: Map<EquipmentSlot, EquippedItemDetails> = new Map<EquipmentSlot, EquippedItemDetails>();
- const weapons: EquippedItemDetails[] = [];
- let anyDamageSpells: boolean = false;
- equippedItems.forEach(item => {
- if(item.type === 'ARMOUR') {
- equipment.set(item.equipment_slot, item);
- }
- else if(item.type === 'WEAPON') {
- weapons.push(item);
- }
- else if(item.type === 'SPELL') {
- if(item.affectedSkills.includes('destruction_magic')) {
- anyDamageSpells = true;
- }
- weapons.push(item);
- }
+app.get('/player/skills', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const skills = await getPlayerSkills(req.player.id);
- boost.strength += item.boosts.strength;
- boost.constitution += item.boosts.constitution;
- boost.dexterity += item.boosts.dexterity;
- boost.intelligence += item.boosts.intelligence;
+ res.send(renderSkills(skills));
+});
- if(item.type === 'SPELL' && item.affectedSkills.includes('restoration_magic')) {
- boost.hp += item.boosts.damage;
- }
- else {
- boost.damage += item.boosts.damage;
- }
- });
+app.get('/player/inventory', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const [inventory, items] = await Promise.all([
+ getInventory(req.player.id),
+ getPlayersItems(req.player.id)
+ ]);
- // if you flee'd, then we want to check your dex vs. the monsters
- // but we want to give you the item/weapon boosts you need
- // if not then you're going to get hit.
- if(data.action === 'flee') {
- roundData.roundDetails.push(`You managed to escape from the ${monster.name}!`)
- roundData.winner = 'monster';
- await clearFight(player.id);
+ res.send(renderInventoryPage(inventory, items));
+});
- socket.emit('fight-over', {roundData, monsters: []});
- return;
- }
+app.post('/player/equip/:item_id/:slot', authEndpoint, blockPlayerInFight, async (req: AuthRequest, res: Response) => {
+ const inventoryItem = await getInventoryItem(req.player.id, req.params.item_id);
+ const equippedItems = await getEquippedItems(req.player.id);
+ const requestedSlot = req.params.slot;
+ let desiredSlot: EquipmentSlot = inventoryItem.equipment_slot;
- const primaryStat = data.action === 'attack' ? player.strength : player.constitution;
- const boostStat = data.action === 'attack' ? boost.strength : boost.constitution;
- const attackType = data.action === 'attack' ? 'physical' : 'magical';
-
- const playerDamage = Math.floor(((primaryStat + boostStat) * 1.3) + boost.damage);
- const skillsUsed: Record<SkillID | any, number> = {};
- let hpHealAfterMasteries: number = -1;
- let playerDamageAfterMasteries: number = 0;
- // apply masteries!
- weapons.forEach(item => {
- item.affectedSkills.forEach(id => {
- if(id === 'restoration_magic') {
- if(hpHealAfterMasteries < 0) {
- hpHealAfterMasteries = 0;
- }
- hpHealAfterMasteries += Skills.get(id).effect(playerSkills.get(id));
+ try {
+ // handes the situation where you're trying to equip an item
+ // that can be equipped to any hand
+ if(inventoryItem.equipment_slot === 'ANY_HAND') {
+ if(requestedSlot === 'LEFT_HAND' || requestedSlot === 'RIGHT_HAND') {
+ // get the players current equipment in that slot!
+ if(equippedItems.some(v => {
+ return v.equipment_slot === requestedSlot || v.equipment_slot === 'TWO_HANDED';
+ })) {
+ throw new Error();
}
else {
- playerDamageAfterMasteries += playerDamage * Skills.get(id).effect(playerSkills.get(id));
+ desiredSlot = requestedSlot;
}
+ }
+ }
- if(!skillsUsed[id]) {
- skillsUsed[id] = 0;
- }
- skillsUsed[id]++;
- });
+ if(requestedSlot === 'TWO_HANDED') {
+ if(equippedItems.some(v => {
+ return v.equipment_slot === 'LEFT_HAND' || v.equipment_slot === 'RIGHT_HAND';
+ })) {
+ throw new Error();
+ }
+ }
+
+
+ await equip(req.player.id, inventoryItem, desiredSlot);
+ const socketId = cache.get(`socket:${req.player.id}`).toString();
+ io.to(socketId).emit('updatePlayer', req.player);
+ io.to(socketId).emit('alert', {
+ type: 'success',
+ text: `You equipped your ${inventoryItem.name}`
});
+ }
+ catch(e) {
+ logger.log(e);
+ }
- await updatePlayerSkills(player.id, skillsUsed);
+ const [inventory, items] = await Promise.all([
+ getInventory(req.player.id),
+ getPlayersItems(req.player.id)
+ ]);
- const playerFinalDamage = (attackType === 'magical' && !anyDamageSpells) ? 0 : Math.floor(playerDamage + playerDamageAfterMasteries);
- const playerFinalHeal = Math.floor(boost.hp + hpHealAfterMasteries);
+ res.send(renderInventoryPage(inventory, items, inventoryItem.type) + renderPlayerBar(req.player, inventory));
+});
- roundData.roundDetails.push(`You targeted the monsters ${data.target.toUpperCase()} with ${attackType} damage!`);
- if(data.target === 'arms') {
- if(monster.armsAp > 0) {
- monster.armsAp -= playerFinalDamage;
+app.post('/player/unequip/:item_id', authEndpoint, blockPlayerInFight, async (req: AuthRequest, res: Response) => {
+ const [item, ] = await Promise.all([
+ getInventoryItem(req.player.id, req.params.item_id),
+ unequip(req.player.id, req.params.item_id)
+ ]);
- roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
- if(monster.armsAp < 0) {
+ const [inventory, items] = await Promise.all([
+ getInventory(req.player.id),
+ getPlayersItems(req.player.id)
+ ]);
- roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
- roundData.roundDetails.push(`You dealt ${monster.armsAp * -1} damage to their HP`);
- monster.hp += monster.armsAp;
- monster.armsAp = 0;
- }
- }
- else {
- roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
- monster.hp -= playerFinalDamage;
- }
- }
- else if (data.target === 'head') {
- if(monster.helmAp > 0) {
- monster.helmAp -= playerFinalDamage;
+ res.send(renderInventoryPage(inventory, items, item.type) + renderPlayerBar(req.player, inventory));
+});
- roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
- if(monster.helmAp < 0) {
+app.get('/player/explore', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const fight = await loadMonsterFromFight(req.player.id);
+ const travelPlan = await getTravelPlan(req.player.id);
+ let closestTown = req.player.city_id;
- roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
- roundData.roundDetails.push(`You dealt ${monster.armsAp * 1} damage to their HP`);
- monster.hp += monster.helmAp;
- monster.helmAp = 0;
- }
- }
- else {
- roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
- monster.hp -= playerFinalDamage;
- }
- }
- else if(data.target === 'legs') {
- if(monster.legsAp > 0) {
- monster.legsAp -= playerFinalDamage;
+ if(travelPlan) {
+ closestTown = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
+ }
- roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
- if(monster.legsAp < 0) {
+ const equippedItems = await getEquippedItems(req.player.id);
+ if(fight) {
+ const data: MonsterForFight = {
+ id: fight.id,
+ hp: fight.hp,
+ maxHp: fight.maxHp,
+ name: fight.name,
+ level: fight.level,
+ fight_trigger: fight.fight_trigger
+ };
+ const location = await getMonsterLocation(fight.ref_id);
- roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
- roundData.roundDetails.push(`You dealt ${monster.legsAp * 1} damage to their HP`);
- monster.hp += monster.legsAp;
- monster.legsAp = 0;
- }
- }
- else {
- roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
- monster.hp -= playerFinalDamage;
+
+ res.send(renderPlayerBar(req.player, equippedItems) + renderFightPreRound(data, true, location, closestTown));
+ }
+ else {
+ if(travelPlan) {
+ // traveling!
+ const chanceToSeeMonster = random(0, 100);
+ const things: any[] = [];
+ if(chanceToSeeMonster <= 30) {
+ const monster = await getRandomMonster([closestTown]);
+ things.push(monster);
}
+
+ // STEP_DELAY
+ const nextAction = cache[`step:${req.player.id}`] || 0;
+
+ res.send(renderPlayerBar(req.player, equippedItems) + renderTravel({
+ things,
+ nextAction,
+ closestTown: closestTown,
+ walkingText: '',
+ travelPlan
+ }));
}
else {
- if(monster.chestAp > 0) {
- monster.chestAp -= playerFinalDamage;
+ // display the city info!
+ const [city, locations, paths] = await Promise.all([
+ getCityDetails(req.player.city_id),
+ getAllServices(req.player.city_id),
+ getAllPaths(req.player.city_id)
+ ]);
+
+ res.send(renderPlayerBar(req.player, equippedItems) + await renderMap({city, locations, paths}, closestTown));
+ }
- roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
+ }
+});
- if(monster.chestAp < 0) {
- roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
- roundData.roundDetails.push(`You dealt ${monster.chestAp * 1} damage to their HP`);
- monster.hp += monster.chestAp;
- monster.chestAp = 0;
- }
- }
- else {
- roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
- monster.hp -= playerFinalDamage;
- }
- }
+// used to purchase equipment from a particular shop
+app.put('/location/:location_id/equipment/:item_id', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const item = await getShopEquipment(parseInt(req.params.item_id), parseInt(req.params.location_id));
- if(monster.hp <= 0) {
- roundData.monster.hp = 0;
- roundData.winner = 'player';
+ if(!item) {
+ logger.log(`Invalid item [${req.params.item_id}]`);
+ return res.sendStatus(400);
+ }
- roundData.rewards.exp = monster.exp;
- roundData.rewards.gold = monster.gold;
+ if(req.player.gold < item.cost) {
+ res.send(Alert.ErrorAlert(`Sorry, you need at least ${item.cost.toLocaleString()}G to purchase this.`));
+ return;
+ }
- player.gold += monster.gold;
- player.exp += monster.exp;
+ req.player.gold -= item.cost;
- if(player.exp >= expToLevel(player.level + 1)) {
- player.exp -= expToLevel(player.level + 1)
- player.level++;
- roundData.rewards.levelIncrease = true;
+ await updatePlayer(req.player);
+ await addInventoryItem(req.player.id, item);
- _.each(professionList[player.profession].onLevelUpStatIncrease(player.level), (v, stat) => {
- if(v > 0) {
- roundData.roundDetails.push(`You gained +${v} ${stat}`);
- player[stat] += v;
- }
- });
+ const equippedItems = await getEquippedItems(req.player.id);
- player.hp = maxHp(player.constitution, player.level);
- }
- // get the monster location!
- const rawMonster = await loadMonster(monster.ref_id);
- const monsterList = await getMonsterList(rawMonster.location_id);
- potentialMonsters = monsterList.map(monster => {
- return {
- id: monster.id,
- name: monster.name,
- level: monster.level,
- hp: monster.hp,
- maxHp: monster.maxHp
- }
- });
+ res.send(renderPlayerBar(req.player, equippedItems) + Alert.SuccessAlert(`You purchased ${item.name}`));
+});
- await clearFight(player.id);
- await updatePlayer(player);
- socket.emit('fight-over', {roundData, monsters: potentialMonsters});
- return;
- }
+// used to purchase items from a particular shop
+app.put('/location/:location_id/items/:item_id', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const item: (ShopItem & Item) = await getItemFromShop(parseInt(req.params.item_id), parseInt(req.params.location_id));
- roundData.roundDetails.push(`The ${monster.name} targeted your ${target}!`);
- if(equipment.has(target)) {
- const item = equipment.get(target);
- item.currentAp -= monster.strength;
- if(item.currentAp < 0) {
- roundData.roundDetails.push(`Your ${item.name} amour was destroyed`);
- roundData.roundDetails.push(`The ${monster.name} hit your HP for ${item.currentAp * -1} damage!`);
- player.hp += item.currentAp;
- item.currentAp = 0;
- await deleteInventoryItem(player.id, item.item_id);
- }
- else {
- roundData.roundDetails.push(`Your ${target} took ${monster.strength} damage!`);
- await updateAp(player.id, item.item_id, item.currentAp, item.maxAp);
- }
+ if(!item) {
+ logger.log(`Invalid item [${req.params.item_id}]`);
+ return res.sendStatus(400);
+ }
- }
- else {
- roundData.roundDetails.push(`The ${monster.name} hit you for ${monster.strength} damage`);
- player.hp -= monster.strength;
- }
+ if(req.player.gold < item.price_per_unit) {
+ res.send(Alert.ErrorAlert(`Sorry, you need at least ${item.price_per_unit.toLocaleString()}G to purchase this.`));
+ return;
+ }
- if(playerFinalHeal > 0) {
- player.hp += playerFinalHeal;
- if(player.hp > maxHp(player.constitution, player.level)) {
- player.hp = maxHp(player.constitution, player.level);
- }
- roundData.roundDetails.push(`You healed for ${playerFinalHeal} HP`);
- }
+ req.player.gold -= item.price_per_unit;
- // update the players inventory for this item!
+ await updatePlayer(req.player);
+ await givePlayerItem(req.player.id, item.id, 1);
- if(player.hp <= 0) {
- player.hp = 0;
- roundData.winner = 'monster';
+ const equippedItems = await getEquippedItems(req.player.id);
- roundData.roundDetails.push(`You were killed by the ${monster.name}`);
+ res.send(renderPlayerBar(req.player, equippedItems) + Alert.SuccessAlert(`You purchased a ${item.name}`));
+});
- await clearFight(player.id);
- await updatePlayer(player);
+// used to display equipment modals in a store, validates that
+// the equipment is actually in this store before displaying
+// the modal
+app.get('/location/:location_id/equipment/:item_id/overview', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const equipment = await getShopEquipment(parseInt(req.params.item_id), parseInt(req.params.location_id));
- socket.emit('fight-over', {roundData, monsters: []});
- return;
- }
+ if(!equipment) {
+ logger.log(`Invalid equipment [${req.params.item_id}]`);
+ return res.sendStatus(400);
+ }
- await updatePlayer(player);
- await saveFightState(player.id, monster);
+ let html = `
+<dialog>
+ <div class="item-modal-overview">
+ <div class="icon">
+ <img src="${equipment.icon ? `/assets/img/icons/equipment/${equipment.icon}` : 'https://via.placeholder.com/64x64'}" title="${equipment.name}" alt="${equipment.name}">
+ </div>
+ <div>
+ ${renderEquipmentDetails(equipment, req.player)}
+ </div>
+ </div>
+ <div class="actions">
+ <button hx-put="/location/${equipment.location_id}/equipment/${equipment.id}" formmethod="dialog" value="cancel" class="green">Buy</button>
+ <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
+ </div>
+</dialog>
+`;
+
+ res.send(html);
+});
- calcAp(equippedItems, socket);
- socket.emit('fight-round', roundData);
- });
+// used to display item modals in a store, validates that
+// the item is actually in this store before displaying
+// the modal
+app.get('/location/:location_id/items/:item_id/overview', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const item: (ShopItem & Item) = await getItemFromShop(parseInt(req.params.item_id), parseInt(req.params.location_id));
- // this is a special event to let the client know it can start
- // requesting data
- socket.emit('ready');
+ if(!item) {
+ logger.log(`Invalid item [${req.params.item_id}]`);
+ return res.sendStatus(400);
+ }
+
+ let html = `
+<dialog>
+ <div class="item-modal-overview">
+ <div class="icon">
+ <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}" alt="${item.name}">
+ </div>
+ <div>
+ <h4>${item.name}</h4>
+ <p>${item.description}</p>
+ </div>
+ </div>
+ <div class="actions">
+ <button hx-put="/location/${item.location_id}/items/${item.id}" formmethod="dialog" value="cancel" class="red">Buy</button>
+ <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
+ </div>
+</dialog>
+`;
+
+ res.send(html);
});
-function authEndpoint(req: Request, res: Response, next: any) {
- const authToken = req.headers['x-authtoken'];
- if(!authToken) {
- logger.log(`Invalid auth token ${authToken}`);
- res.sendStatus(400)
+app.put('/item/:item_id', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const item: PlayerItem = await getItemFromPlayer(req.player.id, parseInt(req.params.item_id));
+
+ if(!item) {
+ console.log(`Can't find item [${req.params.item_id}]`);
+ return;
}
- else {
- next()
+
+ if(item.amount < 1) {
+ res.send(Alert.ErrorAlert(`You dont have enough ${item.name}`));
+ return;
}
-}
-app.get('/city/:id', async (req: Request, res: Response) => {
- const id = parseInt(req.params.id);
- if(!id || isNaN(id)) {
+ item.amount -= 1;
+
+ switch(item.effect_name) {
+ case 'heal_small':
+ const hpGain = HealthPotionSmall.effect(req.player);
+
+ req.player.hp += hpGain;
+
+ if(req.player.hp > maxHp(req.player.constitution, req.player.level)) {
+ req.player.hp = maxHp(req.player.constitution, req.player.level);
+ }
+ break;
+ }
+
+ await updateItemCount(req.player.id, item.item_id, -1);
+ await updatePlayer(req.player);
+
+ const inventory = await getInventory(req.player.id);
+ const equippedItems = inventory.filter(i => i.is_equipped);
+ const items = await getPlayersItems(req.player.id);
+
+ res.send(
+ [
+ renderPlayerBar(req.player, equippedItems),
+ renderInventoryPage(inventory, items, 'ITEMS'),
+ Alert.SuccessAlert(`You used the ${item.name}`)
+ ].join("")
+ );
+
+});
+
+app.get('/modal/items/:item_id', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const item: PlayerItem = await getItemFromPlayer(req.player.id, parseInt(req.params.item_id));
+
+ if(!item) {
+ logger.log(`Invalid item [${req.params.item_id}]`);
return res.sendStatus(400);
}
- const [city, locations, paths] = await Promise.all([
- getCityDetails(id),
- getAllServices(id),
- getAllPaths(id)
+
+ let html = `
+<dialog>
+ <div class="item-modal-overview">
+ <div class="icon">
+ <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}" alt="${item.name}">
+ </div>
+ <div>
+ <h4>${item.name}</h4>
+ <p>${item.description}</p>
+ </div>
+ </div>
+ <div class="actions">
+ <button hx-put="/item/${item.item_id}" formmethod="dialog" value="cancel" hx-target="#inventory" class="red">Use</button>
+ <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
+ </div>
+</dialog>
+`;
+
+ res.send(html);
+});
+
+app.get('/city/stores/city:stores/:location_id', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const location = await getService(parseInt(req.params.location_id));
+
+ if(!location || location.city_id !== req.player.city_id) {
+ logger.log(`Invalid location: [${req.params.location_id}]`);
+ res.sendStatus(400);
+ }
+ const [shopEquipment, shopItems] = await Promise.all([
+ listShopItems({location_id: location.id}),
+ getShopItems(location.id),
]);
- res.json({city, locations, paths});
+ const html = await renderStore(shopEquipment, shopItems, req.player, location);
+
+ res.send(html);
});
-app.get('/fight', authEndpoint, async (req: Request, res: Response) => {
- const authToken = req.headers['x-authtoken'].toString();
- const player: Player = await loadPlayer(authToken)
+app.get('/city/explore/city:explore/:location_id', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const location = await getService(parseInt(req.params.location_id));
+ if(!location || location.city_id !== req.player.city_id) {
- if(!player) {
- logger.log(`Couldnt find player with id ${authToken}`);
- return res.sendStatus(400);
+ logger.log(`Invalid location: [${req.params.location_id}]`);
+ res.sendStatus(400);
}
- const fight = await loadMonsterFromFight(player.id);
+ const monsters: Monster[] = await getMonsterList(location.id);
+ res.send(renderOnlyMonsterSelector(monsters, 0, location));
+});
- res.json(fight || null);
+app.post('/travel', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const destination_id = parseInt(req.body.destination_id);
+ if(!destination_id || isNaN(destination_id)) {
+ logger.log(`Invalid destination_id [${req.body.destination_id}]`);
+ return res.sendStatus(400);
+ }
+
+ const travelPlan = travel(req.player, req.body.destination_id);
+
+ res.json(travelPlan);
});
-app.post('/fight', authEndpoint, async (req: Request, res: Response) => {
- const authToken = req.headers['x-authtoken'].toString();
- const player: Player = await loadPlayer(authToken)
+app.post('/fight/turn', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const fightBlockKey = `fightturn:${req.player.id}`;
- if(!player) {
- logger.log(`Couldnt find player with id ${authToken}`);
+ if(cache[fightBlockKey] && cache[fightBlockKey] > Date.now()) {
+ res.status(429).send(Alert.ErrorAlert('Hmm, you are fight too quickly'));
+ return;
+
+ }
+
+ cache[fightBlockKey] = Date.now() + CONSTANT.FIGHT_ATTACK_DELAY;
+ const monster = await loadMonsterWithFaction(req.player.id);
+
+ if(!monster) {
+ res.send(Alert.ErrorAlert('Not in a fight'));
+ return;
+ }
+
+ const fightData = await fightRound(req.player, monster, {
+ action: req.body.action,
+ target: req.body.fightTarget
+ });
+
+
+ let html = renderFight(
+ monster,
+ renderRoundDetails(fightData.roundData),
+ fightData.roundData.winner === 'in-progress',
+ cache[fightBlockKey]
+ );
+
+ if(fightData.roundData.winner !== 'in-progress') {
+ delete cache[fightBlockKey];
+ }
+
+ if(fightData.monsters.length && monster.fight_trigger === 'explore') {
+ html += renderMonsterSelector(fightData.monsters, monster.ref_id);
+ }
+
+ let travelSection = '';
+ if(monster.fight_trigger === 'travel' && fightData.roundData.winner === 'player') {
+ // you're travellinga dn you won.. display the keep walking!
+ const travelPlan = await getTravelPlan(req.player.id);
+ const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
+ travelSection = travelButton(0);
+ }
+
+ const equippedItems = await getEquippedItems(req.player.id);
+ const playerBar = renderPlayerBar(fightData.player, equippedItems);
+
+ res.send(html + travelSection + playerBar);
+});
+
+app.post('/fight', fightRateLimiter, authEndpoint, async (req: AuthRequest, res: Response) => {
+ if(req.player.hp <= 0) {
+ logger.log(`Player didn\'t have enough hp`);
return res.sendStatus(400);
}
const monsterId: number = req.body.monsterId;
+ const fightTrigger: FightTrigger = req.body.fightTrigger ?? 'travel';
if(!monsterId) {
logger.log(`Missing monster Id ${monsterId}`);
return res.sendStatus(400);
}
+ if(!fightTrigger || !['travel', 'explore'].includes(fightTrigger)) {
+ logger.log(`Invalid fight trigger [${fightTrigger}]`);
+ return res.sendStatus(400);
+ }
+
const monster = await loadMonster(monsterId);
if(!monster) {
return res.sendStatus(400);
}
- const fight = await createFight(player.id, monster);
+ const fight = await createFight(req.player.id, monster, fightTrigger);
+ const location = await getService(monster.location_id);
+
const data: MonsterForFight = {
id: fight.id,
hp: fight.hp,
maxHp: fight.maxHp,
name: fight.name,
- level: fight.level
+ level: fight.level,
+ fight_trigger: fight.fight_trigger
};
- res.json(data);
+
+ res.send(renderFightPreRound(data, true, location, location.city_id));
});
-app.post('/signup', async (req: Request, res: Response) => {
+app.post('/travel/step', authEndpoint, async (req: AuthRequest, res: Response) => {
+ const stepTimerKey = `step:${req.player.id}`;
+
+ const travelPlan = await getTravelPlan(req.player.id);
+ if(!travelPlan) {
+ res.send(Alert.ErrorAlert('You don\'t have a travel plan'));
+ return;
+ }
+
+ if(cache[stepTimerKey]) {
+ if(cache[stepTimerKey] > Date.now()) {
+ res.send(Alert.ErrorAlert('Hmm.. travelling too quickly'));
+ return;
+ }
+ }
+
+ travelPlan.current_position++;
+
+ if(travelPlan.current_position >= travelPlan.total_distance) {
+ const travel = await completeTravel(req.player.id);
+
+ req.player.city_id = travel.destination_id;
+ await movePlayer(travel.destination_id, req.player.id);
+
+ const [city, locations, paths] = await Promise.all([
+ getCityDetails(travel.destination_id),
+ getAllServices(travel.destination_id),
+ getAllPaths(travel.destination_id)
+ ]);
+
+ delete cache[stepTimerKey];
+ res.send(await renderMap({city, locations, paths}, req.player.city_id));
+ }
+ else {
+ const walkingText: string[] = [
+ 'You take a step forward',
+ 'You keep moving'
+ ];
+ // update existing plan..
+ // decide if they will run into anything
+ const travelPlan = await stepForward(req.player.id);
+
+ const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
+
+ const chanceToSeeMonster = random(0, 100);
+ const things: any[] = [];
+ if(chanceToSeeMonster <= 30) {
+ const monster = await getRandomMonster([closest]);
+ things.push(monster);
+ }
+
+ // STEP_DELAY
+ const nextAction = Date.now() + CONSTANT.STEP_DELAY;
+
+ cache[stepTimerKey] = nextAction;
+
+ res.send(renderTravel({
+ things,
+ nextAction,
+ closestTown: closest,
+ walkingText: sample(walkingText),
+ travelPlan
+ }));
+
+ }
+});
+
+app.post('/travel/return-to-source', authEndpoint, async (req: AuthRequest, res: Response) => {
+ // puts the player back in their starting town
+ // doesn't matter if they don't have one
+ // redirect them!
+ await clearTravelPlan(req.player.id);
+ const equippedItems = await getEquippedItems(req.player.id);
+
+ const fight = await loadMonsterFromFight(req.player.id);
+ if(fight) {
+ // go to the fight screen
+ const data: MonsterForFight = {
+ id: fight.id,
+ hp: fight.hp,
+ maxHp: fight.maxHp,
+ name: fight.name,
+ level: fight.level,
+ fight_trigger: fight.fight_trigger
+ };
+ const location = await getMonsterLocation(fight.ref_id);
+
+ res.send(renderPlayerBar(req.player, equippedItems) + renderFightPreRound(data, true, location, req.player.city_id));
+ }
+ else {
+ const [city, locations, paths] = await Promise.all([
+ getCityDetails(req.player.city_id),
+ getAllServices(req.player.city_id),
+ getAllPaths(req.player.city_id)
+ ]);
+
+ res.send(renderPlayerBar(req.player, equippedItems) + await renderMap({city, locations, paths}, req.player.city_id));
+
+ }
+
+});
+
+app.post('/travel/:destination_id', authEndpoint, async (req: AuthRequest, res: Response) => {
+if(req.player.hp <= 0) {
+ logger.log(`Player didn\'t have enough hp`);
+ res.send(Alert.ErrorAlert('Sorry, you need some HP to start travelling.'));
+ return;
+ }
+
+ const destination = await getCityDetails(parseInt(req.params.destination_id));
+
+ if(!destination) {
+ res.send(Alert.ErrorAlert(`Thats not a valid desination`));
+ return;
+ }
+
+ const travelPlan = await travel(req.player, destination.id);
+
+ res.send(renderTravel({
+ things: [],
+ nextAction: 0,
+ walkingText: '',
+ closestTown: req.player.city_id,
+ travelPlan
+ }));
+});
+
+app.get('/settings', authEndpoint, async (req: AuthRequest, res: Response) => {
+ let warning = '';
+ let html = '';
+ if(req.player.account_type === 'session') {
+ warning += `<div class="alert error">If you log out without signing up first, this account is lost forever.</div>`;
+ }
+
+ html += '<a href="#" hx-post="/logout">Logout</a>';
+ res.send(warning + html);
+});
+
+app.post('/logout', authEndpoint, async (req: AuthRequest, res: Response) => {
+ // ref to get the socket id for a particular player
+ cache.delete(`socket:${req.player.id}`);
+ // ref to get the player object
+ cache.delete(`token:${req.player.id}`);
+
+ logger.log(`${req.player.username} logged out`);
+
+ res.send('logout');
+});
+
+
+app.post('/auth', async (req: Request, res: Response) => {
+ if(req.body.authType === 'login') {
+ loginHandler(req, res);
+ }
+ else if(req.body.authType === 'signup') {
+ signupHandler(req, res);
+ }
+ else {
+ logger.log(`Invalid auth type [${req.body.authType}]`);
+ res.sendStatus(400);
+ }
+});
+
+
+async function signupHandler(req: Request, res: Response) {
const {username, password} = req.body;
const authToken = req.headers['x-authtoken'];
if(!username || !password || !authToken) {
- res.sendStatus(400);
+ res.send(Alert.ErrorAlert('Invalid username/password'));
return;
}
-
try {
const player = await loadPlayer(authToken.toString());
- console.log(`Attempted claim for ${player.username}`);
+ logger.log(`Attempted claim for ${player.username}`);
await signup(authToken.toString(), username, password);
username: username
});
- console.log(`Player claimed ${player.username} => ${username}`);
+ logger.log(`${username} claimed ${player.username}`);
io.emit('chat', broadcastMessage('server', `${player.username} is now ${username}`));
- player.username = username;
- player.account_type = 'auth';
-
- res.json({
- player
- });
+ res.setHeader('hx-refresh', 'true');
+ res.sendStatus(200);
}
catch(e) {
- console.log(e);
+ logger.log(e);
if(e?.constraint === 'players_username_unique') {
- res.send({
- error: 'That username is already taken.'
- }).status(500);
+ res.send(Alert.ErrorAlert('That username is already taken'));
}
else {
- res.send({error: 'Please try again'}).status(500);
+ res.send(Alert.ErrorAlert('Please try again'));
}
}
-});
+}
-app.post('/login', async (req: Request, res: Response) => {
+async function loginHandler (req: Request, res: Response) {
const {username, password} = req.body;
+ if(!username || !username.length) {
+ res.send(Alert.ErrorAlert('Invalid username'));
+ return;
+ }
+ if(!password || !password.length) {
+ res.send(Alert.ErrorAlert('Invalid password'));
+ return;
+ }
+
try {
const player = await login(username, password);
- res.json({player});
+ io.sockets.sockets.forEach(socket => {
+ if(socket.handshake.headers['x-authtoken'] === req.headers['x-authtoken']) {
+ bootstrapSocket(socket, player);
+ }
+ });
+ res.sendStatus(204);
}
catch(e) {
console.log(e);
- res.json({error: 'That user doesnt exist'}).status(500);
+ res.send(Alert.ErrorAlert('That user doesn\'t exist'));
}
-});
+}
server.listen(process.env.API_PORT, () => {
logger.log(`Listening on port ${process.env.API_PORT}`);