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