feat: return to town button if killed while travelling
[risinglegends.git] / src / client / index.ts
1 import { io } from 'socket.io-client';
2 import $ from 'jquery';
3 import {expToLevel, maxHp, Player, StatDef, StatDisplay} from '../shared/player';
4 import { authToken, http } from './http';
5 import { CustomEventManager } from './events';
6 import {Fight, FightTrigger, MonsterForFight, MonsterForList} from '../shared/monsters';
7 import {FightRound} from '../shared/fight';
8 import { City, Location, LocationType, Path } from '../shared/map'
9 import { v4 as uuid } from 'uuid';
10 import {EquipmentSlot, ShopItem} from '../shared/inventory';
11 import { each } from 'lodash';
12 import {EquippedItemDetails} from '../shared/equipped';
13 import { Skill, Skills } from '../shared/skills';
14 import { configureChat } from './chat';
15 import { SocketEvent } from './socket-event.client';
16 import * as EventList from '../events/client';
17 import { TimeManager } from '../shared/time';
18 import { TravelDTO } from '../events/travel/shared';
19
20
21 const time = new TimeManager();
22 const cache = new Map<string, any>();
23 const events = new CustomEventManager();
24 const socket = io({
25   extraHeaders: {
26     'x-authtoken': authToken()
27   }
28 });
29
30 configureChat(socket);
31
32 function setTimeGradient() {
33   const gradientName = time.gradientName();
34   const $body = $('body');
35   $body.removeClass(Array.from($body[0].classList)).addClass(gradientName);
36   if(time.isNight()) {
37     $body.addClass('night');
38   }
39 }
40
41 setTimeGradient();
42 setInterval(setTimeGradient, 60 * 1000);
43
44 function icon(name: string): string {
45   return `<img src="/assets/img/${name}.png" class="icon">`;
46 }
47
48
49 function generateProgressBar(current: number, max: number, color: string, displayPercent: boolean = true): string {
50   let percent = 0;
51   if(max > 0) {
52     percent = Math.floor((current / max) * 100);
53   }
54   const display = `${displayPercent? `${percent}% - `: ''}`;
55   return `<div class="progress-bar" style="background: linear-gradient(to right, ${color}, ${color} ${percent}%, transparent ${percent}%, transparent)" title="${display}${current}/${max}">${display}${current}/${max}</div>`;
56 }
57
58 function progressBar(current: number, max: number, el: string, color: string) {
59   let percent = 0;
60   if(max > 0) {
61     percent = Math.floor((current / max) * 100);
62   }
63   $(`#${el}`).css(
64     'background',
65     `linear-gradient(to right, ${color}, ${color} ${percent}%, transparent ${percent}%, transparent)`
66   ).attr('title', `${percent}% - ${current}/${max}`).html(`${current}/${max} - ${percent}%`);
67 }
68
69 function updatePlayer() {
70   const player: Player = cache.get('player');
71
72   $('#username').html(`${player.username}, level ${player.level} ${player.profession}`);
73
74   progressBar(
75     player.hp,
76     maxHp(player.constitution, player.level),
77     'hp-bar',
78     '#ff7070'
79   );
80
81   progressBar(
82     player.exp,
83     expToLevel(player.level + 1),
84     'exp-bar',
85     '#5997f9'
86   );
87
88   ['strength', 'constitution', 'dexterity', 'intelligence','hp','exp'].forEach(s => {
89     $(`.${s}`).html(player[s]);
90   });
91
92   $('.maxHp').html(maxHp(player.constitution, player.level).toString());
93   $('.expToLevel').html(expToLevel(player.level + 1).toString());
94   $('.gold').html(player.gold.toLocaleString());
95
96   if(player.account_type === 'session') {
97     $('#signup-prompt').html(`<p>Hey there! It looks like you're using a SESSION account. This allows you to play with away, but the account is tied to your current device, and will be lost if you ever clear your cookies.</p><p>
98           <form id="signup">
99             <div class="form-group">
100               <label>Username:</label>
101               <input type="text" name="username">
102             </div>
103             <div class="form-group">
104               <label>Password:</label>
105               <input type="password" name="password">
106             </div>
107             <button type="submit" class="success">Create your Account</button>
108             <button type="button" id="login">Login To Existing Account</button>
109           </form></p>`).removeClass('hidden');
110   }
111
112   $('#extra-inventory-info').html(renderStatDetails());
113 }
114
115 socket.on('connect', () => {
116   console.log(`Connected: ${socket.id}`);
117 });
118
119 socket.on('ready', () => {
120   console.log('Server connection verified');
121   socket.emit('inventory');
122 });
123
124 socket.on('server-stats', (data: {onlinePlayers: number}) => {
125   $('#server-stats').html(`${data.onlinePlayers} players online`);
126 });
127
128 each(EventList, event => {
129   console.log(`Binding Event ${event.eventName}`);
130   if(event instanceof SocketEvent) {
131     socket.on(event.eventName, event.handler.bind(null, {
132       cache,
133       socket,
134       events
135     }));
136   }
137   else {
138     console.log('Skipped binding', event);
139   }
140 });
141
142 socket.on('calc:ap', (data: {ap: Record<EquipmentSlot, { currentAp: number, maxAp: number}>}) => {
143   const { ap } = data;
144   const html = `
145   <div>
146     ${icon('helm')}
147     ${generateProgressBar(ap.HEAD?.currentAp || 0, ap.HEAD?.maxAp || 0, '#7be67b')}
148   </div>
149   <div>
150     ${icon('arms')}
151     ${generateProgressBar(ap.ARMS?.currentAp || 0, ap.ARMS?.maxAp || 0, '#7be67b')}
152   </div>
153   <div>
154     ${icon('chest')}
155     ${generateProgressBar(ap.CHEST?.currentAp || 0, ap.CHEST?.maxAp || 0, '#7be67b')}
156   </div>
157   <div>
158     ${icon('legs')}
159     ${generateProgressBar(ap.LEGS?.currentAp || 0, ap.LEGS?.maxAp || 0, '#7be67b')}
160   </div>
161   `;
162   $('#ap-bar').html(html);
163 });
164
165 events.on('tab:skills', () => {
166   $('#skills').html('');
167   socket.emit('skills');
168 });
169
170 socket.on('skills', (data: {skills: Skill[]}) => {
171   let html = `<table id="skill-list">
172   ${data.skills.map((skill: Skill) => {
173     const definition = Skills.get(skill.id);
174     const percent = skill.exp / definition.expToLevel(skill.level + 1);
175     return `
176     <tr>
177     <td class="skill-level">${skill.level.toLocaleString()}</td>
178     <td class="skill-description" title="Total Exp: ${skill.exp.toLocaleString()}/${definition.expToLevel(skill.level + 1).toLocaleString()}">
179       <span class="skill-exp">${(percent * 100).toPrecision(2)}% to next level</span>
180       <b>${definition.display}</b>
181       <p>${definition.description}</p>
182     </td>
183     </tr>
184     `;
185   }).join("\n")}
186   </table>`;
187
188   $('#skills').html(html);
189 });
190
191 events.on('alert', (data: {type: string, text: string}) => {
192   let id = uuid();
193   $('#alerts').append(`<div class="alert ${data.type}" id="alert-${id}">${data.text}</div>`);
194
195   setTimeout(() => {
196     $(`#alert-${id}`).remove();
197   }, 3000);
198
199 });
200
201 socket.on('alert', data => {
202   events.emit('alert', [data]);
203 });
204
205 socket.on('init', (data: {version: string}) => {
206   $('#version').html(`v${data.version}`);
207 });
208
209 socket.on('authToken', (authToken: string) => {
210   console.log(`recv auth token ${authToken}`);
211   localStorage.setItem('authToken', authToken);
212 });
213
214
215 socket.on('player', (player: Player) => {
216   cache.set('player', player);
217   updatePlayer();
218 });
219
220
221 $('nav a').on('click', e => {
222   e.preventDefault();
223   e.stopPropagation();
224
225
226   const $tabContainer = $(`#${$(e.target).data('container')}`);
227
228   $tabContainer.find('.tab').removeClass('active');
229
230   $(`#${$(e.target).data('section')}`).addClass('active');
231
232   if(e.target.innerHTML !== 'Settings') {
233     $(e.target).closest('nav').find('a').removeClass('active');
234     $(e.target).addClass('active');
235   }
236
237   events.emit(`tab:${$(e.target).data('section')}`);
238 });
239
240 events.on('tab:inventory', () => {
241   socket.emit('inventory');
242 });
243
244 function renderEquipmentPlacementGrid(items: EquippedItemDetails[]) {
245   const placeholder = 'https://via.placeholder.com/64x64';
246   // @ts-ignore
247   const map: Record<EquipmentSlot, EquippedItemDetails> = items.filter(item => item.is_equipped).reduce((acc, item) => {
248     acc[item.equipment_slot] = item;
249     return acc;
250   }, {});
251
252   const html = `
253   <table id="character-equipment-placement">
254   <tr>
255     <td>
256     </td>
257     <td style="background-image: url('${placeholder}');" title="${map.HEAD ? map.HEAD.name : 'Empty'}">
258     ${map.HEAD ? map.HEAD.name : 'HEAD'}
259     </td>
260     <td style="background-image: url('${placeholder}');" title="${map.ARMS ? map.ARMS.name : 'Empty'}">
261     ${map.ARMS ? map.ARMS.name : 'ARMS'}
262     </td>
263   </tr>
264   <tr>
265     <td style="background-image: url('${placeholder}');" title="${map.LEFT_HAND ? map.LEFT_HAND.name : (map.TWO_HANDED ? map.TWO_HANDED.name : '')}">
266     ${map.LEFT_HAND ? map.LEFT_HAND.name : (map.TWO_HANDED ? map.TWO_HANDED.name : 'L_HAND')}
267     </td>
268     <td style="background-image: url('${placeholder}');" title="${map.CHEST ? map.CHEST.name : ''}">
269     ${map.CHEST ? map.CHEST.name : 'CHEST'}
270     </td>
271     <td style="background-image: url('${placeholder}');" title="${map.RIGHT_HAND ? map.RIGHT_HAND.name : (map.TWO_HANDED ? map.TWO_HANDED.name : '')}">
272     ${map.RIGHT_HAND ? map.RIGHT_HAND.name : (map.TWO_HANDED ? map.TWO_HANDED.name : 'R_HAND')}
273     </td>
274   </tr>
275   <tr>
276     <td>
277     </td>
278     <td style="background-image: url('${placeholder}');" title="${map.LEGS ? map.LEGS.name : ''}">
279     ${map.LEGS ? map.LEGS.name : 'LEGS'}
280     </td>
281     <td>
282     </td>
283   </tr>
284   </table>
285   `;
286
287   return html;
288 }
289 function statPointIncreaser(stat: StatDisplay) {
290   return `<button class="increase-stat emit-event" data-event="spend-stat-point" data-args="${stat.id}">+</button>`;
291 }
292 function renderStatDetails() {
293   const player: Player = cache.get('player');
294   let html = '<table id="stat-breakdown">';
295
296   StatDef.forEach(stat => {
297     html += `<tr>
298       <th>${stat.display}</th>
299       <td class="${stat.id}">
300         ${player[stat.id]}
301         ${player.stat_points ? statPointIncreaser(stat) : ''}
302       </td>
303     </tr>`;
304   });
305
306   html += `<tr><th>Stat Points</th><td class="stat_points">${player.stat_points}</td></tr>`;
307
308   html += '</table>';
309
310   return html;
311 }
312
313 $('body').on('click', 'nav.filter', e => {
314   e.preventDefault();
315   e.stopPropagation();
316
317   const $target = $(e.target);
318   const filter = $target.data('filter');
319
320   $('.filter-result').removeClass('active').addClass('hidden');
321   $(`#filter_${filter}`).addClass('active').removeClass('hidden');
322
323   $target.closest('nav').find('a').removeClass('active');
324   $target.addClass('active');
325
326   cache.set('active-inventory-section', filter);
327 });
328
329 function renderInventorySection(inventory: EquippedItemDetails[]): string {
330   return inventory.map(item => {
331     return renderInventoryItem(item, item => {
332       if(item.is_equipped) {
333         return `<button type="button" class="unequip-item error" data-id="${item.item_id}">Unequip</button>`;
334       }
335       else {
336         if(item.equipment_slot === 'ANY_HAND') {
337           return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="LEFT_HAND">Equip L</button>
338           <button type="button" class="equip-item" data-id="${item.item_id}" data-slot="RIGHT_HAND">Equip R</button>`;
339         }
340         else if(item.equipment_slot === 'LEFT_HAND') {
341           return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip Left</button>`;
342         }
343         else if(item.equipment_slot === 'RIGHT_HAND') {
344           return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip Right</button>`;
345         }
346         else if(item.equipment_slot === 'TWO_HANDED') {
347           return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip</button>`;
348         }
349         else {
350           return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip</button>`;
351         }
352       }
353     })
354   }).join("\n");
355 }
356
357 socket.on('inventory', (data: {inventory: EquippedItemDetails[]}) => {
358   const player: Player = cache.get('player');
359   const activeSection = cache.get('active-inventory-section') || 'ARMOUR';
360   // split the inventory into sections!
361   const sectionedInventory: {ARMOUR: EquippedItemDetails[], WEAPON: EquippedItemDetails[], SPELL: EquippedItemDetails[]} = {
362     ARMOUR: [],
363     WEAPON: [],
364     SPELL: []
365   };
366
367   data.inventory.forEach(item => {
368     sectionedInventory[item.type].push(item);
369   });
370
371   const html = `
372   <div id="inventory-page">
373     <div id="character-summary">
374     ${renderEquipmentPlacementGrid(data.inventory)}
375       <div id="extra-inventory-info">
376         ${renderStatDetails()}
377       </div>
378     </div>
379     <div id="inventory-section">
380       <nav class="filter">
381         <a href="#" data-filter="ARMOUR" class="${activeSection === 'ARMOUR' ? 'active': ''}">Armour</a>
382         <a href="#" data-filter="WEAPON" class="${activeSection === 'WEAPON' ? 'active': ''}">Weapons</a>
383         <a href="#" data-filter="SPELL" class="${activeSection === 'SPELL' ? 'active': ''}">Spells</a>
384       </nav>
385       <div class="inventory-listing">
386         <div class="filter-result ${activeSection === 'ARMOUR' ? 'active' : 'hidden'}" id="filter_ARMOUR">
387           ${renderInventorySection(sectionedInventory.ARMOUR)}
388         </div>
389         <div class="filter-result ${activeSection === 'WEAPON' ? 'active' : 'hidden'}" id="filter_WEAPON">
390           ${renderInventorySection(sectionedInventory.WEAPON)}
391         </div>
392         <div class="filter-result ${activeSection === 'SPELL' ? 'active' : 'hidden'}" id="filter_SPELL">
393           ${renderInventorySection(sectionedInventory.SPELL)}
394         </div>
395       </div>
396     </div>
397   </div>
398   `;
399   $('#inventory').html(html);
400 });
401
402 $('body').on('click', '.equip-item', e => {
403   e.preventDefault();
404   e.stopPropagation();
405
406   const $target = $(e.target);
407   const id = $target.data('id');
408   const slot = $target.data('slot');
409
410   socket.emit('equip', {id, slot});
411 });
412
413 $('body').on('click', '.unequip-item', e => {
414   e.preventDefault();
415   e.stopPropagation();
416
417   socket.emit('unequip', { id: $(e.target).data('id') });
418 });
419
420 function setMapBackground(city_id: number) {
421   $('#explore').css({
422     '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")`
423   });
424 }
425
426 events.on('renderMap', async function renderMap(data: { city: City, locations: Location[], paths: Path[]}) {
427   if(!data || cache.has('currentMapHTML')) {
428     $('#map').html(cache.get('currentMapHTML'));
429     return;
430   }
431
432   if(!data) {
433     console.error('oh no.. this got triggered without any city data');
434   }
435
436   setMapBackground(data.city.id);
437
438   const servicesParsed: Record<LocationType, string[]> = {
439     'SERVICES': [],
440     'STORES': [],
441     'EXPLORE': []
442   };
443
444   data.locations.forEach(l => {
445     servicesParsed[l.type].push(`<a href="#" class="city-emit-event" data-event="${l.event_name}" data-args="${l.id}">${l.name}</a>`);
446   });
447
448   let html = `<h1>${data.city.name}</h1>
449   <div class="city-details">`;
450
451   if(servicesParsed.SERVICES.length) {
452     html += `<div><h3>Services</h3>${servicesParsed.SERVICES.join("<br>")}</div>`
453   }
454   if(servicesParsed.STORES.length) {
455     html += `<div><h3>Stores</h3>${servicesParsed.STORES.join("<br>")}</div>`
456   }
457   if(servicesParsed.EXPLORE.length) {
458     html += `<div><h3>Explore</h3>${servicesParsed.EXPLORE.join("<br>")}</div>`
459   }
460   html += `
461     <div>
462       <h3>Travel</h3>
463       ${data.paths.map(path => {
464         return `<a href="#" class="city-emit-event" data-event="city:travel" data-args="${path.ending_city}">${path.ending_city_name}</a>`
465       }).join("<br>")}
466     </div>
467   </div>
468   `;
469
470   cache.set('currentMapHTML', html);
471
472   $('#map').html(html);
473 });
474
475 function renderRequirement(name: string, val: number | string, currentVal?: number): string {
476   let colorIndicator = '';
477   if(currentVal) {
478     colorIndicator = currentVal >= val ? 'success' : 'error';
479   }
480   return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${colorIndicator}">${val.toLocaleString()}</span>`;
481 }
482
483 function renderStatBoost(name: string, val: number | string): string {
484   let valSign: string = '';
485   if(typeof val === 'number') {
486     valSign = val > 0 ? '+' : '-';
487   }
488   return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${typeof val === 'number' && val > 0 ? "success": "error"}">${valSign}${val}</span>`;
489 }
490
491 function renderInventoryItem(item: EquippedItemDetails , action: (item: EquippedItemDetails) => string): string {
492     return `<div class="store-list">
493     <div>
494       <img src="https://via.placeholder.com/64x64">
495     </div>
496     <div class="details">
497       <div class="name">${item.name}</div>
498       <div class="requirements">
499       ${item.requirements.level ? renderRequirement('LVL', item.requirements.level): ''}
500       ${item.requirements.strength ? renderRequirement('STR', item.requirements.strength): ''}
501       ${item.requirements.constitution ? renderRequirement('CON', item.requirements.constitution): ''}
502       ${item.requirements.dexterity ? renderRequirement('DEX', item.requirements.dexterity): ''}
503       ${item.requirements.intelligence ? renderRequirement('INT', item.requirements.intelligence): ''}
504       ${renderRequirement('PRF', item.profession)}
505       </div>
506       <div class="stat-mods">
507       ${item.boosts.strength ? renderStatBoost('STR', item.boosts.strength) : ''}
508       ${item.boosts.constitution ? renderStatBoost('CON', item.boosts.constitution) : ''}
509       ${item.boosts.dexterity ? renderStatBoost('DEX', item.boosts.dexterity) : ''}
510       ${item.boosts.intelligence ? renderStatBoost('INT', item.boosts.intelligence) : ''}
511       ${item.boosts.damage ? renderStatBoost('DMG', item.boosts.damage) : ''}
512       ${item.boosts.damage_mitigation ? renderStatBoost('MIT', item.boosts.damage_mitigation)+'%' : ''}
513       ${['WEAPON','SPELL'].includes(item.type) ? '': generateProgressBar(item.currentAp, item.maxAp, '#7be67b')}
514       </div>
515       ${item.hasOwnProperty('id') ? `<div>${item.cost.toLocaleString()}G</div>` : ''}
516     </div>
517     <div class="store-actions">
518       ${action(item)}
519     </div>
520     </div>`;
521 }
522
523 function renderShopItem(item: ShopItem, action: (item: ShopItem) => string): string {
524   const player: Player = cache.get('player');
525     return `<div class="store-list">
526     <div>
527       <img src="https://via.placeholder.com/64x64">
528     </div>
529     <div class="details">
530       <div class="name">${item.name}${item.equipment_slot === 'TWO_HANDED' ? ' (2H)': ''}</div>
531       <div class="requirements">
532       ${item.requirements.level ? renderRequirement('LVL', item.requirements.level, player.level): ''}
533       ${item.requirements.strength ? renderRequirement('STR', item.requirements.strength, player.strength): ''}
534       ${item.requirements.constitution ? renderRequirement('CON', item.requirements.constitution, player.constitution): ''}
535       ${item.requirements.dexterity ? renderRequirement('DEX', item.requirements.dexterity, player.dexterity): ''}
536       ${item.requirements.intelligence ? renderRequirement('INT', item.requirements.intelligence, player.intelligence): ''}
537       ${renderRequirement('PRF', item.profession)}
538       </div>
539       <div class="stat-mods">
540       ${item.boosts.strength ? renderStatBoost('STR', item.boosts.strength) : ''}
541       ${item.boosts.constitution ? renderStatBoost('CON', item.boosts.constitution) : ''}
542       ${item.boosts.dexterity ? renderStatBoost('DEX', item.boosts.dexterity) : ''}
543       ${item.boosts.intelligence ? renderStatBoost('INT', item.boosts.intelligence) : ''}
544       ${item.boosts.damage ? renderStatBoost(item.affectedSkills.includes('restoration_magic') ? 'HP' : 'DMG', item.boosts.damage) : ''}
545       ${item.boosts.damage_mitigation ? renderStatBoost('MIT', item.boosts.damage_mitigation)+'%' : ''}
546       ${['WEAPON','SPELL'].includes(item.type) ? '' : renderStatBoost('AP', item.maxAp)}
547       </div>
548       ${item.hasOwnProperty('id') ? `<div>${item.cost.toLocaleString()}G</div>` : ''}
549     </div>
550     <div class="store-actions">
551       ${action(item)}
552     </div>
553     </div>`;
554
555 }
556
557 socket.on('city:stores', (data: ShopItem[]) => {
558   let html = `<div class="shop-inventory-listing">
559   ${data.map(item => {
560     return renderShopItem(item, i => {
561       const id = `data-id="${i.id}"`;
562       return `<button type="button" class="purchase-item" ${id} data-type="${i.type}" data-equipment-slot="${i.equipment_slot}" data-cost="${i.cost}">Buy</button>`
563     })
564   }).join("\n")}
565   </div>
566   `;
567
568   $('#map').html(html);
569
570 });
571
572 $('body').on('click', '.purchase-item', e => {
573   e.preventDefault();
574   e.stopPropagation();
575
576   const player = cache.get('player');
577   const cost = parseInt($(e.target).data('cost'));
578
579
580   const id = $(e.target).data('id');
581   if(player.gold < cost) {
582     events.emit('alert', [{
583       type: 'error',
584       text: 'You don\'t have enough gold!'
585     }]);
586     return;
587   }
588   else {
589     const type = $(e.target).data('type');
590     socket.emit('purchase', {
591       id
592     });
593   }
594 });
595
596 $('body').on('click', '.emit-event', e => {
597   const $el = $(e.target);
598
599   const eventName = $el.data('event');
600   const args = $el.data('args');
601
602   console.log(`Sending event ${eventName}`, { args });
603   socket.emit(eventName, { args });
604 });
605
606 $('body').on('click', '.city-emit-event', e => {
607   const $el = $(e.target);
608
609   const eventName = $el.data('event');
610   const args = $el.data('args');
611
612   console.log(`Sending event ${eventName}`, { args });
613   socket.emit(eventName, { args });
614 });
615
616 $('body').on('click', '.emit-event-internal', e => {
617   const $el = $(e.target);
618
619   const eventName = $el.data('event');
620   const args: string[] = $el.data('args')?.toString().split('|');
621
622   console.log(`Trigger internal event [${eventName}]`);
623   events.emit(eventName, args);
624 });
625
626
627 socket.on('explore:fights', (monsters: MonsterForList[]) => {
628   const lastSelectedMonster = cache.get('last-selected-monster');
629   if(monsters.length) {
630     let sel = `<form id="fight-selector"><select id="monster-selector">
631     ${monsters.map(monster => {
632       return `<option value="${monster.id}" ${lastSelectedMonster == monster.id ? 'selected': ''}>${monster.name}</option>`;
633     }).join("\n")}
634     </select> <button type="submit">Fight</button></option>`;
635
636     $('#map').html(sel);
637   }
638 });
639
640 events.on('tab:explore', async () => {
641   const res = await http.get('/state');
642   if(cache.get('player').hp <= 0 || (!res.fight && !res.travel)) {
643     // get the details of the city
644     // render the map!
645     let data: {city: City, locations: Location[], paths: Path[]};
646     if(!cache.has(('currentMapHTML'))) {
647       data = await http.get(`/city/${cache.get('player').city_id}`);
648     }
649     events.emit('renderMap', [data, res.travel]);
650
651   }
652   else if(res.fight) {
653     setMapBackground(res.closestTown);
654     $('#map').html(renderFight(res.fight));
655   }
656   else if(res.travel) {
657     // render TRAVEL
658     events.emit('renderTravel', [res.travel]);
659   }
660 });
661
662 function renderTravel(data: TravelDTO) {
663   setMapBackground(data.closestTown);
664   let html = `<button class="emit-event" data-event="travel:step">Keep Walking</button>`;
665
666
667   if(data.things.length) {
668     // ok you found something, for now we only support 
669     // monsters, but eventually that will change
670     html += `<button class="emit-event-internal" data-event="startFight" data-args="${data.things[0].id}|travel">Fight</button>`;
671     html += `<p>You see a ${data.things[0].name}.</p>`;
672
673   }
674   else if(data.walkingText) {
675     html += `<p>${data.walkingText}</p>`;
676   }
677
678   $('#map').html(html);
679 }
680
681 events.on('renderTravel', renderTravel);
682
683 function renderFight(monster: MonsterForFight | Fight) {
684   const hpPercent = Math.floor((monster.hp / monster.maxHp) * 100);
685
686   let html = `<div id="fight-container">
687     <div id="defender-info">
688       <div class="avatar-container">
689         <img id="avatar" src="https://via.placeholder.com/64x64">
690       </div>
691       <div id="defender-stat-bars">
692         <div id="defender-name">${monster.name}</div>
693         <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>
694       </div>
695     </div>
696     <div id="fight-actions">
697       <select id="fight-target">
698         <option value="head">Head</option>
699         <option value="body">Body</option>
700         <option value="arms">Arms</option>
701         <option value="legs">Legs</option>
702       </select>
703       <button type="button" class="fight-action" data-action="attack">Attack</button>
704       <button type="button" class="fight-action" data-action="cast">Cast</button>
705       <button type="button" class="fight-action" data-action="flee">Flee</button>
706     </div>
707     <div id="fight-results"></div>
708   </div>`;
709
710   return html;
711 }
712
713 socket.on('updatePlayer', (player: Player) => {
714   cache.set('player', player);
715   updatePlayer();
716 });
717
718 socket.on('fight-over', (data: {roundData: FightRound, monsters: MonsterForFight[]}) => {
719   const { roundData, monsters } = data;
720   cache.set('player', roundData.player);
721   updatePlayer();
722
723   $('#map').html(renderFight(roundData.monster));
724
725   let html: string[] = roundData.roundDetails.map(d => `<div>${d}</div>`);
726
727   if(roundData.winner === 'player') {
728     html.push(`<div>You defeated the ${roundData.monster.name}!</div>`);
729     if(roundData.rewards.gold) {
730       html.push(`<div>You gained ${roundData.rewards.gold} gold`);
731     }
732     if(roundData.rewards.exp) {
733       html.push(`<div>You gained ${roundData.rewards.exp} exp`);
734     }
735     if(roundData.rewards.levelIncrease) {
736       html.push(`<div>You gained a level! ${roundData.player.level}`);
737     }
738   }
739
740   if(roundData.player.hp === 0) {
741     // prompt to return to town and don't let them do anything
742     html.push(`<p>You were killed...</p>`);
743     html.push('<p><button class="emit-event-internal" data-event="tab:explore" data-args="">Back to Town</button></p>');
744   }
745   else {
746     switch(roundData.fightTrigger) {
747       case 'explore':
748         if(monsters.length) {
749           const lastSelectedMonster = cache.get('last-selected-monster');
750           // put this above the fight details
751           html.unshift(`<h2>Fight Again</h2><form id="fight-selector"><select id="monster-selector">
752             ${monsters.map(monster => {
753               return `<option value="${monster.id}" ${lastSelectedMonster == monster.id ? 'selected': ''}>${monster.name}</option>`;
754             }).join("\n")}
755             </select> <button type="submit">Fight</button></option>
756           </select></form><hr>`);
757         }
758       break;
759       case 'travel':
760         html.push(`<p><button class="emit-event" data-event="travel:step">Keep Walking</button></p>`);
761       break;
762       default:
763         console.error(`Unknown fight trigger [${roundData.fightTrigger}]`, roundData);
764       break;
765     }
766   }
767
768
769
770   $('#fight-results').html(html.join("\n"));
771   $('#fight-actions').html('');
772
773 });
774
775 socket.on('fight-round', (data: FightRound) => {
776   $('.fight-action').prop('disabled', false);
777   cache.set('player', data.player);
778   updatePlayer();
779
780   $('#map').html(renderFight(data.monster));
781
782   $('#fight-results').html(data.roundDetails.map(d => `<div>${d}</div>`).join("\n"));
783 });
784
785 async function startFight(monsterId: string, fightTrigger: FightTrigger = 'explore') {
786   // check your HP first
787   if(cache.get('player').hp <= 0) {
788     events.emit('alert', [{
789       type: 'error',
790       text: 'You don\'t have enough HP to go looking for a fight'
791     }]);
792     return;
793   }
794
795   cache.set('last-selected-monster', monsterId);
796
797   try {
798     const monster: MonsterForFight = await http.post('/fight', {
799       monsterId,
800       fightTrigger
801     });
802
803     $('#map').html(renderFight(monster));
804   }
805   catch(e) {
806     events.emit('alert', [{
807       type: 'error',
808       text: 'Sorry, you can\'t start that fight'
809     }]);
810   }
811 }
812
813 events.on('startFight', startFight);
814
815 $('body').on('submit', '#fight-selector', async e => {
816   e.preventDefault();
817   e.stopPropagation();
818
819   const monsterId = $('#monster-selector option:selected').val();
820
821   if(monsterId) {
822     startFight(monsterId.toString());
823   }
824   else {
825     console.error(`Invalid monster id [${monsterId}]`);
826   }
827
828 });
829
830 $('body').on('click', '.fight-action', e => {
831   e.preventDefault();
832   e.stopPropagation();
833
834   const action = $(e.target).data('action');
835   const target = $('#fight-target option:selected').val();
836   $('.fight-action').prop('disabled', true);
837
838   socket.emit('fight', { action, target });
839 });
840
841 $('body').on('submit', '#signup', async e => {
842   e.preventDefault();
843   e.stopPropagation();
844
845   const data: Record<string, string> = {}; 
846
847   $(e.target).serializeArray().forEach(v => data[v.name] = v.value);
848
849   const res = await http.post('/signup', data);
850
851   if(res.error) {
852     events.emit('alert', [{
853       type: 'error',
854       text: res.error
855     }]);
856   }
857   else {
858     cache.set('player', res.player);
859     updatePlayer();
860     $('#signup-prompt').remove();
861   }
862
863 });
864
865 $('body').on('click', '#login', async e => {
866   e.preventDefault();
867   e.stopPropagation();
868
869   const data: Record<string, string> = {}; 
870
871   $(e.target).closest('form').serializeArray().forEach(v => data[v.name] = v.value);
872
873   if(data.username && data.password) {
874     const res = await http.post('/login', data);
875     if(res.error) {
876       events.emit('alert', [{type: 'error', text: res.error}]);
877     }
878     else {
879       localStorage.setItem('authToken', res.player.id);
880       window.location.reload();
881     }
882   }
883 });
884
885 $('body').on('click', '#logout', async e => {
886   e.preventDefault();
887   e.stopPropagation();
888
889   let logout = false;
890
891   const player = cache.get('player');
892   if(/^Player[\d]+$/.test(player.username)) {
893     const clear = confirm('Are you sure? You will not be able to retrieve this character unless you set up an email/password');
894     if(clear) {
895       logout = true;
896     }
897     else {
898       logout = false;
899     }
900   }
901   else {
902     logout = true;
903   }
904
905   if(logout) {
906     socket.emit('logout');
907     localStorage.clear();
908     window.location.reload();
909   }
910
911 });