chore(release): 0.2.8
[risinglegends.git] / src / server / api.ts
index 85ba258baec428e18919ec38dbb6f56bea3db8ce..e47c4a1f2c998dcd3991af5e213abae4ebc26e54 100644 (file)
@@ -1,77 +1,89 @@
+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 http from 'http';
 import { Server, Socket } from 'socket.io';
 import { logger } from './lib/logger';
-import { EquipmentInSlotError } from './error';
-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, professionList} from '../shared/player';
-import {clearFight, createFight, getMonsterList, loadMonster, loadMonsterFromFight, saveFightState} from './monster';
+import {expToLevel, maxHp, Player} from '../shared/player';
+import {clearFight, createFight, getMonsterList, getRandomMonster, loadMonster, loadMonsterFromFight, loadMonsterWithFaction, saveFightState} from './monster';
 import {FightRound} from '../shared/fight';
-import {addInventoryItem, getEquippedItems, getInventory, getInventoryItem, updateAp} from './inventory';
-import {Monster, MonsterForList} from '../shared/monsters';
-import {getShopItem, listShopItems} from './shopItem';
-import {equip, unequip} from './equipment';
-import { v4 as uuid } from 'uuid';
+import {addInventoryItem, deleteInventoryItem, getEquippedItems, getInventory, getInventoryItem, updateAp} from './inventory';
+import { getItemFromPlayer, getItemFromShop, getPlayersItems, getShopItems, givePlayerItem, updateItemCount } from './items';
+import {FightTrigger, Monster, MonsterForFight, MonsterWithFaction} from '../shared/monsters';
+import {getShopEquipment, listShopItems } from './shopEquipment';
 import {EquippedItemDetails} from '../shared/equipped';
-import {ArmourType, EquipmentSlot, SubType} from '../shared/inventory';
-import { getAllServices, getCityDetails } from './map';
+import {ArmourEquipmentSlot, 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  { router as healerRouter } from './locations/healer';
+
+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 } from './views/monster-selector';
+import { renderFight, 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, 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();
 
+otel.s();
+
 const app = express();
 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<SubType, {currentAp: number, maxAp: number}> | Record<any, any> = {};
-  inventoryItem.forEach(item => {
-    if(item.is_equipped && item.type === 'ARMOUR') {
-      ap[item.subType] = {
-        currentAp: item.currentAp,
-        maxAp: item.maxAp
-      };
-    }
-  });
-
-  socket.emit('calc:ap', {ap});
-}
+app.use((req, res, next) => {
+  console.log(req.method, req.url);
+  next();
+});
 
