PageSourceSearch

https://heroescm-jam.fly.dev/js/jam.js?v=20260505z-token1

js heroescm-jam.fly.dev collected 2026-10-03 08:26:19 UTC 30,976 bytes, 785 lines download raw bytes

1// ════════════════════════════════════════════════════════════════
2//  Heroes of Combat and Magic — Jam Edition controller
3//  Menu → existing CombatEngine.start (full main-game combat).
4// ════════════════════════════════════════════════════════════════
5
6(() => {
7'use strict';
8
9// ────────────────────────── Globals ──────────────────────────
10const G = {
11  socket: null,
12  player: null,
13  armyLeft: {},        // { unitId: count }
14  armyRight: {},
15  factionLeft: 'beastlands',
16  factionRight: 'necromancers',
17  pointsCustom: 500,
18  pointsPvp: 500,
19  pvpMode: null,        // 'pvp_1v1' | 'pvp_2v2' | 'pvp_ffa'
20  pvpRoom: null,
21};
22window.G = G;
23
24const FACTION_KEYS = ['beastlands', 'necromancers', 'machinas', 'knights'];
25
26// ─── Persist player's last army (Variant A — single global slot) ───
27// Saves G.armyLeft + G.factionLeft to localStorage so reopening the
28// browser restores the last-built army on the LEFT side.
29const LS_KEY_LAST_ARMY = 'jam_lastArmy';
30function saveLastArmy() {
31  try {
32    localStorage.setItem(LS_KEY_LAST_ARMY, JSON.stringify({
33      faction: G.factionLeft,
34      army:    G.armyLeft,
35    }));
36  } catch (_) { /* quota / private mode — silently ignore */ }
37}
38function loadLastArmy() {
39  try {
40    const raw = localStorage.getItem(LS_KEY_LAST_ARMY);
41    if (!raw) return;
42    const data = JSON.parse(raw);
43    if (data && typeof data === 'object') {
44      if (typeof data.faction === 'string' && FACTION_KEYS.includes(data.faction)) {
45        G.factionLeft = data.faction;
46      }
47      if (data.army && typeof data.army === 'object') {
48        // Filter out unit ids that no longer exist (data.js was edited).
49        const cleaned = {};
50        for (const [uid, n] of Object.entries(data.army)) {
51          if (ALL_UNITS && ALL_UNITS[uid] && Number.isFinite(n) && n > 0) cleaned[uid] = n;
52        }
53        G.armyLeft = cleaned;
54      }
55    }
56  } catch (_) { /* corrupt JSON — ignore, leave default empty army */ }
57}
58
59// ────────────────────────── Boot ──────────────────────────
60window.addEventListener('DOMContentLoaded', () => {
61  loadLastArmy();   // restore last-built army before any UI builds
62  initSocket();
63  initMenu();
64  initCustomScreen();
65  initPvpScreen();
66  initCommonHandlers();
67  initVibeJamPortal();   // Exit portal button + ?portal=true incoming handler
68});
69
70// ────────────── Vibe Jam 2026 Portal ──────────────
71// Exit: button in combat HUD (#btn-combat-portal) → vibejam.cc/portal/2026
72// Incoming: ?portal=true in URL → skip menu, instant random battle
73function initVibeJamPortal() {
74  const VIBEJAM_URL = 'https://vibejam.cc/portal/2026';
75  const SELF_HOST = 'vibejam.heroescm.com';
76
77  // Exit portal button (lives in combat HUD)
78  const exitBtn = document.getElementById('btn-combat-portal');
79  if (exitBtn) {
80    exitBtn.addEventListener('click', () => {
81      const username = (G.player?.username) || 'Hero';
82      const params = new URLSearchParams({
83        ref: SELF_HOST,
84        username,
85        color: 'gold',
86      });
87      location.href = `${VIBEJAM_URL}?${params.toString()}`;
88    });
89  }
90
91  // Incoming portal handler
92  const urlParams = new URLSearchParams(location.search);
93  if (urlParams.get('portal') === 'true') {
94    G._portalIncoming = {
95      username: urlParams.get('username') || 'Hero',
96      ref: urlParams.get('ref') || '',
97    };
98    // Defer until socket auth + UI init complete, then auto-launch random battle
99    setTimeout(() => startPortalBattle(), 600);
100  }
101}
102
103function startPortalBattle() {
104  const factions = FACTION_KEYS;
105  const playerFaction = factions[Math.floor(Math.random() * factions.length)];
106  const enemyFaction  = factions[Math.floor(Math.random() * factions.length)];
107  G.factionLeft  = playerFaction;
108  G.factionRight = enemyFaction;
109  G.armyLeft  = {};
110  G.armyRight = {};
111  G.pointsCustom = 3000;
112
113  // Auto-fill player army @ 3000
114  autoFillArmy('left', 'screen-custom-setup');
115
116  // Auto-fill enemy army @ 1500 (temporarily lower budget)
117  const savedBudget = G.pointsCustom;
118  G.pointsCustom = 1500;
119  autoFillArmy('right', 'screen-custom-setup');
120  G.pointsCustom = savedBudget;   // restore so HUD shows 3000
121
122  launchCustomBattle();
123}
124
125function initSocket() {
126  if (typeof io === 'undefined') {
127    console.warn('Socket.io not available — PvP disabled');
128    return;
129  }
130  G.socket = io({ transports: ['websocket', 'polling'] });
131  // Expose under window.socket so chat-dock.js (which polls for it on a
132  // 30-second timeout) can attach. Without this, the chat dock never
133  // wires its `chat:history` / `chat:msg` listeners and the dock looks
134  // permanently disconnected. Same alias is what menu-redesign.js
135  // already reads as `window.socket || (window.G && window.G.socket)`.
136  window.socket = G.socket;
137  G.socket.on('connect', () => {
138    const cached = localStorage.getItem('jam_token');
139    if (cached) {
140      G.socket.emit('auth:resume', { token: cached }, (res) => {
141        if (res?.success) {
142          G.player = res.player;
143          // Server may rotate the token on resume — save fresh one.
144          if (res.sessionToken) localStorage.setItem('jam_token', res.sessionToken);
145        } else {
146          // Stale/invalid token — wipe so we don't keep retrying it.
147          try { localStorage.removeItem('jam_token'); } catch (_) {}
148          registerGuest();
149        }
150      });
151    } else registerGuest();
152  });
153  G.socket.on('pvp:joined', onPvpRoomUpdate);
154  G.socket.on('pvp:left', onPvpRoomUpdate);
155  G.socket.on('pvp:start', onPvpStart);
156  G.socket.on('pvp:cancelled', () => { toast('Room cancelled'); showScreen('screen-pvp'); });
157  G.socket.on('pvp:expired', () => { toast('Lobby expired'); showScreen('screen-pvp'); });
158  // Server pushes `pvp:lobbies` snapshot whenever any room changes.
159  G.socket.on('pvp:lobbies', (lobbies) => {
160    // Only update list if we're on the PvP screen.
161    if (document.getElementById('screen-pvp')?.classList.contains('active')) {
162      renderRoomList(lobbies || []);
163    }
164  });
165
166  function registerGuest() {
167    const rand = Math.random().toString(36).slice(2, 8);
168    const guestName = 'Player' + rand;
169    G.socket.emit('auth:register', {
170      username: guestName,
171      password: 'jam_' + Math.random().toString(36).slice(2, 14) + 'A1',
172      faction: 'beastlands',
173      email: `${guestName}@jam.local`,
174      emailOptIn: false,
175    }, (res) => {
176      if (res?.success) {
177        G.player = res.player;
178        // Server returns the field as `sessionToken` (not `token`) — saving
179        // the wrong key meant the token was NEVER persisted, so every
180        // reload re-registered as a fresh guest. That blew up activity
181        // log noise + bot/legit conflation.
182        const tok = res.sessionToken || res.token;
183        if (tok) localStorage.setItem('jam_token', tok);
184      }
185    });
186  }
187}
188
189// ────────────────────────── Screen routing ──────────────────────────
190function showScreen(id) {
191  // hex-combat.js calls showScreen('combat') without the 'screen-' prefix.
192  // Auto-add the prefix if the bare id doesn't match an element.
193  let el = document.getElementById(id);
194  if (!el) el = document.getElementById('screen-' + id);
195  document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
196  if (el) el.classList.add('active');
197  window.scrollTo(0, 0);
198}
199window.showScreen = showScreen;
200
201function initCommonHandlers() {
202  document.querySelectorAll('[data-back]').forEach(b => {
203    b.addEventListener('click', () => {
204      if (G.pvpRoom && G.socket) {
205        G.socket.emit('pvp:leave', { lobbyId: G.pvpRoom.id });
206        G.pvpRoom = null;
207      }
208      showScreen('screen-menu');
209    });
210  });
211  document.getElementById('btn-combat-result-map')?.addEventListener('click', () => {
212    document.getElementById('combat-result-overlay').style.display = 'none';
213    showScreen('screen-menu');
214  });
215  document.getElementById('btn-combat-surrender')?.addEventListener('click', () => {
216    if (window.CombatEngine?.surrender) window.CombatEngine.surrender();
217  });
218}
219
220// ────────────────────────── Menu ──────────────────────────
221function initMenu() {
222  document.querySelectorAll('#screen-menu [data-action]').forEach(btn => {
223    btn.addEventListener('click', () => {
224      const a = btn.dataset.action;
225      if (a === 'custom') {
226        renderArmyBuilder('screen-custom-setup', 'left');
227        renderArmyBuilder('screen-custom-setup', 'right');
228        showScreen('screen-custom-setup');
229      } else if (a.startsWith('pvp_')) {
230        G.pvpMode = a;
231        document.getElementById('pvp-title').textContent =
232          a === 'pvp_1v1' ? 'PvP — 1 vs 1' :
233          a === 'pvp_2v2' ? 'PvP — 2 vs 2 (team)' :
234          'PvP — Free For All (4)';
235        renderArmyBuilder('screen-pvp', 'left');
236        showScreen('screen-pvp');
237        if (G.socket) G.socket.emit('pvp:list', null, (res) => renderRoomList(res?.lobbies || []));
238      }
239    });
240  });
241}
242
243// ────────────────────────── Army builder ──────────────────────────
244function renderArmyBuilder(screenId, side) {
245  const screen = document.getElementById(screenId);
246  if (!screen) return;
247  const col = screen.querySelector(`.army-col[data-side="${side}"]`);
248  if (!col) return;
249
250  const tabsEl = col.querySelector('.faction-tabs');
251  const gridEl = col.querySelector('.unit-grid');
252  const factionRef = side === 'left' ? 'factionLeft' : 'factionRight';
253
254  tabsEl.innerHTML = '';
255  FACTION_KEYS.forEach(fid => {
256    const f = FACTIONS[fid];
257    if (!f) return;
258    const btn = document.createElement('button');
259    btn.className = 'faction-tab' + (G[factionRef] === fid ? ' active' : '');
260    btn.textContent = `${f.emoji} ${f.name}`;
261    btn.addEventListener('click', () => {
262      G[factionRef] = fid;
263      if (side === 'left') G.armyLeft = {}; else G.armyRight = {};
264      if (side === 'left') saveLastArmy();   // remember last faction (army cleared)
265      renderArmyBuilder(screenId, side);
266    });
267    tabsEl.appendChild(btn);
268  });
269
270  gridEl.innerHTML = '';
271  const faction = FACTIONS[G[factionRef]];
272  const unitIds = faction?.units || [];
273  unitIds.forEach(uid => {
274    const u = ALL_UNITS[uid];
275    if (!u) return;
276    const army = side === 'left' ? G.armyLeft : G.armyRight;
277    const count = army[uid] || 0;
278    const card = document.createElement('div');
279    card.className = 'unit-card' + (count > 0 ? ' selected' : '');
280    card.dataset.unit = uid;
281    card.innerHTML = `
282      <div class="portrait" style="background-image:url('images/portraits/raw2/${uid}.webp')"></div>
283      <div class="name">${u.name}</div>
284      <div class="cost">${u.pointCost}p</div>
285      <div class="count">×${count}</div>
286    `;
287    card.addEventListener('click', (e) => addToArmy(side, uid, e.shiftKey ? -1 : +1, screenId));
288    card.addEventListener('contextmenu', (e) => { e.preventDefault(); addToArmy(side, uid, -1, screenId); });
289    gridEl.appendChild(card);
290  });
291
292  col.querySelector(`[data-clear="${side}"]`)?.addEventListener('click', () => {
293    if (side === 'left') G.armyLeft = {}; else G.armyRight = {};
294    if (side === 'left') saveLastArmy();
295    renderArmyBuilder(screenId, side);
296  });
297  col.querySelector(`[data-auto="${side}"]`)?.addEventListener('click', () => autoFillArmy(side, screenId));
298
299  recomputeArmy(screenId, side);
300}
301
302function addToArmy(side, unitId, delta, screenId) {
303  const army = side === 'left' ? G.armyLeft : G.armyRight;
304  const u = ALL_UNITS[unitId];
305  if (!u) return;
306  const budget = (screenId === 'screen-pvp') ? G.pointsPvp : G.pointsCustom;
307  const used = computeUsed(army);
308  if (delta > 0 && u.pointCost > (budget - used)) {
309    toast('Not enough points');
310    return;
311  }
312  army[unitId] = Math.max(0, (army[unitId] || 0) + delta);
313  if (army[unitId] === 0) delete army[unitId];
314  if (side === 'left') saveLastArmy();
315  renderArmyBuilder(screenId, side);
316}
317
318function computeUsed(army) {
319  let total = 0;
320  for (const [uid, n] of Object.entries(army)) {
321    const u = ALL_UNITS[uid];
322    if (u) total += u.pointCost * n;
323  }
324  return total;
325}
326
327function recomputeArmy(screenId, side) {
328  const screen = document.getElementById(screenId);
329  if (!screen) return;
330  const col = screen.querySelector(`.army-col[data-side="${side}"]`);
331  if (!col) return;
332  const army = side === 'left' ? G.armyLeft : G.armyRight;
333  const used = computeUsed(army);
334  const budget = (screenId === 'screen-pvp') ? G.pointsPvp : G.pointsCustom;
335  col.querySelector('.pts-used').textContent = used;
336  col.querySelector('.pts-budget').textContent = budget;
337  col.querySelector('.army-summary').classList.toggle('over', used > budget);
338}
339
340function autoFillArmy(side, screenId) {
341  const army = side === 'left' ? G.armyLeft : G.armyRight;
342  const factionId = side === 'left' ? G.factionLeft : G.factionRight;
343  const budget = (screenId === 'screen-pvp') ? G.pointsPvp : G.pointsCustom;
344  const f = FACTIONS[factionId];
345  if (!f) return;
346
347  for (const k of Object.keys(army)) delete army[k];
348  let remaining = budget;
349
350  const units = f.units.map(id => ALL_UNITS[id]).filter(Boolean).sort((a, b) => b.pointCost - a.pointCost);
351
352  for (const u of units) {
353    if (u.pointCost <= remaining * 0.4 && u.pointCost > 50) {
354      army[u.id] = 1;
355      remaining -= u.pointCost;
356      break;
357    }
358  }
359  const mid = units.find(u => u.pointCost >= 15 && u.pointCost <= 50);
360  if (mid && remaining >= mid.pointCost) {
361    const n = Math.min(8, Math.floor((remaining * 0.5) / mid.pointCost));
362    if (n > 0) { army[mid.id] = n; remaining -= n * mid.pointCost; }
363  }
364  const cheap = units[units.length - 1];
365  if (cheap && remaining >= cheap.pointCost) {
366    const n = Math.floor(remaining / cheap.pointCost);
367    if (n > 0) army[cheap.id] = (army[cheap.id] || 0) + n;
368  }
369  if (side === 'left') saveLastArmy();
370  renderArmyBuilder(screenId, side);
371}
372
373function armyToConfig(army) {
374  // Build the legacy object map shape that CombatEngine.start accepts directly.
375  return { ...army };
376}
377
378function armyToStacksArray(army, side) {
379  // Explicit-position stacks: side 'left' → col 0, 'right' → far col
380  const entries = Object.entries(army).filter(([, c]) => c > 0);
381  const n = entries.length;
382  const GRID_ROWS = 10;
383  const farCol = 13;
384  return entries.map(([unitId, count], i) => {
385    const spacing = n > 1 ? (GRID_ROWS - 1) / (n - 1) : 0;
386    const row = n > 1 ? Math.round(i * spacing) : Math.floor(GRID_ROWS / 2);
387    return { unitId, count, x: side === 'left' ? 0 : farCol, y: row };
388  });
389}
390
391// ────────────────────────── Custom Battle ──────────────────────────
392function initCustomScreen() {
393  const range = document.getElementById('custom-points');
394  const out = document.getElementById('custom-points-val');
395  range.addEventListener('input', () => {
396    G.pointsCustom = parseInt(range.value, 10);
397    out.textContent = G.pointsCustom;
398    recomputeArmy('screen-custom-setup', 'left');
399    recomputeArmy('screen-custom-setup', 'right');
400  });
401
402  document.getElementById('custom-start').addEventListener('click', () => {
403    if (computeUsed(G.armyLeft) === 0) { toast('Build your army first'); return; }
404    if (computeUsed(G.armyRight) === 0) autoFillArmy('right', 'screen-custom-setup');
405    if (computeUsed(G.armyLeft) > G.pointsCustom) { toast('Your army is over budget'); return; }
406    launchCustomBattle();
407  });
408}
409
410function launchCustomBattle() {
411  // Assemble inputs for the existing CombatEngine.start.
412  const armyConfig = armyToConfig(G.armyLeft);
413  const enemyArmy = armyToStacksArray(G.armyRight, 'right');
414  const faction = G.factionLeft;
415  const playerName = (G.player?.username) || 'Hero';
416
417  // No hero stats / artifacts in jam — pass empty heroStats so engine uses unit base stats.
418  const heroStats = { attack: 0, defense: 0, knowledge: 1, spellPower: 0 };
419
420  showScreen('screen-combat');
421
422  // Mark formation skip — engine will auto-deploy.
423  window._jamMode = 'custom';
424
425  if (!window.CombatEngine?.start) {
426    toast('Combat engine not loaded');
427    return;
428  }
429
430  // Read map index forced by menu-redesign.js (defaults to random if absent).
431  const forcedMapIdx = (typeof window.__forcedMapIndex === 'number') ? window.__forcedMapIndex : undefined;
432
433  // Battle log: split into START and END events so the admin can see
434  // every match that begins, not just those completed. Same battleId
435  // ties them together — server status flips 'active' → 'completed' on
436  // end. Battles where the player closes the tab / navigates away
437  // remain 'active' and a 10-minute server-side sweep marks them
438  // 'abandoned'. Both emits are fire-and-forget — never blocks gameplay.
439  const _battleId = (window.crypto?.randomUUID
440    ? window.crypto.randomUUID()
441    : 'b-' + Math.random().toString(36).slice(2) + '-' + Date.now());
442  const _battleStartTs = Date.now();
443  try {
444    const sock = G.socket;
445    const myId = G.player?.id;
446    if (sock && sock.connected && myId) {
447      sock.emit('jam:battle:start', {
448        id: _battleId,
449        battle_type: 'custom',
450        player_id: myId,
451        username: G.player?.username || playerName || 'Hero',
452        faction,
453        enemy_faction: window.G?.factionRight || null,
454      });
455    }
456  } catch (_) {}
457
458  window.CombatEngine.start(armyConfig, enemyArmy, faction, (won) => {
459    showCombatResult(won);
460    try {
461      const sock = G.socket;
462      if (sock && sock.connected) {
463        sock.emit('jam:battle:end', {
464          id: _battleId,
465          won: !!won,
466          duration_ms: Date.now() - _battleStartTs,
467        });
468      }
469    } catch (_) {}
470  }, playerName, {
471    heroStats,
472    upgrades: {},
473    _demoMapIndex: forcedMapIdx,
474    // ── Test magic system (added 2026-04-28) ──
475    // Custom Battle gets a hard-coded "test mage" loadout: 30 mana, SP 1,
476    // 3 spells available — Slow / Haste / Lightning. This unlocks the
477    // ⚡ Magic button in combat without needing the castle/magic-school
478    // economy from main game. PvP path below is intentionally still
479    // `magic: null` (lockstep concerns — wire later when system is solid).
480    magic: {
481      currentMana: 30,
482      maxMana: 30,
483      spellPower: 1,
484      availableSpells: ['slow', 'haste', 'lightning'],
485    },
486    coop: null,
487    pvp: null,
488  });
489
490  // Re-scale combat viewport after layout settles (it gets initial 0×0 width
491  // because the screen was display:none until the showScreen call in start).
492  requestAnimationFrame(() => {
493    requestAnimationFrame(() => {
494      const wrapper = document.querySelector('.combat-main-wrapper');
495      const main = document.querySelector('.combat-main');
496      if (wrapper && main) {
497        const availW = wrapper.clientWidth || window.innerWidth;
498        const availH = wrapper.clientHeight || window.innerHeight;
499        const scale = Math.min(availW / 1200, availH / 700);
500        main.style.transform = `scale(${scale})`;
501        main.style.transformOrigin = 'center center';
502      }
503    });
504  });
505
506  // Auto-skip the formation phase for jam custom battle — click the Start
507  // Battle button as soon as it becomes available (the engine auto-starts
508  // after 15 s otherwise; for jam we don't need the formation step).
509  let tries = 0;
510  const tryAutoStart = () => {
511    const btn = document.getElementById('btn-formation-start');
512    if (btn && btn.onclick) {
513      // Trigger the engine's beginBattle by calling the click handler directly.
514      try { btn.click(); } catch (e) { console.warn('formation auto-start failed', e); }
515      return;
516    }
517    if (++tries < 20) setTimeout(tryAutoStart, 250);
518  };
519  setTimeout(tryAutoStart, 500);
520}
521
522function showCombatResult(won) {
523  const overlay = document.getElementById('combat-result-overlay');
524  const title = document.getElementById('combat-result-title');
525  const video = document.getElementById('combat-result-video');
526  if (!overlay) return;
527  title.textContent = won ? 'VICTORY' : 'DEFEAT';
528  if (video) {
529    video.src = won ? 'images/ui/victory.webm' : 'images/ui/defeat.webm';
530    video.play().catch(() => {});
531  }
532  overlay.style.display = 'flex';
533}
534
535// ────────────────────────── PvP ──────────────────────────
536function initPvpScreen() {
537  const range = document.getElementById('pvp-points');
538  const out = document.getElementById('pvp-points-val');
539  range.addEventListener('input', () => {
540    G.pointsPvp = parseInt(range.value, 10);
541    out.textContent = G.pointsPvp;
542    recomputeArmy('screen-pvp', 'left');
543  });
544
545  document.getElementById('pvp-create').addEventListener('click', () => {
546    if (!G.socket) { toast('Server unavailable'); return; }
547    if (computeUsed(G.armyLeft) === 0) { toast('Build your army first'); return; }
548    const name = document.getElementById('pvp-name').value.trim() || 'Guest';
549    // Server expects army_config as simple { unitId: count } map, same as
550    // hunt:start / coop. Faction is read from the player record (set on auth).
551    const armyConfig = { ...G.armyLeft };
552    G.socket.emit('pvp:create', {
553      mode: G.pvpMode,
554      army_config: armyConfig,
555      turnLimitSec: 60,
556    }, (res) => {
557      if (res?.error) { toast(res.error); return; }
558      G.pvpRoom = { id: res.lobbyId, mode: G.pvpMode };
559      showScreen('screen-pvp-wait');
560      document.getElementById('pvp-wait-id').textContent = res.lobbyId.slice(0, 8);
561      document.getElementById('pvp-wait-count').textContent = '1/' + seatCount(G.pvpMode);
562    });
563  });
564
565  document.getElementById('pvp-refresh').addEventListener('click', () => {
566    if (!G.socket) return;
567    G.socket.emit('pvp:list', null, (res) => renderRoomList(res?.lobbies || []));
568  });
569
570  // Add Bot — host-only. Inserts a synthetic seat (random faction, auto-army)
571  // and triggers pvp:start when the bot fills the LAST empty seat. Useful for
572  // testing hero placement / 2v2 / FFA solo.
573  document.getElementById('pvp-add-bot')?.addEventListener('click', () => addBot(1));
574  document.getElementById('pvp-fill-bots')?.addEventListener('click', () => {
575    const total = seatCount(G.pvpRoom?.mode || G.pvpMode);
576    const seated = parseInt(document.getElementById('pvp-wait-count').textContent.split('/')[0], 10) || 1;
577    addBot(total - seated);
578  });
579}
580
581// Add `count` bots to the current pvp lobby, one at a time. Each request
582// is sequential — server emits pvp:joined per bot so the wait counter
583// ticks visibly, and the LAST bot triggers pvp:start.
584function addBot(count) {
585  if (!G.socket || !G.pvpRoom) { toast('Create a room first'); return; }
586  const remaining = Math.max(0, count | 0);
587  if (remaining === 0) { toast('Lobby is already full'); return; }
588  G.socket.emit('pvp:add_bot', { lobbyId: G.pvpRoom.id }, (res) => {
589    if (res?.error) { toast(res.error); return; }
590    if (res?.started) {
591      // pvp:start will arrive separately and call onPvpStart.
592      return;
593    }
594    if (remaining > 1) addBot(remaining - 1);
595  });
596}
597
598function seatCount(mode) { return mode === 'pvp_1v1' ? 2 : 4; }
599
600function renderRoomList(rooms) {
601  const el = document.getElementById('pvp-rooms');
602  if (!el) return;
603  const filtered = (rooms || []).filter(r => r.mode === G.pvpMode);
604  if (filtered.length === 0) {
605    el.innerHTML = '<p class="rooms-empty">No open rooms. Create one!</p>';
606    return;
607  }
608  el.innerHTML = '';
609  filtered.forEach(r => {
610    const item = document.createElement('div');
611    item.className = 'room-item';
612    item.innerHTML = `
613      <div class="room-info">
614        <div class="room-host">${escapeHtml(r.hostName || 'Guest')}</div>
615        <div class="room-meta">${r.seated || 1}/${seatCount(r.mode)} · ${r.mode}</div>
616      </div>
617      <button class="btn btn-mini">Join</button>
618    `;
619    item.querySelector('button').addEventListener('click', () => joinRoom(r.id));
620    el.appendChild(item);
621  });
622}
623
624function joinRoom(lobbyId) {
625  if (!G.socket) return;
626  if (computeUsed(G.armyLeft) === 0) { toast('Build your army first'); return; }
627  const name = document.getElementById('pvp-name').value.trim() || 'Guest';
628  const armyConfig = {
629    faction: G.factionLeft,
630    slots: Object.entries(G.armyLeft).map(([id, count]) => ({ id, count })),
631    heroName: name,
632    points: G.pointsPvp,
633  };
634  G.socket.emit('pvp:join', { lobbyId, army_config: armyConfig }, (res) => {
635    if (res?.error) { toast(res.error); return; }
636    G.pvpRoom = { id: lobbyId, mode: G.pvpMode };
637    showScreen('screen-pvp-wait');
638    document.getElementById('pvp-wait-id').textContent = lobbyId.slice(0, 8);
639  });
640}
641
642function onPvpRoomUpdate(data) {
643  if (!G.pvpRoom || data.lobbyId !== G.pvpRoom.id) return;
644  document.getElementById('pvp-wait-count').textContent =
645    (data.seated || 1) + '/' + seatCount(G.pvpRoom.mode);
646}
647
648function onPvpStart(payload) {
649  // payload: { lobbyId, roomId, mode, seed, turnLimitMs, seats, armies, heroInfos }
650  // Mirror of lobby.html's pvp:start handler from the main game, adapted to
651  // the SPA flow (no sessionStorage, no page navigation).
652  if (!payload) return;
653  const myId = G.player?.id;
654  if (!myId) { toast('Not logged in'); return; }
655
656  const mySeat = (payload.seats || []).find(s => s.playerId === myId);
657  const myArmyEntry = (payload.armies || []).find(a => a.playerId === myId);
658  if (!mySeat || !myArmyEntry) { toast('Match start failed'); return; }
659
660  const opponents = payload.seats
661    .filter(s => s.team !== mySeat.team)
662    .map(s => {
663      const a = payload.armies.find(x => x.playerId === s.playerId);
664      return {
665        playerId: s.playerId, username: s.username, faction: s.faction,
666        team: s.team, tag: s.tag, seatIndex: s.seatIndex,
667        army: a ? a.army : [], heroStats: a ? a.heroStats : null,
668      };
669    });
670  const teammates = payload.seats
671    .filter(s => s.team === mySeat.team && s.playerId !== myId)
672    .map(s => {
673      const a = payload.armies.find(x => x.playerId === s.playerId);
674      return {
675        playerId: s.playerId, username: s.username, faction: s.faction,
676        team: s.team, tag: s.tag, seatIndex: s.seatIndex,
677        army: a ? a.army : [], heroStats: a ? a.heroStats : null,
678      };
679    });
680
681  // Canonical assembly: team A → playerArmy, team B → enemyArmy, summed across
682  // teammates so every client initializes the sim identically. hex-combat reads
683  // window._pvpContext to tag stack ownership + gate input.
684  const allArmies = [
685    { ...mySeat, army: myArmyEntry.army, heroStats: myArmyEntry.heroStats },
686    ...teammates,
687    ...opponents,
688  ];
689  const teamA = allArmies.filter(m => m.team === 'A');
690  const teamB = allArmies.filter(m => m.team === 'B');
691  const sumArmy = (members) => members.reduce((o, m) => {
692    for (const s of (m.army || [])) o[s.unitId] = (o[s.unitId] || 0) + s.count;
693    return o;
694  }, {});
695  const playerArmy = sumArmy(teamA);
696  const enemyArmyArr = teamB.flatMap(m => (m.army || []).map(s => ({ unitId: s.unitId, count: s.count })));
697
698  window._pvpContext = {
699    mode: payload.mode,
700    roomId: payload.roomId,
701    seed: payload.seed,
702    turnLimitMs: payload.turnLimitMs || null,
703    myPlayerId: myId,
704    mySeat: { ...mySeat, army: myArmyEntry.army, heroStats: myArmyEntry.heroStats },
705    myTeam: mySeat.team,
706    myTag: mySeat.tag,
707    teammates,
708    opponents,
709    allSeats: payload.seats,
710    botPlayerIds: Array.isArray(payload.botPlayerIds) ? payload.botPlayerIds : [],
711    heroInfos: payload.heroInfos || null,
712    teamAArmies: teamA.map(m => ({
713      playerId: m.playerId, username: m.username, faction: m.faction,
714      tag: m.tag, seatIndex: m.seatIndex, team: m.team,
715      army: m.army || [], heroStats: m.heroStats || null,
716    })),
717    teamBArmies: teamB.map(m => ({
718      playerId: m.playerId, username: m.username, faction: m.faction,
719      tag: m.tag, seatIndex: m.seatIndex, team: m.team,
720      army: m.army || [], heroStats: m.heroStats || null,
721    })),
722    myHeroStats: myArmyEntry.heroStats || null,
723  };
724
725  const oppNames = opponents.map(o => o.username).join(' & ');
726  toast(`PvP ${payload.mode.replace('pvp_', '').toUpperCase()} vs ${oppNames}`);
727
728  showScreen('screen-combat');
729  if (!window.CombatEngine?.start) { toast('Combat engine not loaded'); return; }
730
731  const heroStats = myArmyEntry.heroStats || { attack: 0, defense: 0, knowledge: 1, spellPower: 0 };
732  window.CombatEngine.start(playerArmy, enemyArmyArr, mySeat.faction || G.factionLeft, (won) => {
733    showCombatResult(won);
734  }, G.player?.username || 'Hero', {
735    heroStats,
736    upgrades: {},
737    magic: null,
738    coop: null,
739    pvp: window._pvpContext,
740    seed: payload.seed,
741  });
742
743  // Re-scale viewport (same as custom path)
744  requestAnimationFrame(() => {
745    requestAnimationFrame(() => {
746      const wrapper = document.querySelector('.combat-main-wrapper');
747      const main = document.querySelector('.combat-main');
748      if (wrapper && main) {
749        const availW = wrapper.clientWidth || window.innerWidth;
750        const availH = wrapper.clientHeight || window.innerHeight;
751        const scale = Math.min(availW / 1200, availH / 700);
752        main.style.transform = `scale(${scale})`;
753        main.style.transformOrigin = 'center center';
754      }
755    });
756  });
757}
758
759// ────────────────────────── Helpers ──────────────────────────
760function toast(msg, ms = 2200) {
761  const t = document.getElementById('toast');
762  if (!t) { console.log('[toast]', msg); return; }
763  t.textContent = msg;
764  t.classList.remove('hidden');
765  clearTimeout(t._h);
766  t._h = setTimeout(() => t.classList.add('hidden'), ms);
767}
768
769function escapeHtml(s) {
770  return String(s || '').replace(/[&<>"']/g, ch => ({
771    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
772  }[ch]));
773}
774
775// ────────────────────────── Public surface (for menu-redesign.js) ──────────────────────────
776// menu-redesign.js calls these via window.* — explicit expose keeps the
777// IIFE encapsulation otherwise.
778Object.assign(window, {
779  autoFillArmy,
780  launchCustomBattle,
781  renderArmyBuilder,
782  toast,
783});
784
785})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.