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 bodyParser from 'body-parser';
9 import http from 'http';
10 import { Server, Socket } from 'socket.io';
11 import { logger } from './lib/logger';
12 import { loadPlayer, createPlayer, updatePlayer, movePlayer } from './player';
13 import { random, sample } from 'lodash';
14 import {broadcastMessage, Message} from '../shared/message';
15 import {expToLevel, maxHp, Player} from '../shared/player';
16 import {clearFight, createFight, getMonsterList, getMonsterLocation, getRandomMonster, loadMonster, loadMonsterFromFight, loadMonsterWithFaction, saveFightState} from './monster';
17 import {FightRound} from '../shared/fight';
18 import {addInventoryItem, deleteInventoryItem, getEquippedItems, getInventory, getInventoryItem, updateAp} from './inventory';
19 import { getItemFromPlayer, getItemFromShop, getPlayersItems, getShopItems, givePlayerItem, updateItemCount } from './items';
20 import {Fight, FightTrigger, Monster, MonsterForFight, MonsterWithFaction} from '../shared/monsters';
21 import {getShopEquipment, listShopItems } from './shopEquipment';
22 import {EquippedItemDetails} from '../shared/equipped';
23 import {ArmourEquipmentSlot, EquipmentSlot} from '../shared/inventory';
24 import { clearTravelPlan, completeTravel, getAllPaths, getAllServices, getCityDetails, getService, getTravelPlan, stepForward, travel } from './map';
25 import { signup, login, authEndpoint, AuthRequest } from './auth';
26 import {db} from './lib/db';
27 import { getPlayerSkills, getPlayerSkillsAsObject, updatePlayerSkills } from './skills';
28 import {SkillID, Skills} from '../shared/skills';
30 import { router as healerRouter } from './locations/healer';
31 import { router as professionRouter } from './locations/recruiter';
33 import * as Alert from './views/alert';
34 import { renderPlayerBar } from './views/player-bar'
35 import { renderEquipmentDetails, renderStore } from './views/stores';
36 import { renderMap } from './views/map';
37 import { renderProfilePage } from './views/profile';
38 import { renderSkills } from './views/skills';
39 import { renderInventoryPage } from './views/inventory';
40 import { renderMonsterSelector, renderOnlyMonsterSelector } from './views/monster-selector';
41 import { renderFight, renderFightPreRound, renderRoundDetails } from './views/fight';
42 import { renderTravel, travelButton } from './views/travel';
43 import { renderChatMessage } from './views/chat';
46 import { createMonsters } from '../../seeds/monsters';
47 import { createAllCitiesAndLocations } from '../../seeds/cities';
48 import { createShopItems, createShopEquipment } from '../../seeds/shop_items';
49 import { Item, PlayerItem, ShopItem } from 'shared/items';
50 import { equip, unequip } from './equipment';
51 import { HealthPotionSmall } from '../shared/items/health_potion';
57 const app = express();
58 const server = http.createServer(app);
60 app.use(express.static(join(__dirname, '..', '..', 'public')));
61 app.use(bodyParser.urlencoded({ extended: true }));
62 app.use(express.json());
64 const io = new Server(server);
66 const cache = new Map<string, any>();
67 const chatHistory: Message[] = [];
69 app.use((req, res, next) => {
70 console.log(req.method, req.url);
74 async function bootstrapSocket(socket: Socket, player: Player) {
75 // ref to get the socket id for a particular player
76 cache.set(`socket:${player.id}`, socket.id);
77 // ref to get the player object
78 cache.set(`token:${player.id}`, player);
80 socket.emit('authToken', player.id);
82 socket.emit('chat', renderChatMessage(broadcastMessage('server', `${player.username} just logged in`)));
85 io.on('connection', async socket => {
86 logger.log(`socket ${socket.id} connected, authToken: ${socket.handshake.headers['x-authtoken']}`);
88 let authToken = socket.handshake.headers['x-authtoken'].toString() === 'null' ? null : socket.handshake.headers['x-authtoken'].toString();
92 logger.log(`Attempting to load player with id ${authToken}`);
93 player = await loadPlayer(authToken);
96 logger.log(`Creating player`);
97 player = await createPlayer();
98 authToken = player.id;
99 socket.handshake.headers['x-authtoken'] = authToken;
102 logger.log(`Socket [${socket.id}] auth token: ${player.id}`);
104 bootstrapSocket(socket, player);
106 socket.on('disconnect', () => {
107 console.log(`Player ${player.username} left`);
108 io.emit('status', `${io.sockets.sockets.size} Online (v${version})`);
112 io.emit('status', `${io.sockets.sockets.size} Online (v${version})`);
113 // this is a special event to let the client know it can start
115 socket.emit('ready');
118 async function fightRound(player: Player, monster: MonsterWithFaction, data: {action: 'attack' | 'cast' | 'flee', target: 'head' | 'body' | 'arms' | 'legs'}) {
119 const playerSkills = await getPlayerSkillsAsObject(player.id);
120 const roundData: FightRound = {
123 winner: 'in-progress',
124 fightTrigger: monster.fight_trigger,
132 const equippedItems = await getEquippedItems(player.id);
134 // we only use this if the player successfully defeated the monster
135 // they were fighting, then we load the other monsters in this area
136 // so they can "fight again"
137 let potentialMonsters: MonsterForFight[] = [];
140 * cumulative chance of head/arms/body/legs
144 * we use the factor to decide how many decimal places
148 const monsterTarget = [0.2, 0.4, 0.9, 1];
149 const targets: ArmourEquipmentSlot[] = ['HEAD', 'CHEST', 'ARMS', 'LEGS'];
151 const rand = Math.ceil(Math.random() * factor);
152 let target: ArmourEquipmentSlot = 'CHEST';
153 monsterTarget.forEach((i, idx) => {
154 if (rand > (i * factor)) {
155 target = targets[idx] as ArmourEquipmentSlot;
168 const equipment: Map<EquipmentSlot, EquippedItemDetails> = new Map<EquipmentSlot, EquippedItemDetails>();
169 const weapons: EquippedItemDetails[] = [];
170 let anyDamageSpells: boolean = false;
171 equippedItems.forEach(item => {
172 if(item.type === 'ARMOUR') {
173 equipment.set(item.equipment_slot, item);
175 else if(item.type === 'WEAPON') {
178 else if(item.type === 'SPELL') {
179 if(item.affectedSkills.includes('destruction_magic')) {
180 anyDamageSpells = true;
185 boost.strength += item.boosts.strength;
186 boost.constitution += item.boosts.constitution;
187 boost.dexterity += item.boosts.dexterity;
188 boost.intelligence += item.boosts.intelligence;
190 if(item.type === 'SPELL' && item.affectedSkills.includes('restoration_magic')) {
191 boost.hp += item.boosts.damage;
194 boost.damage += item.boosts.damage;
198 // if you flee'd, then we want to check your dex vs. the monsters
199 // but we want to give you the item/weapon boosts you need
200 // if not then you're going to get hit.
201 if(data.action === 'flee') {
202 roundData.roundDetails.push(`You managed to escape from the ${monster.name}!`)
203 roundData.winner = 'monster';
204 await clearFight(player.id);
206 return { roundData, monsters: [], player };
209 const attackType = data.action === 'attack' ? 'physical' : 'magical';
210 const primaryStat = data.action === 'attack' ? player.strength : player.intelligence;
211 const boostStat = data.action === 'attack' ? boost.strength : boost.intelligence;
213 const playerDamage = Math.floor(((primaryStat + boostStat) * 1.3) + boost.damage);
214 const skillsUsed: Record<SkillID | any, number> = {};
215 let hpHealAfterMasteries: number = -1;
216 let playerDamageAfterMasteries: number = 0;
218 weapons.forEach(item => {
219 item.affectedSkills.forEach(id => {
220 if(id === 'restoration_magic') {
221 if(hpHealAfterMasteries < 0) {
222 hpHealAfterMasteries = 0;
224 hpHealAfterMasteries += Skills.get(id).effect(playerSkills.get(id));
227 playerDamageAfterMasteries += playerDamage * Skills.get(id).effect(playerSkills.get(id));
230 if(!skillsUsed[id]) {
237 await updatePlayerSkills(player.id, skillsUsed);
239 const playerFinalDamage = (data.action === 'cast' && !anyDamageSpells) ? 0 : Math.floor(playerDamage + playerDamageAfterMasteries);
240 const playerFinalHeal = Math.floor(boost.hp + hpHealAfterMasteries);
242 roundData.roundDetails.push(`You targeted the monsters ${data.target.toUpperCase()} with ${attackType} damage!`);
243 let armourKey: string;
244 switch(data.target) {
246 armourKey = 'armsAp';
249 armourKey = 'helmAp';
252 armourKey = 'legsAp';
255 armourKey = 'chestAp';
259 if(monster[armourKey] && monster[armourKey] > 0) {
260 monster[armourKey] -= playerFinalDamage;
262 roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
263 if(monster[armourKey] < 0) {
264 roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
265 roundData.roundDetails.push(`You dealt ${monster[armourKey] * -1} damage to their HP`);
266 monster.hp += monster[armourKey];
267 monster[armourKey] = 0;
271 roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
272 monster.hp -= playerFinalDamage;
275 if(monster.hp <= 0) {
276 roundData.monster.hp = 0;
277 roundData.winner = 'player';
279 roundData.rewards.exp = monster.exp;
280 roundData.rewards.gold = monster.gold;
282 player.gold += monster.gold;
283 player.exp += monster.exp;
285 if(player.exp >= expToLevel(player.level + 1)) {
286 player.exp -= expToLevel(player.level + 1)
288 roundData.rewards.levelIncrease = true;
289 let statPointsGained = 1;
291 if(player.profession !== 'Wanderer') {
292 statPointsGained = 2;
295 player.stat_points += statPointsGained;
297 roundData.roundDetails.push(`You gained ${statPointsGained} stat points!`);
299 player.hp = maxHp(player.constitution, player.level);
301 // get the monster location if it was an EXPLORED fight
302 if(roundData.fightTrigger === 'explore') {
303 const rawMonster = await loadMonster(monster.ref_id);
304 const monsterList = await getMonsterList(rawMonster.location_id);
305 potentialMonsters = monsterList.map(monster => {
309 level: monster.level,
311 maxHp: monster.maxHp,
312 fight_trigger: 'explore'
317 await clearFight(player.id);
318 await updatePlayer(player);
319 return { roundData, monsters: potentialMonsters, player };
322 roundData.roundDetails.push(`The ${monster.name} targeted your ${target}!`);
323 if(equipment.has(target)) {
324 const item = equipment.get(target);
326 const mitigationPercentage = item.boosts.damage_mitigation || 0;
327 const damageAfterMitigation = Math.floor(monster.strength * ((100-mitigationPercentage)/100));
329 item.currentAp -= damageAfterMitigation;
331 if(item.currentAp < 0) {
332 roundData.roundDetails.push(`Your ${item.name} amour was destroyed`);
333 roundData.roundDetails.push(`The ${monster.name} hit your HP for ${item.currentAp * -1} damage!`);
334 player.hp += item.currentAp;
336 await deleteInventoryItem(player.id, item.item_id);
339 roundData.roundDetails.push(`Your ${target} took ${damageAfterMitigation} damage!`);
340 await updateAp(player.id, item.item_id, item.currentAp, item.maxAp);
345 roundData.roundDetails.push(`The ${monster.name} hit you for ${monster.strength} damage`);
346 player.hp -= monster.strength;
349 if(playerFinalHeal > 0) {
350 player.hp += playerFinalHeal;
351 if(player.hp > maxHp(player.constitution, player.level)) {
352 player.hp = maxHp(player.constitution, player.level);
354 roundData.roundDetails.push(`You healed for ${playerFinalHeal} HP`);
357 // update the players inventory for this item!
361 roundData.winner = 'monster';
363 roundData.roundDetails.push(`You were killed by the ${monster.name}`);
365 await clearFight(player.id);
366 await updatePlayer(player);
367 await clearTravelPlan(player.id);
369 return { roundData, monsters: [], player};
372 await updatePlayer(player);
373 await saveFightState(player.id, monster);
375 return { roundData, monsters: [], player};
378 app.use(healerRouter);
379 app.use(professionRouter);
382 app.get('/chat/history', authEndpoint, async (req: AuthRequest, res: Response) => {
383 let html = chatHistory.map(renderChatMessage);
385 res.send(html.join("\n"));
388 app.post('/chat', authEndpoint, async (req: AuthRequest, res: Response) => {
389 const msg = req.body.message.trim();
391 if(!msg || !msg.length) {
396 let message: Message;
397 if(msg.startsWith('/server lmnop')) {
398 if(msg === '/server lmnop refresh-monsters') {
399 await createMonsters();
400 message = broadcastMessage('server', 'Monster refresh!');
402 else if(msg === '/server lmnop refresh-cities') {
403 await createAllCitiesAndLocations();
404 message = broadcastMessage('server', 'Cities, Locations, and Paths refreshed!');
406 else if(msg === '/server lmnop refresh-shops') {
407 await createShopItems();
408 await createShopEquipment();
409 message = broadcastMessage('server', 'Refresh shop items');
412 const str = msg.split('/server lmnop ')[1];
414 message = broadcastMessage('server', str);
419 message = broadcastMessage(req.player.username, xss(msg, {
422 chatHistory.push(message);
423 chatHistory.slice(-10);
427 io.emit('chat', renderChatMessage(message));
432 app.get('/player', authEndpoint, async (req: AuthRequest, res: Response) => {
433 const inventory = await getEquippedItems(req.player.id);
435 res.send(renderPlayerBar(req.player, inventory) + renderProfilePage(req.player));
438 app.post('/player/stat/:stat', authEndpoint, async (req: AuthRequest, res: Response) => {
439 const stat = req.params.stat;
440 if(!['strength', 'constitution', 'dexterity', 'intelligence'].includes(stat)) {
441 res.send(Alert.ErrorAlert(`Sorry, that's not a valid stat to increase`));
445 if(req.player.stat_points <= 0) {
446 res.send(Alert.ErrorAlert(`Sorry, you don't have enough stat points`));
450 req.player.stat_points -= 1;
453 updatePlayer(req.player);
455 const equippedItems = await getEquippedItems(req.player.id);
456 res.send(renderPlayerBar(req.player, equippedItems) + renderProfilePage(req.player));
459 app.get('/player/skills', authEndpoint, async (req: AuthRequest, res: Response) => {
460 const skills = await getPlayerSkills(req.player.id);
462 res.send(renderSkills(skills));
465 app.get('/player/inventory', authEndpoint, async (req: AuthRequest, res: Response) => {
466 const [inventory, items] = await Promise.all([
467 getInventory(req.player.id),
468 getPlayersItems(req.player.id)
471 res.send(renderInventoryPage(inventory, items));
474 app.post('/player/equip/:item_id/:slot', authEndpoint, blockPlayerInFight, async (req: AuthRequest, res: Response) => {
475 const inventoryItem = await getInventoryItem(req.player.id, req.params.item_id);
476 const equippedItems = await getEquippedItems(req.player.id);
477 const requestedSlot = req.params.slot;
478 let desiredSlot: EquipmentSlot = inventoryItem.equipment_slot;
481 // handes the situation where you're trying to equip an item
482 // that can be equipped to any hand
483 if(inventoryItem.equipment_slot === 'ANY_HAND') {
484 if(requestedSlot === 'LEFT_HAND' || requestedSlot === 'RIGHT_HAND') {
485 // get the players current equipment in that slot!
486 if(equippedItems.some(v => {
487 return v.equipment_slot === requestedSlot || v.equipment_slot === 'TWO_HANDED';
492 desiredSlot = requestedSlot;
497 if(requestedSlot === 'TWO_HANDED') {
498 if(equippedItems.some(v => {
499 return v.equipment_slot === 'LEFT_HAND' || v.equipment_slot === 'RIGHT_HAND';
506 await equip(req.player.id, inventoryItem, desiredSlot);
507 const socketId = cache.get(`socket:${req.player.id}`).toString();
508 io.to(socketId).emit('updatePlayer', req.player);
509 io.to(socketId).emit('alert', {
511 text: `You equipped your ${inventoryItem.name}`
518 const [inventory, items] = await Promise.all([
519 getInventory(req.player.id),
520 getPlayersItems(req.player.id)
523 res.send(renderInventoryPage(inventory, items, inventoryItem.type) + renderPlayerBar(req.player, inventory));
526 app.post('/player/unequip/:item_id', authEndpoint, blockPlayerInFight, async (req: AuthRequest, res: Response) => {
527 const [item, ] = await Promise.all([
528 getInventoryItem(req.player.id, req.params.item_id),
529 unequip(req.player.id, req.params.item_id)
532 const [inventory, items] = await Promise.all([
533 getInventory(req.player.id),
534 getPlayersItems(req.player.id)
537 res.send(renderInventoryPage(inventory, items, item.type) + renderPlayerBar(req.player, inventory));
540 async function blockPlayerInFight(req: AuthRequest, res: Response, next) {
541 const fight = await loadMonsterFromFight(req.player.id);
547 res.send(Alert.ErrorAlert(`You are currently in a fight with a ${fight.name}`));
550 app.get('/player/explore', authEndpoint, async (req: AuthRequest, res: Response) => {
551 const fight = await loadMonsterFromFight(req.player.id);
552 const travelPlan = await getTravelPlan(req.player.id);
553 let closestTown = req.player.city_id;
556 closestTown = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
559 const equippedItems = await getEquippedItems(req.player.id);
561 const data: MonsterForFight = {
567 fight_trigger: fight.fight_trigger
569 const location = await getMonsterLocation(fight.ref_id);
572 res.send(renderPlayerBar(req.player, equippedItems) + renderFightPreRound(data, true, location, closestTown));
577 const chanceToSeeMonster = random(0, 100);
578 const things: any[] = [];
579 if(chanceToSeeMonster <= 30) {
580 const monster = await getRandomMonster([closestTown]);
581 things.push(monster);
585 const nextAction = cache[`step:${req.player.id}`] || 0;
587 res.send(renderPlayerBar(req.player, equippedItems) + renderTravel({
590 closestTown: closestTown,
596 // display the city info!
597 const [city, locations, paths] = await Promise.all([
598 getCityDetails(req.player.city_id),
599 getAllServices(req.player.city_id),
600 getAllPaths(req.player.city_id)
603 res.send(renderPlayerBar(req.player, equippedItems) + await renderMap({city, locations, paths}, closestTown));
609 // used to purchase equipment from a particular shop
610 app.put('/location/:location_id/equipment/:item_id', authEndpoint, async (req: AuthRequest, res: Response) => {
611 const item = await getShopEquipment(parseInt(req.params.item_id), parseInt(req.params.location_id));
614 logger.log(`Invalid item [${req.params.item_id}]`);
615 return res.sendStatus(400);
618 if(req.player.gold < item.cost) {
619 res.send(Alert.ErrorAlert(`Sorry, you need at least ${item.cost.toLocaleString()}G to purchase this.`));
623 req.player.gold -= item.cost;
625 await updatePlayer(req.player);
626 await addInventoryItem(req.player.id, item);
628 const equippedItems = await getEquippedItems(req.player.id);
630 res.send(renderPlayerBar(req.player, equippedItems) + Alert.SuccessAlert(`You purchased ${item.name}`));
633 // used to purchase items from a particular shop
634 app.put('/location/:location_id/items/:item_id', authEndpoint, async (req: AuthRequest, res: Response) => {
635 const item: (ShopItem & Item) = await getItemFromShop(parseInt(req.params.item_id), parseInt(req.params.location_id));
638 logger.log(`Invalid item [${req.params.item_id}]`);
639 return res.sendStatus(400);
642 if(req.player.gold < item.price_per_unit) {
643 res.send(Alert.ErrorAlert(`Sorry, you need at least ${item.price_per_unit.toLocaleString()}G to purchase this.`));
647 req.player.gold -= item.price_per_unit;
649 await updatePlayer(req.player);
650 await givePlayerItem(req.player.id, item.id, 1);
652 const equippedItems = await getEquippedItems(req.player.id);
654 res.send(renderPlayerBar(req.player, equippedItems) + Alert.SuccessAlert(`You purchased a ${item.name}`));
657 // used to display equipment modals in a store, validates that
658 // the equipment is actually in this store before displaying
660 app.get('/location/:location_id/equipment/:item_id/overview', authEndpoint, async (req: AuthRequest, res: Response) => {
661 const equipment = await getShopEquipment(parseInt(req.params.item_id), parseInt(req.params.location_id));
664 logger.log(`Invalid equipment [${req.params.item_id}]`);
665 return res.sendStatus(400);
670 <div class="item-modal-overview">
672 <img src="${equipment.icon ? `/assets/img/icons/equipment/${equipment.icon}` : 'https://via.placeholder.com/64x64'}" title="${equipment.name}" alt="${equipment.name}">
675 ${renderEquipmentDetails(equipment, req.player)}
678 <div class="actions">
679 <button hx-put="/location/${equipment.location_id}/equipment/${equipment.id}" formmethod="dialog" value="cancel" class="green">Buy</button>
680 <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
688 // used to display item modals in a store, validates that
689 // the item is actually in this store before displaying
691 app.get('/location/:location_id/items/:item_id/overview', authEndpoint, async (req: AuthRequest, res: Response) => {
692 const item: (ShopItem & Item) = await getItemFromShop(parseInt(req.params.item_id), parseInt(req.params.location_id));
695 logger.log(`Invalid item [${req.params.item_id}]`);
696 return res.sendStatus(400);
701 <div class="item-modal-overview">
703 <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}" alt="${item.name}">
706 <h4>${item.name}</h4>
707 <p>${item.description}</p>
710 <div class="actions">
711 <button hx-put="/location/${item.location_id}/items/${item.id}" formmethod="dialog" value="cancel" class="red">Buy</button>
712 <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
720 app.put('/item/:item_id', authEndpoint, async (req: AuthRequest, res: Response) => {
721 const item: PlayerItem = await getItemFromPlayer(req.player.id, parseInt(req.params.item_id));
724 console.log(`Can't find item [${req.params.item_id}]`);
728 if(item.amount < 1) {
729 res.send(Alert.ErrorAlert(`You dont have enough ${item.name}`));
735 switch(item.effect_name) {
737 const hpGain = HealthPotionSmall.effect(req.player);
739 req.player.hp += hpGain;
741 if(req.player.hp > maxHp(req.player.constitution, req.player.level)) {
742 req.player.hp = maxHp(req.player.constitution, req.player.level);
747 await updateItemCount(req.player.id, item.item_id, -1);
748 await updatePlayer(req.player);
750 const inventory = await getInventory(req.player.id);
751 const equippedItems = inventory.filter(i => i.is_equipped);
752 const items = await getPlayersItems(req.player.id);
756 renderPlayerBar(req.player, equippedItems),
757 renderInventoryPage(inventory, items, 'ITEMS'),
758 Alert.SuccessAlert(`You used the ${item.name}`)
764 app.get('/modal/items/:item_id', authEndpoint, async (req: AuthRequest, res: Response) => {
765 const item: PlayerItem = await getItemFromPlayer(req.player.id, parseInt(req.params.item_id));
768 logger.log(`Invalid item [${req.params.item_id}]`);
769 return res.sendStatus(400);
774 <div class="item-modal-overview">
776 <img src="/assets/img/icons/items/${item.icon_name}" title="${item.name}" alt="${item.name}">
779 <h4>${item.name}</h4>
780 <p>${item.description}</p>
783 <div class="actions">
784 <button hx-put="/item/${item.item_id}" formmethod="dialog" value="cancel" hx-target="#inventory" class="red">Use</button>
785 <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
793 app.get('/city/stores/city:stores/:location_id', authEndpoint, async (req: AuthRequest, res: Response) => {
794 const location = await getService(parseInt(req.params.location_id));
796 if(!location || location.city_id !== req.player.city_id) {
797 logger.log(`Invalid location: [${req.params.location_id}]`);
800 const [shopEquipment, shopItems] = await Promise.all([
801 listShopItems({location_id: location.id}),
802 getShopItems(location.id),
805 const html = await renderStore(shopEquipment, shopItems, req.player, location);
810 app.get('/city/explore/city:explore/:location_id', authEndpoint, async (req: AuthRequest, res: Response) => {
811 const location = await getService(parseInt(req.params.location_id));
812 if(!location || location.city_id !== req.player.city_id) {
814 logger.log(`Invalid location: [${req.params.location_id}]`);
818 const monsters: Monster[] = await getMonsterList(location.id);
819 res.send(renderOnlyMonsterSelector(monsters, 0, location));
822 app.post('/travel', authEndpoint, async (req: AuthRequest, res: Response) => {
823 const destination_id = parseInt(req.body.destination_id);
825 if(!destination_id || isNaN(destination_id)) {
826 logger.log(`Invalid destination_id [${req.body.destination_id}]`);
827 return res.sendStatus(400);
830 const travelPlan = travel(req.player, req.body.destination_id);
832 res.json(travelPlan);
835 app.post('/fight/turn', authEndpoint, async (req: AuthRequest, res: Response) => {
836 const monster = await loadMonsterWithFaction(req.player.id);
839 res.send(Alert.ErrorAlert('Not in a fight'));
843 const fightData = await fightRound(req.player, monster, {
844 action: req.body.action,
845 target: req.body.fightTarget
848 let html = renderFight(
850 renderRoundDetails(fightData.roundData),
851 fightData.roundData.winner === 'in-progress'
854 if(fightData.monsters.length && monster.fight_trigger === 'explore') {
855 html += renderMonsterSelector(fightData.monsters, monster.ref_id);
858 let travelSection = '';
859 if(monster.fight_trigger === 'travel' && fightData.roundData.winner === 'player') {
860 // you're travellinga dn you won.. display the keep walking!
861 const travelPlan = await getTravelPlan(req.player.id);
862 const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
863 travelSection = travelButton(0);
866 const equippedItems = await getEquippedItems(req.player.id);
867 const playerBar = renderPlayerBar(fightData.player, equippedItems);
869 res.send(html + travelSection + playerBar);
872 app.post('/fight', authEndpoint, async (req: AuthRequest, res: Response) => {
873 if(req.player.hp <= 0) {
874 logger.log(`Player didn\'t have enough hp`);
875 return res.sendStatus(400);
878 const monsterId: number = req.body.monsterId;
879 const fightTrigger: FightTrigger = req.body.fightTrigger ?? 'travel';
882 logger.log(`Missing monster Id ${monsterId}`);
883 return res.sendStatus(400);
886 if(!fightTrigger || !['travel', 'explore'].includes(fightTrigger)) {
887 logger.log(`Invalid fight trigger [${fightTrigger}]`);
888 return res.sendStatus(400);
891 const monster = await loadMonster(monsterId);
894 logger.log(`Couldnt find monster for ${monsterId}`);
895 return res.sendStatus(400);
898 const fight = await createFight(req.player.id, monster, fightTrigger);
899 const location = await getService(monster.location_id);
902 const data: MonsterForFight = {
908 fight_trigger: fight.fight_trigger
911 res.send(renderFightPreRound(data, true, location, location.city_id));
914 app.post('/travel/step', authEndpoint, async (req: AuthRequest, res: Response) => {
915 const stepTimerKey = `step:${req.player.id}`;
917 const travelPlan = await getTravelPlan(req.player.id);
919 res.send(Alert.ErrorAlert('You don\'t have a travel plan'));
923 if(cache[stepTimerKey]) {
924 if(cache[stepTimerKey] > Date.now()) {
925 res.send(Alert.ErrorAlert('Hmm.. travelling too quickly'));
930 travelPlan.current_position++;
932 if(travelPlan.current_position >= travelPlan.total_distance) {
933 const travel = await completeTravel(req.player.id);
935 req.player.city_id = travel.destination_id;
936 await movePlayer(travel.destination_id, req.player.id);
938 const [city, locations, paths] = await Promise.all([
939 getCityDetails(travel.destination_id),
940 getAllServices(travel.destination_id),
941 getAllPaths(travel.destination_id)
944 delete cache[stepTimerKey];
945 res.send(await renderMap({city, locations, paths}, req.player.city_id));
948 const walkingText: string[] = [
949 'You take a step forward',
952 // update existing plan..
953 // decide if they will run into anything
954 const travelPlan = await stepForward(req.player.id);
956 const closest: number = (travelPlan.current_position / travelPlan.total_distance) > 0.5 ? travelPlan.destination_id : travelPlan.source_id;
958 const chanceToSeeMonster = random(0, 100);
959 const things: any[] = [];
960 if(chanceToSeeMonster <= 30) {
961 const monster = await getRandomMonster([closest]);
962 things.push(monster);
966 const nextAction = Date.now() + 3000;
968 cache[stepTimerKey] = nextAction;
970 res.send(renderTravel({
973 closestTown: closest,
974 walkingText: sample(walkingText),
981 app.post('/travel/return-to-source', authEndpoint, async (req: AuthRequest, res: Response) => {
982 // puts the player back in their starting town
983 // doesn't matter if they don't have one
985 await clearTravelPlan(req.player.id);
986 const equippedItems = await getEquippedItems(req.player.id);
988 const fight = await loadMonsterFromFight(req.player.id);
990 // go to the fight screen
991 const data: MonsterForFight = {
997 fight_trigger: fight.fight_trigger
999 const location = await getMonsterLocation(fight.ref_id);
1001 res.send(renderPlayerBar(req.player, equippedItems) + renderFightPreRound(data, true, location, req.player.city_id));
1004 const [city, locations, paths] = await Promise.all([
1005 getCityDetails(req.player.city_id),
1006 getAllServices(req.player.city_id),
1007 getAllPaths(req.player.city_id)
1010 res.send(renderPlayerBar(req.player, equippedItems) + await renderMap({city, locations, paths}, req.player.city_id));
1016 app.post('/travel/:destination_id', authEndpoint, async (req: AuthRequest, res: Response) => {
1017 if(req.player.hp <= 0) {
1018 logger.log(`Player didn\'t have enough hp`);
1019 res.send(Alert.ErrorAlert('Sorry, you need some HP to start travelling.'));
1023 const destination = await getCityDetails(parseInt(req.params.destination_id));
1026 res.send(Alert.ErrorAlert(`Thats not a valid desination`));
1030 const travelPlan = await travel(req.player, destination.id);
1032 res.send(renderTravel({
1036 closestTown: req.player.city_id,
1041 app.get('/settings', authEndpoint, async (req: AuthRequest, res: Response) => {
1044 if(req.player.account_type === 'session') {
1045 warning += `<div class="alert error">If you log out without signing up first, this account is lost forever.</div>`;
1048 html += '<a href="#" hx-post="/logout">Logout</a>';
1049 res.send(warning + html);
1052 app.post('/logout', authEndpoint, async (req: AuthRequest, res: Response) => {
1053 // ref to get the socket id for a particular player
1054 cache.delete(`socket:${req.player.id}`);
1055 // ref to get the player object
1056 cache.delete(`token:${req.player.id}`);
1058 logger.log(`${req.player.username} logged out`);
1064 app.post('/auth', async (req: Request, res: Response) => {
1065 if(req.body.authType === 'login') {
1066 loginHandler(req, res);
1068 else if(req.body.authType === 'signup') {
1069 signupHandler(req, res);
1072 logger.log(`Invalid auth type [${req.body.authType}]`);
1073 res.sendStatus(400);
1078 async function signupHandler(req: Request, res: Response) {
1079 const {username, password} = req.body;
1080 const authToken = req.headers['x-authtoken'];
1082 if(!username || !password || !authToken) {
1083 res.send(Alert.ErrorAlert('Invalid username/password'));
1088 const player = await loadPlayer(authToken.toString());
1089 logger.log(`Attempted claim for ${player.username}`);
1091 await signup(authToken.toString(), username, password);
1093 await db('players').where({id: player.id}).update({
1094 account_type: 'auth',
1098 logger.log(`${username} claimed ${player.username}`);
1100 io.emit('chat', broadcastMessage('server', `${player.username} is now ${username}`));
1102 res.setHeader('hx-refresh', 'true');
1103 res.sendStatus(200);
1107 if(e?.constraint === 'players_username_unique') {
1108 res.send(Alert.ErrorAlert('That username is already taken'));
1111 res.send(Alert.ErrorAlert('Please try again'));
1116 async function loginHandler (req: Request, res: Response) {
1117 const {username, password} = req.body;
1118 if(!username || !username.length) {
1119 res.send(Alert.ErrorAlert('Invalid username'));
1122 if(!password || !password.length) {
1123 res.send(Alert.ErrorAlert('Invalid password'));
1128 const player = await login(username, password);
1129 io.sockets.sockets.forEach(socket => {
1130 if(socket.handshake.headers['x-authtoken'] === req.headers['x-authtoken']) {
1131 bootstrapSocket(socket, player);
1134 res.sendStatus(204);
1138 res.send(Alert.ErrorAlert('That user doesn\'t exist'));
1142 server.listen(process.env.API_PORT, () => {
1143 logger.log(`Listening on port ${process.env.API_PORT}`);