-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: string, 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) {
@@ -81,469 +93,783 @@ io.on('connection', async socket => {
   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);
+
+  socket.on('disconnect', () => {
+    console.log(`Player ${player.username} left`);
+    io.emit('status', `${io.sockets.sockets.size} Online (v${version})`);
+  });
 
-  const inventory = await getEquippedItems(player.id);
-  calcAp(inventory, socket);
 
-  setServerStats();
-  io.emit('chathistory', chatHistory);
-  io.emit('chat', broadcastMessage('server', `${player.username} just logged in`));
+  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.on('chat', (msg: string) => {
-    if(msg.length > 0) {
-      const message = broadcastMessage(player.username, msg);
-      chatHistory.push(message);
-      chatHistory.slice(-10);
-      io.emit('chat', message);
+async function fightRound(player: Player, monster: MonsterWithFaction,  data: {action: 'attack' | 'cast' | 'flee', target: 'head' | 'body' | 'arms' | 'legs'}) {
+  const playerSkills = await getPlayerSkillsAsObject(player.id);
+  const roundData: FightRound = {
+    monster,
+    player,
+    winner: 'in-progress',
+    fightTrigger: monster.fight_trigger,
+    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;
     }
   });
 
-  socket.on('calc:ap', async () => {
-    const items = await getEquippedItems(player.id);
-    calcAp(items, socket);
-  });
+  const boost = {
+    strength: 0,
+    constitution: 0,
+    dexterity: 0,
+    intelligence: 0,
+    damage: 0,
+    hp: 0,
+  };
+
+  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);
+    }
+
+    boost.strength += item.boosts.strength;
+    boost.constitution += item.boosts.constitution;
+    boost.dexterity += item.boosts.dexterity;
+    boost.intelligence += item.boosts.intelligence;
 
-  socket.on('city:explore:alley_1', async () => {
-    socketSendMonsterList('alley_1', socket);
+    if(item.type === 'SPELL' && item.affectedSkills.includes('restoration_magic')) {
+      boost.hp += item.boosts.damage;
+    }
+    else {
+      boost.damage += item.boosts.damage;
+    }
   });
 
-  socket.on('city:explore:forest_1', async () => {
-    socketSendMonsterList('forest_1', socket);
+  // 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);
+
+    return { roundData, monsters: [], player };
+  }
+
+  const attackType = data.action === 'attack' ? 'physical' : 'magical';
+  const primaryStat = data.action === 'attack' ? player.strength : player.intelligence;
+  const boostStat = data.action === 'attack' ? boost.strength : boost.intelligence;
+
+  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));
+      }
+      else {
+        playerDamageAfterMasteries += playerDamage * Skills.get(id).effect(playerSkills.get(id));
+      }
+
+      if(!skillsUsed[id]) {
+        skillsUsed[id] = 0;
+      }
+      skillsUsed[id]++;
+    });
   });
 
-  socket.on('purchase', async (data) => {
-    const shopItem = await getShopItem(data.id);
-
-    if(shopItem) {
-      shopItem.id = uuid();
-      if(player.gold < shopItem.cost) {
-        socket.emit('alert', {
-          type: 'error',
-          text: `You dont have enough gold to buy the ${shopItem.name}`
-        });
-        return;
+  await updatePlayerSkills(player.id, skillsUsed);
+
+  const playerFinalDamage = (data.action === 'cast' && !anyDamageSpells) ? 0 : Math.floor(playerDamage + playerDamageAfterMasteries);
+  const playerFinalHeal = Math.floor(boost.hp + hpHealAfterMasteries);
+
+  roundData.roundDetails.push(`You targeted the monsters ${data.target.toUpperCase()} with ${attackType} damage!`);
+  let armourKey: string;
+  switch(data.target) {
+    case 'arms':
+      armourKey = 'armsAp';
+      break;
+    case 'head':
+      armourKey = 'helmAp';
+      break;
+    case 'legs':
+      armourKey = 'legsAp';
+      break;
+    case 'body':
+      armourKey = 'chestAp';
+      break;
+  }
+
+  if(monster[armourKey] && monster[armourKey] > 0) {
+    monster[armourKey] -= playerFinalDamage;
+
+    roundData.roundDetails.push(`You dealt ${playerFinalDamage} damage to their armour`);
+    if(monster[armourKey] < 0) {
+      roundData.roundDetails.push(`You destroyed the ${monster.name}'s armour!'`);
+      roundData.roundDetails.push(`You dealt ${monster[armourKey] * -1} damage to their HP`);
+      monster.hp += monster[armourKey];
+      monster[armourKey] = 0;
+    }
+  }
+  else {
+    roundData.roundDetails.push(`You hit the ${monster.name} for ${playerFinalDamage} damage.`);
+    monster.hp -= playerFinalDamage;
+  }
+
+  if(monster.hp <= 0) {
+    roundData.monster.hp = 0;
+    roundData.winner = 'player';
+
+    roundData.rewards.exp = monster.exp;
+    roundData.rewards.gold = monster.gold;
+
+    player.gold += monster.gold;
+    player.exp += monster.exp;
+
+    if(player.exp >= expToLevel(player.level + 1)) {
+      player.exp -= expToLevel(player.level + 1)
+      player.level++;
+      roundData.rewards.levelIncrease = true;
+      let statPointsGained = 1;
+
+      if(player.profession !== 'Wanderer') {
+        statPointsGained = 2;
       }
 
-      player.gold -= shopItem.cost;
-      await updatePlayer(player);
-      await addInventoryItem(player.id, shopItem);
+      player.stat_points += statPointsGained;
 
-      socket.emit('alert', {
-        type: 'success',
-        text: `You bought the ${shopItem.name}`
-      });
+      roundData.roundDetails.push(`You gained ${statPointsGained} stat points!`);
 
-      socket.emit('updatePlayer', player);
+      player.hp = maxHp(player.constitution, player.level);
+    }
+    // get the monster location if it was an EXPLORED fight
+    if(roundData.fightTrigger === 'explore') {
+      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,
+          fight_trigger: 'explore'
+        }
+      });
     }
 
-  });
+    await clearFight(player.id);
+    await updatePlayer(player);
+    return { roundData, monsters: potentialMonsters, player };
+  }
 
