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 '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' 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.