chore(release): 0.2.4
[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 { capitalize, 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   const cacheKey = `active-${$(e.target).closest('nav').attr('id')}`;
361
362   cache.set(cacheKey, filter);
363 });
364
365 function renderInventorySection(inventory: EquippedItemDetails[]): string {
366   return inventory.map(item => {
367     return renderInventoryItem(item, item => {
368       if(item.is_equipped) {
369         return `<button type="button" class="unequip-item error" data-id="${item.item_id}">Unequip</button>`;
370       }
371       else {
372         if(item.equipment_slot === 'ANY_HAND') {
373           return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="LEFT_HAND">Equip L</button>
374           <button type="button" class="equip-item" data-id="${item.item_id}" data-slot="RIGHT_HAND">Equip R</button>`;
375         }
376         else if(item.equipment_slot === 'LEFT_HAND') {
377           return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip Left</button>`;
378         }
379         else if(item.equipment_slot === 'RIGHT_HAND') {
380           return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip Right</button>`;
381         }
382         else if(item.equipment_slot === 'TWO_HANDED') {
383           return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip</button>`;
384         }
385         else {
386           return `<button type="button" class="equip-item" data-id="${item.item_id}" data-slot="${item.equipment_slot}">Equip</button>`;
387         }
388       }
389     })
390   }).join("\n");
391 }
392
393 socket.on('inventory', (data: {inventory: EquippedItemDetails[]}) => {
394   const player: Player = cache.get('player');
395   const activeSection = cache.get('active-inventory-section') || 'ARMOUR';
396   // split the inventory into sections!
397   const sectionedInventory: {ARMOUR: EquippedItemDetails[], WEAPON: EquippedItemDetails[], SPELL: EquippedItemDetails[]} = {
398     ARMOUR: [],
399     WEAPON: [],
400     SPELL: []
401   };
402
403   data.inventory.forEach(item => {
404     sectionedInventory[item.type].push(item);
405   });
406
407   const html = `
408   <div id="inventory-page">
409     <div id="character-summary">
410     ${renderEquipmentPlacementGrid(data.inventory)}
411       <div id="extra-inventory-info">
412         ${renderStatDetails()}
413       </div>
414     </div>
415     <div id="inventory-section" class="filter-container">
416       <nav class="filter" id="inventory-section">
417         <a href="#" data-filter="ARMOUR" class="${activeSection === 'ARMOUR' ? 'active': ''}">Armour</a>
418         <a href="#" data-filter="WEAPON" class="${activeSection === 'WEAPON' ? 'active': ''}">Weapons</a>
419         <a href="#" data-filter="SPELL" class="${activeSection === 'SPELL' ? 'active': ''}">Spells</a>
420       </nav>
421       <div class="inventory-listing listing">
422         <div class="filter-result ${activeSection === 'ARMOUR' ? 'active' : 'hidden'}" id="filter_ARMOUR">
423           ${renderInventorySection(sectionedInventory.ARMOUR)}
424         </div>
425         <div class="filter-result ${activeSection === 'WEAPON' ? 'active' : 'hidden'}" id="filter_WEAPON">
426           ${renderInventorySection(sectionedInventory.WEAPON)}
427         </div>
428         <div class="filter-result ${activeSection === 'SPELL' ? 'active' : 'hidden'}" id="filter_SPELL">
429           ${renderInventorySection(sectionedInventory.SPELL)}
430         </div>
431       </div>
432     </div>
433   </div>
434   `;
435   $('#inventory').html(html);
436 });
437
438 $('body').on('click', '.equip-item', e => {
439   e.preventDefault();
440   e.stopPropagation();
441
442   const $target = $(e.target);
443   const id = $target.data('id');
444   const slot = $target.data('slot');
445
446   socket.emit('equip', {id, slot});
447 });
448
449 $('body').on('click', '.unequip-item', e => {
450   e.preventDefault();
451   e.stopPropagation();
452
453   socket.emit('unequip', { id: $(e.target).data('id') });
454 });
455
456 function setMapBackground(city_id: number) {
457   $('#explore').css({
458     '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")`
459   });
460 }
461
462 events.on('renderMap', async function renderMap(data: { city: City, locations: Location[], paths: Path[]}) {
463   if(!data && cache.has('currentMapHTML')) {
464     $('#map').html(cache.get('currentMapHTML'));
465     return;
466   }
467
468   if(!data) {
469     console.error('oh no.. this got triggered without any city data');
470   }
471
472   setMapBackground(data.city.id);
473
474   const servicesParsed: Record<LocationType, string[]> = {
475     'SERVICES': [],
476     'STORES': [],
477     'EXPLORE': []
478   };
479
480   data.locations.forEach(l => {
481     servicesParsed[l.type].push(`<a href="#" class="city-emit-event" data-event="${l.event_name}" data-args="${l.id}">${l.name}</a>`);
482   });
483
484   let html = `<h1>${data.city.name}</h1>
485   <div class="city-details">`;
486
487   if(servicesParsed.SERVICES.length) {
488     html += `<div><h3>Services</h3>${servicesParsed.SERVICES.join("<br>")}</div>`
489   }
490   if(servicesParsed.STORES.length) {
491     html += `<div><h3>Stores</h3>${servicesParsed.STORES.join("<br>")}</div>`
492   }
493   if(servicesParsed.EXPLORE.length) {
494     html += `<div><h3>Explore</h3>${servicesParsed.EXPLORE.join("<br>")}</div>`
495   }
496   html += `
497     <div>
498       <h3>Travel</h3>
499       ${data.paths.map(path => {
500         return `<a href="#" class="city-emit-event" data-event="city:travel" data-args="${path.ending_city}">${path.ending_city_name}</a>`
501       }).join("<br>")}
502     </div>
503   </div>
504   `;
505
506   cache.set('currentMapHTML', html);
507
508   $('#map').html(html);
509 });
510
511 function renderRequirement(name: string, val: number | string, currentVal?: number): string {
512   let colorIndicator = '';
513   if(currentVal) {
514     colorIndicator = currentVal >= val ? 'success' : 'error';
515   }
516   return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${colorIndicator}">${val.toLocaleString()}</span>`;
517 }
518
519 function renderStatBoost(name: string, val: number | string): string {
520   let valSign: string = '';
521   if(typeof val === 'number') {
522     valSign = val > 0 ? '+' : '-';
523   }
524   return `<span class="requirement-title">${name}</span>: <span class="requirement-value ${typeof val === 'number' ? (val > 0 ? "success": "error") : ""}">${valSign}${val}</span>`;
525 }
526
527 function renderInventoryItem(item: EquippedItemDetails , action: (item: EquippedItemDetails) => string): string {
528     return `<div class="store-list">
529     <div>
530       <img src="https://via.placeholder.com/64x64">
531     </div>
532     <div class="details">
533       <div class="name">${item.name}</div>
534       <div class="requirements">
535       ${item.requirements.level ? renderRequirement('LVL', item.requirements.level): ''}
536       ${item.requirements.strength ? renderRequirement('STR', item.requirements.strength): ''}
537       ${item.requirements.constitution ? renderRequirement('CON', item.requirements.constitution): ''}
538       ${item.requirements.dexterity ? renderRequirement('DEX', item.requirements.dexterity): ''}
539       ${item.requirements.intelligence ? renderRequirement('INT', item.requirements.intelligence): ''}
540       ${renderRequirement('PRF', item.profession)}
541       </div>
542       <div class="stat-mods">
543       ${item.boosts.strength ? renderStatBoost('STR', item.boosts.strength) : ''}
544       ${item.boosts.constitution ? renderStatBoost('CON', item.boosts.constitution) : ''}
545       ${item.boosts.dexterity ? renderStatBoost('DEX', item.boosts.dexterity) : ''}
546       ${item.boosts.intelligence ? renderStatBoost('INT', item.boosts.intelligence) : ''}
547       ${item.boosts.damage ? renderStatBoost('DMG', item.boosts.damage) : ''}
548       ${item.boosts.damage_mitigation ? renderStatBoost('MIT', item.boosts.damage_mitigation.toString())+'%' : ''}
549       ${['WEAPON','SPELL'].includes(item.type) ? '': generateProgressBar(item.currentAp, item.maxAp, '#7be67b')}
550       </div>
551       ${item.hasOwnProperty('id') ? `<div>${item.cost.toLocaleString()}G</div>` : ''}
552     </div>
553     <div class="store-actions">
554       ${action(item)}
555     </div>
556     </div>`;
557 }
558
559 function renderShopItem(item: ShopItem, action: (item: ShopItem) => string): string {
560   const player: Player = cache.get('player');
561     return `<div class="store-list">
562     <div>
563       <img src="https://via.placeholder.com/64x64">
564     </div>
565     <div class="details">
566       <div class="name">${item.name}${item.equipment_slot === 'TWO_HANDED' ? ' (2H)': ''}</div>
567       <div class="requirements">
568       ${item.requirements.level ? renderRequirement('LVL', item.requirements.level, player.level): ''}
569       ${item.requirements.strength ? renderRequirement('STR', item.requirements.strength, player.strength): ''}
570       ${item.requirements.constitution ? renderRequirement('CON', item.requirements.constitution, player.constitution): ''}
571       ${item.requirements.dexterity ? renderRequirement('DEX', item.requirements.dexterity, player.dexterity): ''}
572       ${item.requirements.intelligence ? renderRequirement('INT', item.requirements.intelligence, player.intelligence): ''}
573       ${renderRequirement('PRF', item.profession)}
574       </div>
575       <div class="stat-mods">
576       ${item.boosts.strength ? renderStatBoost('STR', item.boosts.strength) : ''}
577       ${item.boosts.constitution ? renderStatBoost('CON', item.boosts.constitution) : ''}
578       ${item.boosts.dexterity ? renderStatBoost('DEX', item.boosts.dexterity) : ''}
579       ${item.boosts.intelligence ? renderStatBoost('INT', item.boosts.intelligence) : ''}
580       ${item.boosts.damage ? renderStatBoost(item.affectedSkills.includes('restoration_magic') ? 'HP' : 'DMG', item.boosts.damage) : ''}
581       ${item.boosts.damage_mitigation ? renderStatBoost('MIT', item.boosts.damage_mitigation.toString())+'%' : ''}
582       ${['WEAPON','SPELL'].includes(item.type) ? '' : renderStatBoost('AP', item.maxAp.toString())}
583       </div>
584       ${item.hasOwnProperty('id') ? `<div>${item.cost.toLocaleString()}G</div>` : ''}
585     </div>
586     <div class="store-actions">
587       ${action(item)}
588     </div>
589     </div>`;
590
591 }
592
593 socket.on('city:stores', (data: ShopItem[]) => {
594   const listing: Record<string, string> = {};
595   const listingTypes = new Set<string>();
596   data.forEach(item => {
597     if(item.type === 'ARMOUR') {
598       listingTypes.add(item.equipment_slot);
599
600       if(!listing[item.equipment_slot]) {
601         listing[item.equipment_slot] = '';
602       }
603       
604       listing[item.equipment_slot] += renderShopItem(item, i => {
605         const id = `data-id="${i.id}"`;
606         return `<button type="button" class="purchase-item" ${id} data-type="${i.type}" data-equipment-slot="${i.equipment_slot}" data-cost="${i.cost}">Buy</button>`
607       });
608     }
609     else {
610       listingTypes.add(item.type);
611       if(!listing[item.type]) {
612         listing[item.type] = '';
613       }
614       
615       listing[item.type] += renderShopItem(item, i => {
616         const id = `data-id="${i.id}"`;
617         return `<button type="button" class="purchase-item" ${id} data-type="${i.type}" data-equipment-slot="${i.equipment_slot}" data-cost="${i.cost}">Buy</button>`
618       });
619     }
620   });
621
622   const sections = Object.keys(listing);
623   let activeSection = cache.get('active-shop-inventory-listing');
624
625   if(!sections.includes(activeSection)) {
626     activeSection = sections[0];
627   }
628
629   const nav: string[] = [];
630   const finalListing: string[] = [];
631
632   listingTypes.forEach(type => {
633     nav.push(`<a href="#" data-filter="${type}" class="${activeSection === type ? 'active': ''}">${capitalize(type)}</a>`);
634     finalListing.push(`<div class="filter-result ${activeSection === type ? 'active': 'hidden'}" id="filter_${type}">${listing[type]}</div>`);
635   });
636
637   let html = `<div class="shop-inventory-listing filter-container">
638     <nav class="filter" id="shop-inventory-listing">${nav.join(" ")}</nav><div class="inventory-listing listing">
639       ${finalListing.join("\n")}
640     </div>
641   </div>`;
642
643   $('#map').html(html);
644
645 });
646
647 $('body').on('click', '.purchase-item', e => {
648   e.preventDefault();
649   e.stopPropagation();
650
651   const player = cache.get('player');
652   const cost = parseInt($(e.target).data('cost'));
653
654
655   const id = $(e.target).data('id');
656   if(player.gold < cost) {
657     events.emit('alert', [{
658       type: 'error',
659       text: 'You don\'t have enough gold!'
660     }]);
661     return;
662   }
663   else {
664     const type = $(e.target).data('type');
665     socket.emit('purchase', {
666       id
667     });
668   }
669 });
670
671 $('body').on('click', '.emit-event', e => {
672   const $el = $(e.target);
673
674   const eventName = $el.data('event');
675   const args = $el.data('args');
676
677   const rawBlock = $el.data('block');
678
679   if(rawBlock) {
680     const block = parseInt(rawBlock) || 0;
681
682     if(block > Date.now()) {
683       Alert('Sorry, clicked too quick');
684       return;
685     }
686   }
687
688   console.log(`Sending event ${eventName}`, { args });
689   socket.emit(eventName, { args });
690 });
691
692 $('body').on('click', '.city-emit-event', e => {
693   const $el = $(e.target);
694
695   const eventName = $el.data('event');
696   const args = $el.data('args');
697
698   console.log(`Sending event ${eventName}`, { args });
699   socket.emit(eventName, { args });
700 });
701
702 $('body').on('click', '.emit-event-internal', e => {
703   const $el = $(e.target);
704
705   const eventName = $el.data('event');
706   const args: string[] = $el.data('args')?.toString().split('|');
707
708   console.log(`Trigger internal event [${eventName}]`);
709   events.emit(eventName, args);
710 });
711
712
713 socket.on('explore:fights', (monsters: MonsterForList[]) => {
714   const lastSelectedMonster = cache.get('last-selected-monster');
715   if(monsters.length) {
716     let sel = `<form id="fight-selector"><select id="monster-selector">
717     ${monsters.map(monster => {
718       return `<option value="${monster.id}" ${lastSelectedMonster == monster.id ? 'selected': ''}>${monster.name}</option>`;
719     }).join("\n")}
720     </select> <button type="submit">Fight</button></option>`;
721
722     $('#map').html(sel);
723   }
724 });
725
726 events.on('tab:explore', async () => {
727   const state = cache.get('state');
728   if(cache.get('player').hp <= 0 || (!state.fight && !state.travel)) {
729     // get the details of the city
730     // render the map!
731     let data: {city: City, locations: Location[], paths: Path[]};
732     if(!cache.has(('currentMapHTML')) || cache.get('player').hp <= 0) {
733       data = await http.get(`/city/${cache.get('player').city_id}`);
734     }
735     events.emit('renderMap', [data, state.travel]);
736
737   }
738   else if(state.fight) {
739     setMapBackground(state.closestTown);
740     $('#map').html(renderFight(state.fight));
741   }
742   else if(state.travel) {
743     // render TRAVEL
744     events.emit('renderTravel', [state.travel]);
745   }
746 });
747
748 function updateStepButton() {
749   const raw = $('#keep-walking').data('block');
750   const time = parseInt(raw) || 0;
751
752   if(time > 0) {
753     const wait = Math.ceil((time - Date.now())/ 1000);
754     if(wait > 0) {
755       $('#keep-walking').prop('disabled', true).html(`Keep walking (${wait}s)`);
756       setTimeout(updateStepButton, 300);
757       return;
758     }
759   }
760
761   $('#keep-walking').prop('disabled', false).html(`Keep walking`);
762 }
763
764 function renderTravel(data: TravelDTO) {
765   setMapBackground(data.closestTown);
766
767   let promptText = data.walkingText;
768   const blockTime = data.nextAction || 0;
769   let html = `<div id="travelling">`;
770
771   html += '<div id="travelling-actions">';
772   html += `<button class="emit-event" id="keep-walking" data-event="travel:step" data-block="${blockTime}">Keep Walking</button>`;
773
774   if(data.things.length) {
775     // ok you found something, for now we only support 
776     // monsters, but eventually that will change
777     promptText = `You see a ${data.things[0].name}`;
778     html += `<button class="emit-event-internal" data-event="startFight" data-args="${data.things[0].id}|travel">Fight</button>`;
779   }
780
781   // end #travelling-actions
782   html += '</div>';
783   html += `<p>${promptText}</p>`;
784
785   // end #travelling div
786   html += '</div>';
787
788   $('#map').html(html);
789
790   if(blockTime) {
791     updateStepButton();
792   }
793 }
794
795 events.on('renderTravel', renderTravel);
796
797 function renderFight(monster: MonsterForFight | Fight) {
798   const hpPercent = Math.floor((monster.hp / monster.maxHp) * 100);
799
800   let html = `<div id="fight-container">
801     <div id="defender-info">
802       <div class="avatar-container">
803         <img id="avatar" src="https://via.placeholder.com/64x64">
804       </div>
805       <div id="defender-stat-bars">
806         <div id="defender-name">${monster.name}</div>
807         <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>
808       </div>
809     </div>
810     <div id="fight-actions">
811       <select id="fight-target">
812         <option value="head">Head</option>
813         <option value="body">Body</option>
814         <option value="arms">Arms</option>
815         <option value="legs">Legs</option>
816       </select>
817       <button type="button" class="fight-action" data-action="attack">Attack</button>
818       <button type="button" class="fight-action" data-action="cast">Cast</button>
819       <button type="button" class="fight-action" data-action="flee">Flee</button>
820     </div>
821     <div id="fight-results"></div>
822   </div>`;
823
824   return html;
825 }
826
827 socket.on('updatePlayer', (player: Player) => {
828   cache.set('player', player);
829   updatePlayer();
830 });
831
832 socket.on('fight-over', (data: {roundData: FightRound, monsters: MonsterForFight[]}) => {
833   const { roundData, monsters } = data;
834   cache.set('player', roundData.player);
835   updatePlayer();
836
837   $('#map').html(renderFight(roundData.monster));
838
839   let html: string[] = roundData.roundDetails.map(d => `<div>${d}</div>`);
840
841   if(roundData.winner === 'player') {
842     html.push(`<div>You defeated the ${roundData.monster.name}!</div>`);
843     if(roundData.rewards.gold) {
844       html.push(`<div>You gained ${roundData.rewards.gold} gold`);
845     }
846     if(roundData.rewards.exp) {
847       html.push(`<div>You gained ${roundData.rewards.exp} exp`);
848     }
849     if(roundData.rewards.levelIncrease) {
850       html.push(`<div>You gained a level! ${roundData.player.level}`);
851     }
852   }
853
854   if(roundData.player.hp === 0) {
855     // prompt to return to town and don't let them do anything
856     html.push(`<p>You were killed...</p>`);
857     html.push('<p><button class="emit-event-internal" data-event="tab:explore" data-args="">Back to Town</button></p>');
858   }
859   else {
860     switch(roundData.fightTrigger) {
861       case 'explore':
862         if(monsters.length) {
863           const lastSelectedMonster = cache.get('last-selected-monster');
864           // put this above the fight details
865           html.unshift(`<h2>Fight Again</h2><form id="fight-selector"><select id="monster-selector">
866             ${monsters.map(monster => {
867               return `<option value="${monster.id}" ${lastSelectedMonster == monster.id ? 'selected': ''}>${monster.name}</option>`;
868             }).join("\n")}
869             </select> <button type="submit">Fight</button></option>
870           </select></form><hr>`);
871         }
872       break;
873       case 'travel':
874         html.push(`<p><button class="emit-event" data-event="travel:step">Keep Walking</button></p>`);
875       break;
876       default:
877         console.error(`Unknown fight trigger [${roundData.fightTrigger}]`, roundData);
878       break;
879     }
880   }
881
882
883
884   $('#fight-results').html(html.join("\n"));
885   $('#fight-actions').html('');
886
887 });
888
889 socket.on('fight-round', (data: FightRound) => {
890   $('.fight-action').prop('disabled', false);
891   cache.set('player', data.player);
892   updatePlayer();
893
894   $('#map').html(renderFight(data.monster));
895
896   $('#fight-results').html(data.roundDetails.map(d => `<div>${d}</div>`).join("\n"));
897 });
898
899 async function startFight(monsterId: string, fightTrigger: FightTrigger = 'explore') {
900   // check your HP first
901   if(cache.get('player').hp <= 0) {
902     events.emit('alert', [{
903       type: 'error',
904       text: 'You don\'t have enough HP to go looking for a fight'
905     }]);
906     return;
907   }
908
909   cache.set('last-selected-monster', monsterId);
910
911   try {
912     const monster: MonsterForFight = await http.post('/fight', {
913       monsterId,
914       fightTrigger
915     });
916
917     $('#map').html(renderFight(monster));
918   }
919   catch(e) {
920     events.emit('alert', [{
921       type: 'error',
922       text: 'Sorry, you can\'t start that fight'
923     }]);
924   }
925 }
926
927 events.on('startFight', startFight);
928
929 $('body').on('submit', '#fight-selector', async e => {
930   e.preventDefault();
931   e.stopPropagation();
932
933   const monsterId = $('#monster-selector option:selected').val();
934
935   if(monsterId) {
936     startFight(monsterId.toString());
937   }
938   else {
939     console.error(`Invalid monster id [${monsterId}]`);
940   }
941
942 });
943
944 $('body').on('click', '.fight-action', e => {
945   e.preventDefault();
946   e.stopPropagation();
947
948   const action = $(e.target).data('action');
949   const target = $('#fight-target option:selected').val();
950   $('.fight-action').prop('disabled', true);
951
952   socket.emit('fight', { action, target });
953 });
954
955 $('body').on('submit', '#signup', async e => {
956   e.preventDefault();
957   e.stopPropagation();
958
959   const data: Record<string, string> = {}; 
960
961   $(e.target).serializeArray().forEach(v => data[v.name] = v.value);
962
963   const res = await http.post('/signup', data);
964
965   if(res.error) {
966     events.emit('alert', [{
967       type: 'error',
968       text: res.error
969     }]);
970   }
971   else {
972     cache.set('player', res.player);
973     updatePlayer();
974     $('#signup-prompt').remove();
975   }
976
977 });
978
979 $('body').on('click', '#login', async e => {
980   e.preventDefault();
981   e.stopPropagation();
982
983   const data: Record<string, string> = {}; 
984
985   $(e.target).closest('form').serializeArray().forEach(v => data[v.name] = v.value);
986
987   if(data.username && data.password) {
988     const res = await http.post('/login', data);
989     if(res.error) {
990       events.emit('alert', [{type: 'error', text: res.error}]);
991     }
992     else {
993       localStorage.setItem('authToken', res.player.id);
994       window.location.reload();
995     }
996   }
997 });
998
999 $('body').on('click', '#logout', async e => {
1000   e.preventDefault();
1001   e.stopPropagation();
1002
1003   let logout = false;
1004
1005   const player = cache.get('player');
1006   if(/^Player[\d]+$/.test(player.username)) {
1007     const clear = confirm('Are you sure? You will not be able to retrieve this character unless you set up an email/password');
1008     if(clear) {
1009       logout = true;
1010     }
1011     else {
1012       logout = false;
1013     }
1014   }
1015   else {
1016     logout = true;
1017   }
1018
1019   if(logout) {
1020     socket.emit('logout');
1021     localStorage.clear();
1022     window.location.reload();
1023   }
1024
1025 });
1026
1027 function bootstrap() {
1028   console.log('Server connection verified');
1029   socket.emit('inventory');
1030   $('nav a').first().click();
1031 }