chore(release): 0.2.2
[risinglegends.git] / src / client / index.ts
index e7d3244a58d4130b3ab0ce8aa2dc44005caa9f56..3d6fb16783c23e7874ea08b4f3fed61d23b44323 100644 (file)
@@ -1,20 +1,24 @@
 import { io } from 'socket.io-client';
 import $ from 'jquery';
-import {expToLevel, maxHp, Player} from '../shared/player';
+import {expToLevel, maxHp, Player, StatDef, StatDisplay} from '../shared/player';
 import { authToken, http } from './http';
 import { CustomEventManager } from './events';
-import {MonsterForFight, MonsterForList} from '../shared/monsters';
+import {Fight, FightTrigger, MonsterForFight, MonsterForList} from '../shared/monsters';
 import {FightRound} from '../shared/fight';
-import { City, city, Location } from '../shared/map'
+import { City, Location, LocationType, Path } from '../shared/map'
 import { v4 as uuid } from 'uuid';
 import {EquipmentSlot, ShopItem} from '../shared/inventory';
 import { each } from 'lodash';
 import {EquippedItemDetails} from '../shared/equipped';
 import { Skill, Skills } from '../shared/skills';
 import { configureChat } from './chat';
+import { SocketEvent } from './socket-event.client';
 import * as EventList from '../events/client';
