// Master of Vega — combat simulator. Secret entrance: index.html?movsim // (see PreloadScene). A dev tool for tuning VegaCombat: configure a fleet (or // a fleet vs. a bare planet) on each side, then either watch one battle play // out on the real tactical screen (openCombatView, unmodified) or fire a // batch of headless trials through runBattle for win-rate stats. Both paths // call the exact same VegaCombat stepper the live game uses, so a balance // change this tool disagrees with is a change the real game disagrees with // too. // // A "Live / V2 Prototype" toggle switches BOTH Watch Battle and Simulate ×N // together onto VegaCombatV2.js's per-ship engine and VegaCombatViewV2.js's // zoomable per-ship view — never one on each, so the fight you watch and the // trial stats you run always agree. Live mode is untouched, byte-for-byte // the same tool this always was; V2 is an experimental parallel engine (see // docs/mastervega-build-plan.md) that does not affect real player battles. // // Boots straight past GameMenuScene/OpponentSelect, so it has to load its own // slice of the mastervega asset manifest (rules JSON + ship art) instead of // relying on GameRoomScene's lazy fetch — same trick as the other *Editor // scenes. import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { Button } from './VegaButton.js'; import { queueGameAssets } from '../../services/assetLoader.js'; import { compileRules, markNumeral } from './VegaRules.js'; import { ensureSheets } from './VegaArt.js'; import { designFor, stackPower } from './VegaShips.js'; import { createBattle, runBattle } from './VegaCombat.js'; import { openCombatView } from './VegaCombatView.js'; import * as CombatV2 from './VegaCombatV2.js'; import { openCombatViewV2 } from './VegaCombatViewV2.js'; import { formationName } from './VegaFormations.js'; import { VegaMusic } from './VegaMusic.js'; import { FONT, uiClick, openFormationPicker } from './VegaScreens.js'; const PANEL_BG = 0x0b1220; const PANEL_LINE = 0x2c435f; const ATTACKER_COLOR = '#9fd8ff'; const DEFENDER_COLOR = '#ffb0a0'; const TOP = 170; const COL_W = 800; const COL_A_X = 90; const COL_D_X = GAME_WIDTH - 90 - COL_W; // Starter compositions for the "Small / Medium / Large" one-click presets. // Hand-tuned, not derived — they exist to get a plausible fleet on the grid // fast; every count is still a manual stepper afterwards. const FLEET_PRESETS = { small: { frigate: 4, destroyer: 2, cruiser: 0, battleship: 0, starbase: 0, }, medium: { frigate: 6, destroyer: 6, cruiser: 3, battleship: 1, starbase: 0, }, large: { frigate: 10, destroyer: 10, cruiser: 8, battleship: 5, starbase: 1, }, }; const PLANET_PRESETS = { small: { defenseHp: 100, shieldBonus: 0 }, medium: { defenseHp: 400, shieldBonus: 2 }, large: { defenseHp: 1200, shieldBonus: 4 }, }; const TRIAL_COUNTS = [10, 50, 200, 1000]; // The tech tier (0..9, same scale as rules.techs[].tier) needed in every // component field to land VegaShips.markFor() on a given Mark. Derived by // inverting markFor's `1 + floor(avg*0.7)` — see VegaShips.js — so a chosen // Mark here produces a design identical to an empire that actually researched // its way there, not an approximation. const MARK_TIER = { 1: 0, 2: 2, 3: 3, 4: 5, 5: 6, 6: 8, 7: 9, }; function knownForMark(rules, mark) { const tier = MARK_TIER[mark] ?? 9; const known = {}; for (const t of rules.techList) if (t.tier <= tier) known[t.id] = true; return known; } const totalLosses = (losses) => losses.reduce((t, l) => t + l.lost, 0); export default class VegaCombatSim extends Phaser.Scene { constructor() { super('MasterVegaSim'); } preload() { // Boots straight out of PreloadScene, so the lazy game art (ships sheet, // sfx, soundtrack) GameRoomScene would normally fetch isn't loaded yet. queueGameAssets(this, 'mastervega'); } create() { this.cameras.main.setBackgroundColor('#03060d'); this.rules = compileRules(this.cache.json.get('mastervega-rules')); const artwork = this.cache.json.get('mastervega-artwork') ?? { sheets: {} }; this.art = ensureSheets(this, this.rules, artwork).keys; try { this.music = new VegaMusic(this, this.cache.json.get('masterofvega-music')); this.music.setCategory('combat'); } catch (err) { /* music is optional */ } // Only the hulls a tactical battle actually cares about — scouts, // transports and colony ships never fire a shot. this.combatHulls = this.rules.hullList.filter((h) => h.role === 'warship' || h.role === 'base'); this.speciesIds = this.rules.speciesList.map((s) => s.id); this.battleType = 'ship'; // 'ship' | 'planet' this.engineMode = 'live'; // 'live' | 'v2' — see the header comment this.trialCount = 200; this.sideA = this.freshSide(); this.sideD = this.freshSide(); this.sideD.defenseHp = PLANET_PRESETS.medium.defenseHp; this.sideD.shieldBonus = PLANET_PRESETS.medium.shieldBonus; this.applyFleetPreset(this.sideA, 'medium', false); this.applyFleetPreset(this.sideD, 'medium', false); this.add.text(GAME_WIDTH / 2, 44, 'MASTER OF VEGA — COMBAT SIMULATOR', { fontFamily: FONT, fontSize: '34px', color: '#cfe8ff', }).setOrigin(0.5); this.add.text(GAME_WIDTH / 2, 82, 'Configure both sides, then watch an animated battle or run a batch of instant trials.', { fontFamily: FONT, fontSize: '17px', color: '#6f8aa8', }).setOrigin(0.5); new Button(this, GAME_WIDTH - 150, 44, 'Exit', uiClick(this, () => { window.location.search = ''; }), { width: 200, height: 44, fontSize: 16, variant: 'ghost' }); this.panelLayer = this.add.container(0, 0); const resultsBg = this.add.rectangle(GAME_WIDTH / 2, 962, 1620, 170, PANEL_BG, 0.6) .setStrokeStyle(2, PANEL_LINE, 0.9); this.resultsText = this.add.text(GAME_WIDTH / 2, 962, 'Run a battle to see results here.', { fontFamily: FONT, fontSize: '20px', color: '#cfe8ff', align: 'center', lineSpacing: 10, }).setOrigin(0.5); this.resultsBg = resultsBg; this.rebuild(); } freshSide() { const counts = {}; for (const h of this.combatHulls) counts[h.id] = 0; return { species: 'human', mark: 4, counts }; } // ------------------------------------------------------------- mutators applyFleetPreset(side, size, rebuild = true) { const p = FLEET_PRESETS[size]; for (const h of this.combatHulls) side.counts[h.id] = p[h.id] ?? 0; if (rebuild) this.rebuild(); } applyPlanetPreset(size) { const p = PLANET_PRESETS[size]; this.sideD.defenseHp = p.defenseHp; this.sideD.shieldBonus = p.shieldBonus; this.rebuild(); } clearSide(side) { for (const h of this.combatHulls) side.counts[h.id] = 0; this.rebuild(); } cycleSpecies(side) { const i = this.speciesIds.indexOf(side.species); side.species = this.speciesIds[(i + 1) % this.speciesIds.length]; this.rebuild(); } cycleMark(side) { side.mark = side.mark >= 7 ? 1 : side.mark + 1; this.rebuild(); } adjustCount(side, hullId, delta) { side.counts[hullId] = Phaser.Math.Clamp((side.counts[hullId] ?? 0) + delta, 0, 999); this.rebuild(); } // --------------------------------------------------------- battle setup sidePower(side) { const known = knownForMark(this.rules, side.mark); const traits = this.rules.species[side.species]?.traits ?? {}; let ships = 0; let power = 0; for (const h of this.combatHulls) { const c = side.counts[h.id] ?? 0; if (c <= 0) continue; ships += c; power += stackPower(designFor(this.rules, known, h.id, traits), c); } return { ships, power }; } buildSideShips(side) { return this.combatHulls .filter((h) => (side.counts[h.id] ?? 0) > 0) .map((h) => ({ hullId: h.id, mark: side.mark, count: side.counts[h.id] })); } buildBattleOpts() { const a = this.sideA; const d = this.sideD; const attacker = { empireIdx: 0, name: 'Attacker', empire: { known: knownForMark(this.rules, a.mark), traits: this.rules.species[a.species]?.traits ?? {} }, ships: this.buildSideShips(a), }; const defender = { empireIdx: 1, name: this.battleType === 'planet' ? 'Planetary Defenses' : 'Defender', empire: { known: knownForMark(this.rules, d.mark), traits: this.rules.species[d.species]?.traits ?? {} }, ships: this.battleType === 'planet' ? [] : this.buildSideShips(d), }; const colony = this.battleType === 'planet' ? { defenseHp: d.defenseHp, shieldBonus: d.shieldBonus } : null; return { attacker, defender, colony }; } battleReady() { const { attacker, defender, colony } = this.buildBattleOpts(); return attacker.ships.length > 0 && (defender.ships.length > 0 || (colony && colony.defenseHp > 0)); } // ---------------------------------------------------------------- run watchBattle() { if (!this.battleReady()) return; if (this.engineMode === 'v2') { // V2 only: a pre-battle prompt for the attacker's ("your") formation // strategy. The defender ("AI") gets no prompt at all — it picks // silently, which CombatV2.createBattle already does on its own for // any side that doesn't supply a valid choice. openFormationPicker(this, (formationStrategy) => this.launchV2Battle(formationStrategy)); return; } const { attacker, defender, colony } = this.buildBattleOpts(); const battle = createBattle(this.rules, { attacker, defender, colony, starIdx: -1, rnd: Math.random, }); this.music?.setCategory('combat'); openCombatView(this, this.rules, battle, this.art, { attackerSpecies: this.sideA.species, defenderSpecies: this.sideD.species, playerSide: null, onDone: (result) => this.showWatchResult(result), }); } launchV2Battle(attackerFormation) { const { attacker, defender, colony } = this.buildBattleOpts(); attacker.formationStrategy = attackerFormation; const battle = CombatV2.createBattle(this.rules, { attacker, defender, colony, starIdx: -1, rnd: Math.random, }); this.music?.setCategory('combat'); openCombatViewV2(this, this.rules, battle, this.art, { attackerSpecies: this.sideA.species, defenderSpecies: this.sideD.species, playerSide: null, onDone: (result) => this.showWatchResult(result, battle), }); } runTrials(n) { if (!this.battleReady()) return; const { attacker, defender, colony } = this.buildBattleOpts(); const engine = this.engineMode === 'v2' ? CombatV2 : { createBattle, runBattle }; let aWins = 0; let dWins = 0; let draws = 0; let roundsSum = 0; let aLossSum = 0; let dLossSum = 0; let planetKills = 0; for (let i = 0; i < n; i += 1) { const battle = engine.createBattle(this.rules, { attacker, defender, colony, starIdx: -1, rnd: Math.random, }); const result = engine.runBattle(battle); if (result.winner === 'attacker') aWins += 1; else if (result.winner === 'defender') dWins += 1; else draws += 1; roundsSum += result.rounds; aLossSum += totalLosses(result.attackerLosses); dLossSum += totalLosses(result.defenderLosses); if (result.planetDestroyed) planetKills += 1; } this.showTrialResults({ n, aWins, dWins, draws, roundsSum, aLossSum, dLossSum, planetKills, }); } showWatchResult(result, battle = null) { const aLoss = totalLosses(result.attackerLosses); const dLoss = totalLosses(result.defenderLosses); const winner = result.winner === 'draw' ? 'Draw — mutual stalemate' : result.winner === 'attacker' ? 'Attacker wins' : 'Defender wins'; let line2 = `Attacker lost ${aLoss} ship${aLoss === 1 ? '' : 's'} · Defender lost ${dLoss} ship${dLoss === 1 ? '' : 's'}`; if (this.battleType === 'planet') { line2 += result.planetDestroyed ? ' · Planetary defenses destroyed' : ` · Planetary defenses at ${Math.round(result.planetDefenseLeft)} HP`; } let text = `LAST BATTLE — ${winner} in ${result.rounds} round${result.rounds === 1 ? '' : 's'}\n${line2}`; if (battle?.attackerFormation) { text += `\nAttacker formation: ${formationName(battle.attackerFormation)}` + ` · Defender formation: ${formationName(battle.defenderFormation)}`; } this.resultsText.setText(text); } showTrialResults({ n, aWins, dWins, draws, roundsSum, aLossSum, dLossSum, planetKills, }) { const pct = (x) => ((x / n) * 100).toFixed(1); let text = `${n} TRIALS — Attacker wins ${pct(aWins)}% · Defender wins ${pct(dWins)}% · Draws ${pct(draws)}%\n` + `Avg rounds ${(roundsSum / n).toFixed(1)} · Avg attacker losses ${(aLossSum / n).toFixed(1)} ships` + ` · Avg defender losses ${(dLossSum / n).toFixed(1)} ships`; if (this.battleType === 'planet') text += `\nPlanetary defenses destroyed in ${pct(planetKills)}% of trials`; this.resultsText.setText(text); } // --------------------------------------------------------------- draw rebuild() { this.panelLayer.removeAll(true); this.drawModeToggle(); this.drawEngineToggle(); this.drawSide(COL_A_X, this.sideA, false, 'ATTACKER FLEET', ATTACKER_COLOR); this.drawSide(COL_D_X, this.sideD, true, this.battleType === 'planet' ? 'PLANETARY DEFENSES' : 'DEFENDER FLEET', DEFENDER_COLOR); this.drawRunControls(); } // Side by side in one row (not a second row) so neither the side panels // below (which start at TOP=170) nor the run controls further down need // to move for it. drawModeToggle() { const label = this.battleType === 'ship' ? 'Battle Type: Fleet vs Fleet ▸' : 'Battle Type: Fleet vs Planet ▸'; const btn = new Button(this, GAME_WIDTH / 2 - 235, 130, label, uiClick(this, () => { this.battleType = this.battleType === 'ship' ? 'planet' : 'ship'; this.rebuild(); }), { width: 440, height: 48, fontSize: 18, variant: 'ghost' }); this.panelLayer.add(btn); } // Live and V2 are two completely separate engines/views (see the header // comment) — Watch Battle and Simulate both follow this one switch, so // what you watch and what the trial stats measure never disagree. drawEngineToggle() { const isV2 = this.engineMode === 'v2'; const label = isV2 ? 'Engine: V2 Prototype ▸' : 'Engine: Live ▸'; const btn = new Button(this, GAME_WIDTH / 2 + 235, 130, label, uiClick(this, () => { this.engineMode = isV2 ? 'live' : 'v2'; this.rebuild(); }), { width: 440, height: 48, fontSize: 18, variant: isV2 ? 'solid' : 'ghost', }); this.panelLayer.add(btn); } rowLabel(x0, y, label) { const t = this.add.text(x0 + 24, y, label, { fontFamily: FONT, fontSize: '19px', color: '#cfe0f0' }).setOrigin(0, 0.5); this.panelLayer.add(t); } stepperControl(cx, y, value, onDelta, step) { const minus = new Button(this, cx - 95, y, '−', uiClick(this, () => onDelta(-step)), { width: 40, height: 38, fontSize: 20, variant: 'ghost' }); const valText = this.add.text(cx, y, String(value), { fontFamily: FONT, fontSize: '20px', color: '#f2ead8' }).setOrigin(0.5); const plus = new Button(this, cx + 95, y, '+', uiClick(this, () => onDelta(step)), { width: 40, height: 38, fontSize: 20, variant: 'ghost' }); this.panelLayer.add([minus, valText, plus]); } presetRow(x0, width, y, onPick) { const gap = 12; const bw = (width - 40 - gap * 2) / 3; ['small', 'medium', 'large'].forEach((size, i) => { const bx = x0 + 20 + bw / 2 + i * (bw + gap); const btn = new Button(this, bx, y, size[0].toUpperCase() + size.slice(1), uiClick(this, () => onPick(size)), { width: bw, height: 44, fontSize: 16, variant: 'ghost' }); this.panelLayer.add(btn); }); } drawSide(x0, side, isDefender, title, accentHex) { const cx = x0 + COL_W / 2; const isPlanet = isDefender && this.battleType === 'planet'; const panelH = isPlanet ? 420 : 600; const bg = this.add.rectangle(cx, TOP + panelH / 2, COL_W, panelH, PANEL_BG, 0.55) .setStrokeStyle(2, PANEL_LINE, 0.85); this.panelLayer.add(bg); this.panelLayer.add(this.add.text(cx, TOP + 26, title, { fontFamily: FONT, fontSize: '24px', color: accentHex }).setOrigin(0.5)); let y = TOP + 76; if (!isPlanet) { const spName = this.rules.species[side.species]?.name ?? side.species; this.rowLabel(x0, y, 'Species'); const spBtn = new Button(this, x0 + COL_W - 140, y, spName, uiClick(this, () => this.cycleSpecies(side)), { width: 220, height: 40, fontSize: 17, variant: 'ghost' }); this.panelLayer.add(spBtn); y += 56; this.rowLabel(x0, y, 'Tech Level'); const markBtn = new Button(this, x0 + COL_W - 140, y, `Mark ${markNumeral(side.mark)}`, uiClick(this, () => this.cycleMark(side)), { width: 220, height: 40, fontSize: 17, variant: 'ghost' }); this.panelLayer.add(markBtn); y += 70; for (const h of this.combatHulls) { this.rowLabel(x0, y, h.name); const count = side.counts[h.id] ?? 0; this.stepperControl(x0 + COL_W - 170, y, count, (d) => this.adjustCount(side, h.id, d), 1); y += 56; } y += 14; this.presetRow(x0, COL_W, y, (size) => this.applyFleetPreset(side, size)); y += 60; const clearBtn = new Button(this, cx, y, 'Clear', uiClick(this, () => this.clearSide(side)), { width: 160, height: 38, fontSize: 15, variant: 'ghost' }); this.panelLayer.add(clearBtn); y += 48; const { ships, power } = this.sidePower(side); this.panelLayer.add(this.add.text(cx, y, `${ships} ship${ships === 1 ? '' : 's'} · power score ${power.toLocaleString()}`, { fontFamily: FONT, fontSize: '16px', color: '#6f8aa8', }).setOrigin(0.5)); } else { this.rowLabel(x0, y, 'Defense HP'); this.stepperControl(x0 + COL_W - 170, y, side.defenseHp, (d) => { side.defenseHp = Phaser.Math.Clamp(side.defenseHp + d, 0, 5000); this.rebuild(); }, 50); y += 56; this.rowLabel(x0, y, 'Shield Bonus'); this.stepperControl(x0 + COL_W - 170, y, side.shieldBonus, (d) => { side.shieldBonus = Phaser.Math.Clamp(side.shieldBonus + d, 0, 10); this.rebuild(); }, 1); y += 70; this.presetRow(x0, COL_W, y, (size) => this.applyPlanetPreset(size)); y += 80; this.panelLayer.add(this.add.text(cx, y, 'No orbiting ships — the attacker must break the planetary\ndefenses alone before the colony can be invaded.', { fontFamily: FONT, fontSize: '15px', color: '#5a7086', align: 'center', lineSpacing: 6, }).setOrigin(0.5)); } } drawRunControls() { const ready = this.battleReady(); const y = 830; const watchBtn = new Button(this, GAME_WIDTH / 2 - 260, y, 'Watch Battle ▶', uiClick(this, () => this.watchBattle()), { width: 300, height: 58, fontSize: 20 }); watchBtn.setEnabled(ready); this.panelLayer.add(watchBtn); const trialBtn = new Button(this, GAME_WIDTH / 2 + 10, y, `Trials: ${this.trialCount}`, uiClick(this, () => { const i = TRIAL_COUNTS.indexOf(this.trialCount); this.trialCount = TRIAL_COUNTS[(i + 1) % TRIAL_COUNTS.length]; this.rebuild(); }), { width: 180, height: 58, fontSize: 18, variant: 'ghost' }); this.panelLayer.add(trialBtn); const simBtn = new Button(this, GAME_WIDTH / 2 + 260, y, `Simulate ×${this.trialCount}`, uiClick(this, () => this.runTrials(this.trialCount)), { width: 300, height: 58, fontSize: 20 }); simBtn.setEnabled(ready); this.panelLayer.add(simBtn); if (!ready) { const { attacker } = this.buildBattleOpts(); const msg = attacker.ships.length === 0 ? 'Attacker needs at least one ship.' : 'Defender needs ships, or a Battle Type of Fleet vs Planet with Defense HP above 0.'; this.panelLayer.add(this.add.text(GAME_WIDTH / 2, y + 48, msg, { fontFamily: FONT, fontSize: '16px', color: '#e08a8a', }).setOrigin(0.5)); } } }