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