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