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