-  socket.on('city:services:healer', async () => {
-    let text: string[] = [`Welcome to the <b>Healer</b>`];
-    if(player.gold < 10) {
-      text.push(`Oh wow.. you sure dont have any money on you... I guess I'll heal you for free this time.`);
+  roundData.roundDetails.push(`The ${monster.name} targeted your ${target}!`);
+  if(equipment.has(target)) {
+    const item = equipment.get(target);
+    // apply mitigation!
+    const mitigationPercentage = item.boosts.damage_mitigation || 0;
+    const damageAfterMitigation = Math.floor(monster.strength * ((100-mitigationPercentage)/100));
+
+    item.currentAp -= damageAfterMitigation;
+
+    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 {
-      text.push('Healing to full health will cost you 10G.');
+      roundData.roundDetails.push(`Your ${target} took ${damageAfterMitigation} damage!`);
+      await updateAp(player.id, item.item_id, item.currentAp, item.maxAp);
     }
-    text.push(`<br><button type="button" class="city-emit-event" data-event="city:service:healer:heal">Heal!</button>`);
 
-    socket.emit('city:service:healer', {
-      text: text.join("<br>")
-    });
-  });
+  }
+  else {
+    roundData.roundDetails.push(`The ${monster.name} hit you for ${monster.strength} damage`);
+    player.hp -= monster.strength;
+  }
 
-  socket.on('city:service:healer:heal', async () => {
-    player.hp = maxHp(player.constitution, player.level);
-    if(player.gold >= 10) {
-      player.gold -= 10;
+  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`);
+  }
+
+  // update the players inventory for this item!
+
+  if(player.hp <= 0) {
+    player.hp = 0;
+    roundData.winner = 'monster';
+
+    roundData.roundDetails.push(`You were killed by the ${monster.name}`);
+
+    await clearFight(player.id);
     await updatePlayer(player);
-    socket.emit('updatePlayer', player);
+    await clearTravelPlan(player.id);
 
-    socket.emit('city:service:healer', {
-      text: 'You have been healed!'
-    });
-  });
-  socket.on('city:stores:armourer', async () => {
-    const shopItems = await listShopItems({
-      type: 'ARMOUR'
-    })
-    socket.emit('city:store:armourer', shopItems);
-  });
+    return { roundData, monsters: [], player};
+  }
 
-  socket.on('city:stores:blacksmith', async () => {
-    const shopItems = await listShopItems({
-      type: 'WEAPON'
-    })
-    socket.emit('city:store:blacksmith', shopItems);
-  });
+  await updatePlayer(player);
+  await saveFightState(player.id, monster);
 
-  socket.on('city:stores:mageshop', async () => {
-    const shopItems = await listShopItems({
-      type: 'SPELL'
-    })
-    socket.emit('city:store:mageshop', shopItems);
-  });
+  return { roundData, monsters: [], player};
+};
 
-  socket.on('inventory', async () => {
-    const inventory = await getInventory(player.id);
-    socket.emit('inventory', {
-      inventory
-    });
-  });
+app.use(healerRouter);
 
-  socket.on('equip', async (data: {id: string, slot: EquipmentSlot }) => {
-    const inventoryItem = await getInventoryItem(player.id, data.id);
-    const equippedItems = await getEquippedItems(player.id);
-    let desiredSlot: EquipmentSlot = inventoryItem.equipment_slot;
-
-    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(data.slot === 'LEFT_HAND' || data.slot === 'RIGHT_HAND') {
-          // get the players current equipment in that slot!
-          if(equippedItems.some(v => {
-            return v.equipment_slot === data.slot || v.equipment_slot === 'TWO_HANDED';
-          })) {
-            throw new EquipmentInSlotError();
-          }
-          else {
-            desiredSlot = data.slot;
-          }
-        }
-      }
 
-      if(data.slot === 'TWO_HANDED') {
-        if(equippedItems.some(v => {
-          return v.equipment_slot === 'LEFT_HAND' || v.equipment_slot === 'RIGHT_HAND';
-        })) {
-          throw new EquipmentInSlotError();
-        }
-      }
+app.get('/chat/history', authEndpoint, async (req: AuthRequest, res: Response) => {
+  let html = chatHistory.map(renderChatMessage);
 
+  res.send(html.join("\n"));
+});
 
-      await equip(player.id, inventoryItem, desiredSlot);
-      socket.emit('alert', {
-        type: 'success', 
-        text: `You equipped your ${inventoryItem.name} (${inventoryItem.subType})`
-      });
+app.post('/chat', authEndpoint, async (req: AuthRequest, res: Response) => {
+  const msg = req.body.message.trim();
+
+  if(!msg || !msg.length) {
+    res.sendStatus(204);
+    return;
+  }
+
+  let message: Message;
+  if(msg.startsWith('/server lmnop')) {
+    if(msg === '/server lmnop refresh-monsters') {
+      await createMonsters();
+      message = broadcastMessage('server', 'Monster refresh!');
     }
-    catch(e) {
-      if(e.code.toString() === '23505') {
-        socket.emit('alert', {
-          type: 'error',
-          text: 'You already have an item equipped in that slot'
-        });
-      }
-      else {
-        logger.log(e);
+    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, msg);
+    chatHistory.push(message);
+    chatHistory.slice(-10);
+  }
 
-    const inventory = await getInventory(player.id);
-    calcAp(inventory, socket);
-    socket.emit('inventory', {
-      inventory
-    });
-  });
+  if(message) {
+    io.emit('chat', renderChatMessage(message));
+    res.sendStatus(204);
+  }
+});
 
-  socket.on('unequip', async (data: {id: string }) => {
-    await unequip(player.id, data.id);
+app.get('/player', authEndpoint, async (req: AuthRequest, res: Response) => {
+  const inventory = await getEquippedItems(req.player.id);
 
-    const inventory = await getInventory(player.id);
-    calcAp(inventory, socket);
-    socket.emit('inventory', {
-      inventory
-    });
-  });
+  res.send(renderPlayerBar(req.player, inventory) + renderProfilePage(req.player));
+});
 
-  socket.on('fight', async (data: {action: 'attack' | 'cast' | 'flee', target: 'head' | 'body' | 'arms' | 'legs'}) => {
-    const monster = await loadMonsterFromFight(player.id);
-    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 = [];
-
-    /*
-     * 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: ArmourType[] = ['HELM', 'CHEST', 'ARMS', 'LEGS'];
-    // calc weighted
-    const rand = Math.ceil(Math.random() * factor);
-    let target: ArmourType = 'CHEST';
-     monsterTarget.forEach((i, idx) => {
-      if (rand > (i * factor)) {
-        target = targets[idx];
-      }
-    });
+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;
+  }
 
-    const boost = {
-      strength: 0,
-      constitution: 0,
-      dexterity: 0,
-      intelligence: 0,
-      damage: 0,
-    };
+  if(req.player.stat_points <= 0) {
+    res.send(Alert.ErrorAlert(`Sorry, you don't have enough stat points`));
+    return;
+  }
 
-    const equipment: Map<SubType, EquippedItemDetails> = new Map<ArmourType, EquippedItemDetails>();
-    equippedItems.forEach(item => {
-      if(item.type === 'ARMOUR') {
-        equipment.set(item.subType, item);
-      }
+  req.player.stat_points -= 1;
+  req.player[stat]++;
 
-      boost.strength += item.boost_strength;
-      boost.constitution += item.boost_constitution;
-      boost.dexterity += item.boost_dexterity;
-      boost.intelligence += item.boost_intelligence;
-      boost.damage += item.boost_damage;
-    });
+  updatePlayer(req.player);
 
-    const roundData: FightRound = {
-      monster,
-      player,
-      winner: 'in-progress',
-      roundDetails: [],
-      rewards: {
-        exp: 0,
-        gold: 0,
-        levelIncrease: false
-      }
-    };
+  const equippedItems = await getEquippedItems(req.player.id);
+  res.send(renderPlayerBar(req.player, equippedItems) + renderProfilePage(req.player));
+});
 
-    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';
+app.get('/player/skills', authEndpoint, async (req: AuthRequest, res: Response) => {
+  const skills = await getPlayerSkills(req.player.id);
 
-    const playerDamage= Math.floor(((primaryStat + boostStat) * 1.3) + boost.damage);
+  res.send(renderSkills(skills));
+});
 
-    roundData.roundDetails.push(`You targeted the monsters ${data.target.toUpperCase()} with ${attackType} damage!`);
-    if(data.target === 'arms') {
-      if(monster.armsAp > 0) {
-        monster.armsAp -= playerDamage;
+app.get('/player/inventory', authEndpoint, async (req: AuthRequest, res: Response) => {
+  const [inventory, items] = await Promise.all([
+    getInventory(req.player.id),
+    getPlayersItems(req.player.id)
+  ]);
 
-        roundData.roundDetails.push(`You dealt ${playerDamage} damage to their armour`);
-        if(monster.armsAp < 0) {
+  res.send(renderInventoryPage(inventory, items));
+});
 
-          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;
+app.post('/player/equip/:item_id/:slot', authEndpoint, 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;
+
+  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 {
+          desiredSlot = requestedSlot;
         }
-      }
-      else {
-        roundData.roundDetails.push(`You hit the ${monster.name} for ${playerDamage} damage.`);
-        monster.hp -= playerDamage;
       }
     }
-    else if (data.target === 'head') {
-      if(monster.helmAp > 0) {
-        monster.helmAp -= playerDamage;
 
-        roundData.roundDetails.push(`You dealt ${playerDamage} damage to their armour`);
-        if(monster.helmAp < 0) {
-
-          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 ${playerDamage} damage.`);
-        monster.hp -= playerDamage;
+    if(requestedSlot === 'TWO_HANDED') {
+      if(equippedItems.some(v => {
+        return v.equipment_slot === 'LEFT_HAND' || v.equipment_slot === 'RIGHT_HAND';
+      })) {
+        throw new Error();
       }
     }
-    else if(data.target === 'legs') {
-      if(monster.legsAp > 0) {
-        monster.legsAp -= playerDamage;
 
-        roundData.roundDetails.push(`You dealt ${playerDamage} damage to their armour`);
-        if(monster.legsAp < 0) {
 
-          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 ${playerDamage} damage.`);
-        monster.hp -= playerDamage;
+    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);
+  }
+
+  const [inventory, items] = await Promise.all([
+    getInventory(req.player.id),
+    getPlayersItems(req.player.id)
+  ]);
+
+  res.send(renderInventoryPage(inventory, items, inventoryItem.type) + renderPlayerBar(req.player, inventory));
+});
+
+app.post('/player/unequip/:item_id', authEndpoint, 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)
+  ]);
+
+  const [inventory, items] = await Promise.all([
+    getInventory(req.player.id),
+    getPlayersItems(req.player.id)
+  ]);
+
+  res.send(renderInventoryPage(inventory, items, item.type) + renderPlayerBar(req.player, inventory));
+});
+
+app.get('/player/explore', authEndpoint, async (req: AuthRequest, res: Response) => {
+  const fight = await loadMonsterFromFight(req.player.id);
+  let closestTown = req.player.city_id;
+
+  if(fight) {
+    // ok lets display 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
+    };
+
+    res.send(renderFight(data));
+  }
+  else {
+    const travelPlan = await getTravelPlan(req.player.id);
+    if(travelPlan) {
+      // traveling!
+      const travelPlan = await getTravelPlan(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 = cache[`step:${req.player.id}`] || 0;
+
+      res.send(renderTravel({
+        things,
+        nextAction,
+        closestTown: closest,
+        walkingText: ''
+      }));
     }
     else {
-      if(monster.chestAp > 0) {
-        monster.chestAp -= playerDamage;
+      // 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(await renderMap({city, locations, paths}, closestTown));
+    }
 
-        roundData.roundDetails.push(`You dealt ${playerDamage} 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 ${playerDamage} damage.`);
-        monster.hp -= playerDamage;
-      }
-    }
+// 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);
 
+  const equippedItems = await getEquippedItems(req.player.id);
 
-        const levelType = player.level % 2 ? 'evenLevel' : 'oddLevel';
+  res.send(renderPlayerBar(req.player, equippedItems) + Alert.SuccessAlert(`You purchased ${item.name}`));
+});
 
