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