+import { TimeManager } from '../shared/time';
+import { TravelDTO } from '../events/travel/shared';
 
 
+const time = new TimeManager();
 const cache = new Map<string, any>();
 const events = new CustomEventManager();
 const socket = io({
@@ -25,6 +29,32 @@ const socket = io({
 
 configureChat(socket);
 
+function setTimeGradient() {
+  const gradientName = time.gradientName();
+  const $body = $('body');
+  $body.removeClass(Array.from($body[0].classList)).addClass(gradientName);
+  $body.addClass(time.getTimePeriod());
+
+  let icon: string;
+  if(time.get24Hour() >= 5 && time.get24Hour() < 9) {
+    icon = 'morning';
+  }
+  else if(time.get24Hour() >= 9 && time.get24Hour() < 17) {
+    icon = 'afternoon';
+  }
+  else if(time.get24Hour() >= 17 && time.get24Hour() < 20) {
+    icon = 'evening'
+  }
+  else {
+    icon = 'night';
+  }
+  
+  $('#time-of-day').html(`<img src="/assets/img/icons/time-of-day/${icon}.png"> ${time.getHour()}${time.getAmPm()}`);
+}
+
+setTimeGradient();
+setInterval(setTimeGradient, 60 * 1000);
+
 function icon(name: string): string {
   return `<img src="/assets/img/${name}.png" class="icon">`;
 }
@@ -93,16 +123,14 @@ function updatePlayer() {
           </form></p>`).removeClass('hidden');
   }
 
+  $('#extra-inventory-info').html(renderStatDetails());
 }
 
 socket.on('connect', () => {
   console.log(`Connected: ${socket.id}`);
 });
 
-socket.on('ready', () => {
-  console.log('Server connection verified');
-  socket.emit('inventory');
-});
+socket.on('ready', bootstrap);
 
 socket.on('server-stats', (data: {onlinePlayers: number}) => {
   $('#server-stats').html(`${data.onlinePlayers} players online`);
@@ -110,10 +138,16 @@ socket.on('server-stats', (data: {onlinePlayers: number}) => {
 
 each(EventList, event => {
   console.log(`Binding Event ${event.eventName}`);
-  socket.on(event.eventName, event.handler.bind(null, {
-    cache,
-    socket
-  }))
+  if(event instanceof SocketEvent) {
+    socket.on(event.eventName, event.handler.bind(null, {
+      cache,
+      socket,
+      events
+    }));
+  }
+  else {
+    console.log('Skipped binding', event);
+  }
 });
 
 socket.on('calc:ap', (data: {ap: Record<EquipmentSlot, { currentAp: number, maxAp: number}>}) => {
@@ -165,20 +199,27 @@ socket.on('skills', (data: {skills: Skill[]}) => {
   $('#skills').html(html);
 });
 
-events.on('alert', (data: {type: string, text: string}) => {
+function Alert(text: string, type: string = 'error') {
   let id = uuid();
-  $('#alerts').append(`<div class="alert ${data.type}" id="alert-${id}">${data.text}</div>`);
+  $('#alerts').append(`<div class="alert ${type}" id="alert-${id}">${text}</div>`);
 
   setTimeout(() => {
     $(`#alert-${id}`).remove();
   }, 3000);
+}
 
+events.on('alert', (data: {type: string, text: string}) => {
+  Alert(data.text, data.type);
 });
 
 socket.on('alert', data => {
   events.emit('alert', [data]);
 });
 
+socket.on('init', (data: {version: string}) => {
+  $('#version').html(`v${data.version}`);
+});
+
 socket.on('authToken', (authToken: string) => {
   console.log(`recv auth token ${authToken}`);
   localStorage.setItem('authToken', authToken);
@@ -190,21 +231,44 @@ socket.on('player', (player: Player) => {
   updatePlayer();
 });
 
+async function fetchState() {
+  const res = await http.get('/state');
+  cache.set('state', res);
+}
 
-$('nav a').on('click', e => {
+$('nav a').on('click', async e => {
   e.preventDefault();
   e.stopPropagation();
 
+  const tabsDisabledInFight = [
+    'skills',
+    'inventory'
+  ];
+
+  await fetchState();
+  const state = cache.get('state');
+
   const $tabContainer = $(`#${$(e.target).data('container')}`);
+  let tabSection = $(e.target).data('section');
+
+  if(tabsDisabledInFight.includes(tabSection) && state?.fight !== null) {
+    Alert('You are currently in a fight', 'error');
+    // we want to force users over to the explore tab 
+    // if they are currently in a fight and trying to 
+    // access a disabled section
+    tabSection = 'explore';
+  }
 
   $tabContainer.find('.tab').removeClass('active');
 
-  $(`#${$(e.target).data('section')}`).addClass('active');
+  $(`#${tabSection}`).addClass('active');
 
-  $(e.target).closest('nav').find('a').removeClass('active');
-  $(e.target).addClass('active');
+  if(e.target.innerHTML !== 'Settings') {
+    $(e.target).closest('nav').find('a').removeClass('active');
+    $(`nav a[data-section=${tabSection}]`).addClass('active');
+  }
 
-  events.emit(`tab:${$(e.target).data('section')}`);
+  events.emit(`tab:${tabSection}`);
 });
 
 events.on('tab:inventory', () => {
@@ -256,28 +320,27 @@ function renderEquipmentPlacementGrid(items: EquippedItemDetails[]) {
 
   return html;
 }
+function statPointIncreaser(stat: StatDisplay) {
+  return `<button class="increase-stat emit-event" data-event="spend-stat-point" data-args="${stat.id}">+</button>`;
+}
 function renderStatDetails() {
   const player: Player = cache.get('player');
-  const html = `
-  <table id="stat-breakdown">
-  <tr>
-  <th>Strength</th>
-  <td class="strength">${player.strength}</td>
-  </tr>
-  <tr>
-  <th>Constitution</th>
-  <td class="constitution">${player.constitution}</td>
-  </tr>
-  <tr>
-  <th>Dexterity</th>
-  <td class="dexterity">${player.dexterity}</td>
-  </tr>
-  <tr>
-  <th>Intelligence</th>
-  <td class="intelligence">${player.intelligence}</td>
-  </tr>
-  </table>
-  `;
+  let html = '<table id="stat-breakdown">';
+
+  StatDef.forEach(stat => {
+    html += `<tr>
+      <th>${stat.display}</th>
+      <td class="${stat.id}">
+        ${player[stat.id]}
+        ${player.stat_points ? statPointIncreaser(stat) : ''}
+      </td>
+    </tr>`;
+  });
+
+  html += `<tr><th>Stat Points</th><td class="stat_points">${player.stat_points}</td></tr>`;
+
+  html += '</table>';
+
   return html;
 }
 
@@ -388,52 +451,67 @@ $('body').on('click', '.unequip-item', e => {
   socket.emit('unequip', { id: $(e.target).data('id') });
 });
 
-async function renderMap() {
-  if(cache.has(`map:${cache.get('player').city_id}`)) {
-    $('#map').html(cache.get(`map:${cache.get('player').city_id}`));
+function setMapBackground(city_id: number) {
+  $('#explore').css({
+    'background-image': `linear-gradient(to left top, rgba(255,255,255,0) 0%,rgb(255,255,255) 100%), linear-gradient(to left, rgba(255, 255, 255, 0) 0%, rgb(255, 255, 255) 100%), url("/assets/img/map/${city_id}.jpeg")`
+  });
+}
+
+events.on('renderMap', async function renderMap(data: { city: City, locations: Location[], paths: Path[]}) {
+  if(!data && cache.has('currentMapHTML')) {
+    $('#map').html(cache.get('currentMapHTML'));
     return;
   }
 
-  const data: {city: City, locations: Location[]} = await http.get(`/city/${cache.get('player').city_id}`);
+  if(!data) {
+    console.error('oh no.. this got triggered without any city data');
+  }
 
-  let html = `
-  <h1>${data.city.name}</h1>
-  <table>
-    <tr>
-      <th>Services</th>
-      <th>Stores</th>
-      <th>Explore</th>
-    </tr>
-    <tr>
-      <td>
-      ${data.locations.filter(l => l.type === 'SERVICES').map(l => {
-        return `<a href="#" class="city-action" data-type="services" data-id="${l.id}">${l.name}</a>`
-      }).join("<br>")}
-      </td>
-      <td>
-      ${data.locations.filter(l => l.type === 'STORES').map(l => {
-        return `<a href="#" class="city-action" data-type="stores" data-id="${l.id}">${l.name}</a>`
-      }).join("<br>")}
-      </td>
-      <td>
-      ${data.locations.filter(l => l.type === 'EXPLORE').map(l => {
-        return `<a href="#" class="city-action" data-type="explore" data-id="${l.id}">${l.name}</a>`
+  setMapBackground(data.city.id);
+
+  const servicesParsed: Record<LocationType, string[]> = {
+    'SERVICES': [],
+    'STORES': [],
+    'EXPLORE': []
+  };
+
+  data.locations.forEach(l => {
+    servicesParsed[l.type].push(`<a href="#" class="city-emit-event" data-event="${l.event_name}" data-args="${l.id}">${l.name}</a>`);
+  });
+
+  let html = `<h1>${data.city.name}</h1>
+  <div class="city-details">`;
+
+  if(servicesParsed.SERVICES.length) {
+    html += `<div><h3>Services</h3>${servicesParsed.SERVICES.join("<br>")}</div>`
+  }
+  if(servicesParsed.STORES.length) {
+    html += `<div><h3>Stores</h3>${servicesParsed.STORES.join("<br>")}</div>`
+  }
+  if(servicesParsed.EXPLORE.length) {
+    html += `<div><h3>Explore</h3>${servicesParsed.EXPLORE.join("<br>")}</div>`
+  }
+  html += `
+    <div>
+      <h3>Travel</h3>
+      ${data.paths.map(path => {
+        return `<a href="#" class="city-emit-event" data-event="city:travel" data-args="${path.ending_city}">${path.ending_city_name}</a>`
       }).join("<br>")}
-      </td>
-    </tr>
-  </table>
+    </div>
+  </div>
   `;
-  cache.set(`map:${cache.get('player').city_id}`, html);
 
-  $('#map').html(html);
-}
+  cache.set('currentMapHTML', html);
 
-socket.on('city:service:healer', (data: {text: string}) => {
-  $('#map').html(data.text);
+  $('#map').html(html);
 });
 
-function renderRequirement(name: string, val: number | string): string {
-  return `<span class="requirement-title">${name}</span>: <span class="requirement-value">${val.toLocaleString()}</span>`;
+function renderRequirement(name: string, val: number | string, currentVal?: number): string {
+  let colorIndicator = '';
+  if(currentVal) {
+    colorIndicator = currentVal >= val ? 'success' : 'error';
+  }
+  return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${colorIndicator}">${val.toLocaleString()}</span>`;
 }
 
 function renderStatBoost(name: string, val: number | string): string {
@@ -441,7 +519,7 @@ function renderStatBoost(name: string, val: number | string): string {
   if(typeof val === 'number') {
     valSign = val > 0 ? '+' : '-';
   }
-  return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${typeof val === 'number' && val > 0 ? "success": "error"}">${valSign}${val}</span>`;
+  return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${typeof val === 'number' ? (val > 0 ? "success": "error") : ""}">${valSign}${val}</span>`;
 }
 
 function renderInventoryItem(item: EquippedItemDetails , action: (item: EquippedItemDetails) => string): string {
@@ -465,6 +543,7 @@ function renderInventoryItem(item: EquippedItemDetails , action: (item: Equipped
       ${item.boosts.dexterity ? renderStatBoost('DEX', item.boosts.dexterity) : ''}
       ${item.boosts.intelligence ? renderStatBoost('INT', item.boosts.intelligence) : ''}
       ${item.boosts.damage ? renderStatBoost('DMG', item.boosts.damage) : ''}
+      ${item.boosts.damage_mitigation ? renderStatBoost('MIT', item.boosts.damage_mitigation.toString())+'%' : ''}
       ${['WEAPON','SPELL'].includes(item.type) ? '': generateProgressBar(item.currentAp, item.maxAp, '#7be67b')}
       </div>
       ${item.hasOwnProperty('id') ? `<div>${item.cost.toLocaleString()}G</div>` : ''}
@@ -476,6 +555,7 @@ function renderInventoryItem(item: EquippedItemDetails , action: (item: Equipped
 }
 
 function renderShopItem(item: ShopItem, action: (item: ShopItem) => string): string {
+  const player: Player = cache.get('player');
     return `<div class="store-list">
     <div>
       <img src="https://via.placeholder.com/64x64">
@@ -483,11 +563,11 @@ function renderShopItem(item: ShopItem, action: (item: ShopItem) => string): str
     <div class="details">
       <div class="name">${item.name}${item.equipment_slot === 'TWO_HANDED' ? ' (2H)': ''}</div>
       <div class="requirements">
-      ${item.requirements.level ? renderRequirement('LVL', item.requirements.level): ''}
-      ${item.requirements.strength ? renderRequirement('STR', item.requirements.strength): ''}
-      ${item.requirements.constitution ? renderRequirement('CON', item.requirements.constitution): ''}
-      ${item.requirements.dexterity ? renderRequirement('DEX', item.requirements.dexterity): ''}
-      ${item.requirements.intelligence ? renderRequirement('INT', item.requirements.intelligence): ''}
+      ${item.requirements.level ? renderRequirement('LVL', item.requirements.level, player.level): ''}
+      ${item.requirements.strength ? renderRequirement('STR', item.requirements.strength, player.strength): ''}
+      ${item.requirements.constitution ? renderRequirement('CON', item.requirements.constitution, player.constitution): ''}
+      ${item.requirements.dexterity ? renderRequirement('DEX', item.requirements.dexterity, player.dexterity): ''}
+      ${item.requirements.intelligence ? renderRequirement('INT', item.requirements.intelligence, player.intelligence): ''}
       ${renderRequirement('PRF', item.profession)}
       </div>
       <div class="stat-mods">
@@ -495,8 +575,9 @@ function renderShopItem(item: ShopItem, action: (item: ShopItem) => string): str
       ${item.boosts.constitution ? renderStatBoost('CON', item.boosts.constitution) : ''}
       ${item.boosts.dexterity ? renderStatBoost('DEX', item.boosts.dexterity) : ''}
       ${item.boosts.intelligence ? renderStatBoost('INT', item.boosts.intelligence) : ''}
-      ${item.boosts.damage ? renderStatBoost('DMG', item.boosts.damage) : ''}
-      ${['WEAPON','SPELL'].includes(item.type) ? '' : renderStatBoost('AP', item.maxAp)}
+      ${item.boosts.damage ? renderStatBoost(item.affectedSkills.includes('restoration_magic') ? 'HP' : 'DMG', item.boosts.damage) : ''}
+      ${item.boosts.damage_mitigation ? renderStatBoost('MIT', item.boosts.damage_mitigation.toString())+'%' : ''}
+      ${['WEAPON','SPELL'].includes(item.type) ? '' : renderStatBoost('AP', item.maxAp.toString())}
       </div>
       ${item.hasOwnProperty('id') ? `<div>${item.cost.toLocaleString()}G</div>` : ''}
     </div>
@@ -507,8 +588,8 @@ function renderShopItem(item: ShopItem, action: (item: ShopItem) => string): str
 
 }
 
-socket.on('city:store:blacksmith', (data: ShopItem[]) => {
-  let html = `<div class="weapon-listing">
+socket.on('city:stores', (data: ShopItem[]) => {
+  let html = `<div class="shop-inventory-listing">
   ${data.map(item => {
     return renderShopItem(item, i => {
       const id = `data-id="${i.id}"`;
@@ -519,35 +600,9 @@ socket.on('city:store:blacksmith', (data: ShopItem[]) => {
   `;
 
   $('#map').html(html);
-});
-
-socket.on('city:store:armourer', (data: ShopItem[]) => {
-  let html = `<div class="armour-listing">
-  ${data.map(item => {
-    return renderShopItem(item, i => {
-      const id = `data-id="${i.id}"`;
-      return `<button type="button" class="purchase-item" ${id} data-type="${i.type}" data-equipment-slot="${i.equipment_slot}" data-cost="${i.cost}">Buy</button>`
-    })
-  }).join("\n")}
-  </div>
-  `;
 
-  $('#map').html(html);
 });
 
-socket.on('city:store:mageshop', (data: ShopItem[]) => {
-  let html = `<div class="spell-listing">
-  ${data.map(item => {
-    return renderShopItem(item, i => {
-      const id = `data-id="${i.id}"`;
-      return `<button type="button" class="purchase-item" ${id} data-type="${i.type}" data-equipment-slot="${i.equipment_slot}" data-cost="${i.cost}">Buy</button>`
-    })
-  }).join("\n")}
-  </div>
-  `;
-
-  $('#map').html(html);
-});
 $('body').on('click', '.purchase-item', e => {
   e.preventDefault();
   e.stopPropagation();
@@ -572,33 +627,55 @@ $('body').on('click', '.purchase-item', e => {
   }
 });
 
-$('body').on('click', '.city-emit-event', e => {
-  const eventName = $(e.target).data('event');
-  console.log(`Sending event ${eventName}`);
-  socket.emit(eventName);
+$('body').on('click', '.emit-event', e => {
+  const $el = $(e.target);
+
+  const eventName = $el.data('event');
+  const args = $el.data('args');
+
+  const rawBlock = $el.data('block');
+
+  if(rawBlock) {
+    const block = parseInt(rawBlock) || 0;
+
+    if(block > Date.now()) {
+      Alert('Sorry, clicked too quick');
+      return;
+    }
+  }
+
+  console.log(`Sending event ${eventName}`, { args });
+  socket.emit(eventName, { args });
 });
 
+$('body').on('click', '.city-emit-event', e => {
+  const $el = $(e.target);
 
-$('body').on('click', '.city-action', e => {
-  const data = {
-    type: $(e.target).data('type'),
-    id: $(e.target).data('id')
-  };
+  const eventName = $el.data('event');
+  const args = $el.data('args');
 
-  const eventName = `city:${data.type}:${data.id}` ;
+  console.log(`Sending event ${eventName}`, { args });
+  socket.emit(eventName, { args });
+});
+
+$('body').on('click', '.emit-event-internal', e => {
+  const $el = $(e.target);
 
-  console.log('Sending event', eventName);
+  const eventName = $el.data('event');
+  const args: string[] = $el.data('args')?.toString().split('|');
 
-  socket.emit(eventName);
+  console.log(`Trigger internal event [${eventName}]`);
+  events.emit(eventName, args);
 });
 
+
 socket.on('explore:fights', (monsters: MonsterForList[]) => {
   const lastSelectedMonster = cache.get('last-selected-monster');
   if(monsters.length) {
     let sel = `<form id="fight-selector"><select id="monster-selector">
     ${monsters.map(monster => {
-      return `<option value="${monster.id}" ${lastSelectedMonster === monster.id ? 'selected': ''}>${monster.name}</option>`;
-    })}
+      return `<option value="${monster.id}" ${lastSelectedMonster == monster.id ? 'selected': ''}>${monster.name}</option>`;
+    }).join("\n")}
     </select> <button type="submit">Fight</button></option>`;
 
     $('#map').html(sel);
@@ -606,16 +683,77 @@ socket.on('explore:fights', (monsters: MonsterForList[]) => {
 });
 
 events.on('tab:explore', async () => {
-  const res = await http.get('/fight');
-  if(!res) {
-    renderMap();
+  const state = cache.get('state');
+  if(cache.get('player').hp <= 0 || (!state.fight && !state.travel)) {
+    // get the details of the city
+    // render the map!
+    let data: {city: City, locations: Location[], paths: Path[]};
+    if(!cache.has(('currentMapHTML')) || cache.get('player').hp <= 0) {
+      data = await http.get(`/city/${cache.get('player').city_id}`);
+    }
+    events.emit('renderMap', [data, state.travel]);
+
   }
-  else {
-    $('#map').html(renderFight(res));
+  else if(state.fight) {
+    setMapBackground(state.closestTown);
+    $('#map').html(renderFight(state.fight));
+  }
+  else if(state.travel) {
+    // render TRAVEL
+    events.emit('renderTravel', [state.travel]);
   }
 });
 
-function renderFight(monster: MonsterForFight) {
+function updateStepButton() {
+  const raw = $('#keep-walking').data('block');
+  const time = parseInt(raw) || 0;
+
+  if(time > 0) {
+    const wait = Math.ceil((time - Date.now())/ 1000);
+    if(wait > 0) {
+      $('#keep-walking').prop('disabled', true).html(`Keep walking (${wait}s)`);
+      setTimeout(updateStepButton, 300);
+      return;
+    }
+  }
+
+  $('#keep-walking').prop('disabled', false).html(`Keep walking`);
+}
+
+function renderTravel(data: TravelDTO) {
+  setMapBackground(data.closestTown);
+
+  let promptText = data.walkingText;
+  const blockTime = data.nextAction || 0;
+  let html = `<div id="travelling">`;
+
+  html += '<div id="travelling-actions">';
+  html += `<button class="emit-event" id="keep-walking" data-event="travel:step" data-block="${blockTime}">Keep Walking</button>`;
+
+  if(data.things.length) {
+    // ok you found something, for now we only support 
+    // monsters, but eventually that will change
+    promptText = `You see a ${data.things[0].name}`;
+    html += `<button class="emit-event-internal" data-event="startFight" data-args="${data.things[0].id}|travel">Fight</button>`;
+  }
+
+  // end #travelling-actions
+  html += '</div>';
+  html += `<p>${promptText}</p>`;
+
+  // end #travelling div
+  html += '</div>';
+
+  $('#map').html(html);
+
+  if(blockTime) {
+    updateStepButton();
+  }
+}
+
+events.on('renderTravel', renderTravel);
+
+function renderFight(monster: MonsterForFight | Fight) {
   const hpPercent = Math.floor((monster.hp / monster.maxHp) * 100);
 
   let html = `<div id="fight-container">
@@ -628,7 +766,6 @@ function renderFight(monster: MonsterForFight) {
         <div class="progress-bar" id="defender-hp-bar" style="background: linear-gradient(to right, red, red ${hpPercent}%, transparent ${hpPercent}%, transparent)" title="${hpPercent}% - ${monster.hp}/${monster.maxHp}">${hpPercent}% - ${monster.hp} / ${monster.maxHp}</div>
       </div>
     </div>
-    <div id="fight-results"></div>
     <div id="fight-actions">
       <select id="fight-target">
         <option value="head">Head</option>
@@ -640,6 +777,7 @@ function renderFight(monster: MonsterForFight) {
       <button type="button" class="fight-action" data-action="cast">Cast</button>
       <button type="button" class="fight-action" data-action="flee">Flee</button>
     </div>
+    <div id="fight-results"></div>
   </div>`;
 
   return html;
@@ -672,17 +810,36 @@ socket.on('fight-over', (data: {roundData: FightRound, monsters: MonsterForFight
     }
   }
 
-  if(monsters.length) {
-    const lastSelectedMonster = cache.get('last-selected-monster');
-    html.push('<br><hr><h2>Fight Again</h2>');
-    html.push(`<form id="fight-selector"><select id="monster-selector">
-      ${monsters.map(monster => {
-        return `<option value="${monster.id}" ${lastSelectedMonster === monster.id ? 'selected': ''}>${monster.name}</option>`;
-      })}
-      </select> <button type="submit">Fight</button></option>
-    </select></form>`);
+  if(roundData.player.hp === 0) {
+    // prompt to return to town and don't let them do anything
+    html.push(`<p>You were killed...</p>`);
+    html.push('<p><button class="emit-event-internal" data-event="tab:explore" data-args="">Back to Town</button></p>');
+  }
+  else {
+    switch(roundData.fightTrigger) {
+      case 'explore':
+        if(monsters.length) {
+          const lastSelectedMonster = cache.get('last-selected-monster');
+          // put this above the fight details
+          html.unshift(`<h2>Fight Again</h2><form id="fight-selector"><select id="monster-selector">
+            ${monsters.map(monster => {
+              return `<option value="${monster.id}" ${lastSelectedMonster == monster.id ? 'selected': ''}>${monster.name}</option>`;
+            }).join("\n")}
+            </select> <button type="submit">Fight</button></option>
+          </select></form><hr>`);
+        }
+      break;
+      case 'travel':
+        html.push(`<p><button class="emit-event" data-event="travel:step">Keep Walking</button></p>`);
+      break;
+      default:
+        console.error(`Unknown fight trigger [${roundData.fightTrigger}]`, roundData);
+      break;
+    }
   }
 
+
+
   $('#fight-results').html(html.join("\n"));
   $('#fight-actions').html('');
 
@@ -698,20 +855,49 @@ socket.on('fight-round', (data: FightRound) => {
   $('#fight-results').html(data.roundDetails.map(d => `<div>${d}</div>`).join("\n"));
 });
 
+async function startFight(monsterId: string, fightTrigger: FightTrigger = 'explore') {
+  // check your HP first
+  if(cache.get('player').hp <= 0) {
+    events.emit('alert', [{
+      type: 'error',
+      text: 'You don\'t have enough HP to go looking for a fight'
+    }]);
+    return;
+  }
+
+  cache.set('last-selected-monster', monsterId);
+
+  try {
+    const monster: MonsterForFight = await http.post('/fight', {
+      monsterId,
+      fightTrigger
+    });
+
+    $('#map').html(renderFight(monster));
+  }
+  catch(e) {
+    events.emit('alert', [{
+      type: 'error',
+      text: 'Sorry, you can\'t start that fight'
+    }]);
+  }
+}
+
+events.on('startFight', startFight);
+
 $('body').on('submit', '#fight-selector', async e => {
   e.preventDefault();
   e.stopPropagation();
 
   const monsterId = $('#monster-selector option:selected').val();
 
-  cache.set('last-selected-monster', monsterId);
-
-  const monster: MonsterForFight = await http.post('/fight', {
-    monsterId,
-  });
-
+  if(monsterId) {
+    startFight(monsterId.toString());
+  }
+  else {
+    console.error(`Invalid monster id [${monsterId}]`);
+  }
 
-  $('#map').html(renderFight(monster));
 });
 
 $('body').on('click', '.fight-action', e => {
@@ -768,3 +954,37 @@ $('body').on('click', '#login', async e => {
     }
   }
 });
+
+$('body').on('click', '#logout', async e => {
+  e.preventDefault();
+  e.stopPropagation();
+
+  let logout = false;
+
+  const player = cache.get('player');
+  if(/^Player[\d]+$/.test(player.username)) {
+    const clear = confirm('Are you sure? You will not be able to retrieve this character unless you set up an email/password');
+    if(clear) {
+      logout = true;
+    }
+    else {
+      logout = false;
+    }
+  }
+  else {
+    logout = true;
+  }
+
+  if(logout) {
+    socket.emit('logout');
+    localStorage.clear();
+    window.location.reload();
+  }
+
+});
+
+function bootstrap() {
+  console.log('Server connection verified');
+  socket.emit('inventory');
+  $('nav a').first().click();
+}