-        _.each(professionList[player.profession][levelType], (v, stat) => {
-            player[stat] += v;
+// 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));
 
-        });
+  if(!item) {
+    logger.log(`Invalid item [${req.params.item_id}]`);
+    return res.sendStatus(400);
+  }
 
-        player.hp = maxHp(player.constitution, player.level);
-      }
-      // get the monster location!
-      const rawMonster = await loadMonster(monster.id);
-      const monsterList  = await getMonsterList(rawMonster.location_id);
-      potentialMonsters = monsterList.map(monster => {
-        return {
-          id: monster.id,
-          name: monster.name,
-          level: monster.level
-        }
-      });
+  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;
+  }
 
-      await clearFight(player.id);
-      await updatePlayer(player);
-      socket.emit('fight-over', {roundData, monsters: potentialMonsters});
-      return;
-    }
+  req.player.gold -= item.price_per_unit;
 
-    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} was destroyed so they hit your directly for ${monster.strength} damage!`);
-        player.hp += item.currentAp;
-        item.currentAp = 0;
-      }
-      else {
-        roundData.roundDetails.push(`Your ${target} took ${monster.strength} damage!`);
-      }
+  await updatePlayer(req.player);
+  await givePlayerItem(req.player.id, item.id, 1);
 
-      await updateAp(player.id, item.item_id, item.currentAp, item.maxAp);
-    }
-    else {
-      roundData.roundDetails.push(`The ${monster.name} hit you for ${monster.strength} damage`);
-      player.hp -= monster.strength;
-    }
+  const equippedItems = await getEquippedItems(req.player.id);
 
-    // update the players inventory for this item!
+  res.send(renderPlayerBar(req.player, equippedItems) + Alert.SuccessAlert(`You purchased a ${item.name}`));
+});
 
-    if(player.hp <= 0) {
-      player.hp = 0;
-      roundData.winner = 'monster';
+// 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));
 
-      await clearFight(player.id);
-      await updatePlayer(player);
+  if(!equipment) {
+    logger.log(`Invalid equipment [${req.params.item_id}]`);
+    return res.sendStatus(400);
+  }
 
-      socket.emit('fight-over', {roundData, monsters: []});
-      return;
-    }
+  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">Buy</button>
+    <button class="close-modal" formmethod="dialog" value="cancel">Cancel</button>
+  </div>
+</dialog>
+`;
+
+  res.send(html);
+});
 
-    await updatePlayer(player);
-    await saveFightState(player.id, monster);
+// 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));
 
-    calcAp(equippedItems, socket);
-    socket.emit('fight-round', roundData);
-  });
+  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">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 [city, locations] = await Promise.all([
-    getCityDetails(req.params.id),
-    getAllServices(req.params.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("")
+  );
 
-  res.json({city, locations});
 });
 
-app.get('/fight', authEndpoint, async (req: Request, res: Response) => {
-  const authToken = req.headers['x-authtoken'].toString();
-  const player: Player = await loadPlayer(authToken)
+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(!player) {
-    logger.log(`Couldnt find player with id ${authToken}`);
+  if(!item) {
+    logger.log(`Invalid item [${req.params.item_id}]`);
     return res.sendStatus(400);
   }
 
-  const fight = await loadMonsterFromFight(player.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">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(fight || null);
+  const html = await renderStore(shopEquipment, shopItems, req.player);
 
+  res.send(html);
 });
 
-app.post('/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}`);
+    logger.log(`Invalid location: [${req.params.location_id}]`);
+    res.sendStatus(400);
+  }
+
+  const monsters: Monster[] = await getMonsterList(location.id);
+  res.send(renderMonsterSelector(monsters));
+});
+
+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/turn', authEndpoint, async (req: AuthRequest, res: Response) => {
+  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'
+  );
+
+  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', 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: string = req.body.monsterId;
+  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) {
@@ -551,17 +877,212 @@ app.post('/fight', authEndpoint, async (req: Request, res: Response) => {
     return res.sendStatus(400);
   }
 
-  await createFight(player.id, monster);
+  const fight = await createFight(req.player.id, monster, fightTrigger);
 
-  res.json({
-    id: monster.id,
-    name: monster.name,
-    level: monster.level,
-    hp: monster.hp,
-    maxHp: monster.maxHp
-  });
+
+  const data: MonsterForFight = {
+    id: fight.id,
+    hp: fight.hp,
+    maxHp: fight.maxHp,
+    name: fight.name,
+    level: fight.level,
+    fight_trigger: fight.fight_trigger
+  };
+
+  res.send(renderFight(data));
+});
+
+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() + 3000;
+
+    cache[stepTimerKey] = nextAction;
+
+    res.send(renderTravel({
+      things,
+      nextAction,
+      closestTown: closest,
+      walkingText: sample(walkingText)
+    }));
+
+  }
+});
+
+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;
+  }
+
+  await travel(req.player, destination.id);
+
+  res.send(renderTravel({
+    things: [],
+    nextAction: 0,
+    walkingText: '',
+    closestTown: req.player.city_id
+  }));
 });
 
+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.send(Alert.ErrorAlert('Invalid username/password'));
+    return;
+  }
+
+  try {
+    const player = await loadPlayer(authToken.toString());
+    logger.log(`Attempted claim for ${player.username}`);
+
+    await signup(authToken.toString(), username, password);
+
+    await db('players').where({id: player.id}).update({
+      account_type: 'auth',
+      username: username
+    });
+
+    logger.log(`${username} claimed ${player.username}`);
+
+    io.emit('chat', broadcastMessage('server', `${player.username} is now ${username}`));
+
+    res.setHeader('hx-refresh', 'true');
+    res.sendStatus(200);
+  }
+  catch(e) {
+    logger.log(e);
+    if(e?.constraint === 'players_username_unique') {
+      res.send(Alert.ErrorAlert('That username is already taken'));
+    }
+    else {
+      res.send(Alert.ErrorAlert('Please try again'));
+    }
+  }
+}
+
+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);
+    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.send(Alert.ErrorAlert('That user doesn\'t exist'));
+  }
+}
+
 server.listen(process.env.API_PORT, () => {
   logger.log(`Listening on port ${process.env.API_PORT}`);
 });