From 016f91349ac0d358b55dd77dc10f4584e422ab23 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 9 Aug 2026 00:01:15 -0600 Subject: [PATCH] Add Master of Vega combat simulator (`?movsim`) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces VegaCombatSim, a standalone dev tool for tuning and testing VegaCombat. It allows configuring attacker/defender fleets (or fleet vs planet), watching animated battles on the real tactical screen, and running batch trials for win-rate statistics. Both paths use the exact same VegaCombat stepper as the live game. Also fixes a transport unit amount default (1 → 0) in VegaColonyView and corrects a music file reference in the JSON data file. --- data/masterofvega-music.json | 2 +- src/games/mastervega/VegaColonyView.js | 2 +- src/games/mastervega/VegaCombatSim.js | 432 +++++++++++++++++++++++++ src/main.js | 2 + src/scenes/PreloadScene.js | 8 + 5 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 src/games/mastervega/VegaCombatSim.js diff --git a/data/masterofvega-music.json b/data/masterofvega-music.json index 94dd43c..5e5bdb3 100644 --- a/data/masterofvega-music.json +++ b/data/masterofvega-music.json @@ -83,7 +83,7 @@ "title": "Meet N Greet" }] }, "rrashaa": { "tracks": [{ - "file": "vega/audience-rrasha.mp3", + "file": "vega/audience-rrashaa.mp3", "artist": "Ursaal", "title": "Meet N Greet" }] }, diff --git a/src/games/mastervega/VegaColonyView.js b/src/games/mastervega/VegaColonyView.js index 5834360..b916b28 100644 --- a/src/games/mastervega/VegaColonyView.js +++ b/src/games/mastervega/VegaColonyView.js @@ -938,7 +938,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { if (transportLayer) return; const others = empireColonies(state, colony.empireIdx).filter((c) => c.id !== colony.id); let dest = others[0] ?? null; - let amount = 1; + let amount = 0; transportLayer = scene.add.container(0, 0); root.add(transportLayer); diff --git a/src/games/mastervega/VegaCombatSim.js b/src/games/mastervega/VegaCombatSim.js new file mode 100644 index 0000000..12df88a --- /dev/null +++ b/src/games/mastervega/VegaCombatSim.js @@ -0,0 +1,432 @@ +// 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. +// +// 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 '../../ui/Button.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 { VegaMusic } from './VegaMusic.js'; +import { FONT, uiClick } 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.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; + 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), + }); + } + + runTrials(n) { + if (!this.battleReady()) return; + const { attacker, defender, colony } = this.buildBattleOpts(); + 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 = createBattle(this.rules, { + attacker, defender, colony, starIdx: -1, rnd: Math.random, + }); + const result = 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) { + 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`; + } + this.resultsText.setText(`LAST BATTLE — ${winner} in ${result.rounds} round${result.rounds === 1 ? '' : 's'}\n${line2}`); + } + + 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.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(); + } + + drawModeToggle() { + const label = this.battleType === 'ship' ? 'Battle Type: Fleet vs Fleet ▸' : 'Battle Type: Fleet vs Planet ▸'; + const btn = new Button(this, GAME_WIDTH / 2, 130, label, uiClick(this, () => { + this.battleType = this.battleType === 'ship' ? 'planet' : 'ship'; + this.rebuild(); + }), { width: 440, height: 48, fontSize: 19, variant: '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)); + } + } +} diff --git a/src/main.js b/src/main.js index 5b210d6..c6c4297 100644 --- a/src/main.js +++ b/src/main.js @@ -104,6 +104,7 @@ import GooTowerGame from './games/gootower/GooTowerGame.js'; import GooTowerEditor from './games/gootower/GooTowerEditor.js'; import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js'; import MasterOfVegaGame from './games/mastervega/MasterOfVegaGame.js'; +import VegaCombatSim from './games/mastervega/VegaCombatSim.js'; const config = { type: Phaser.AUTO, @@ -220,6 +221,7 @@ const config = { GooTowerGame, ExcitebikeGame, MasterOfVegaGame, + VegaCombatSim, GooTowerEditor, ], }; diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index bb31f67..112ae68 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -257,6 +257,14 @@ export default class PreloadScene extends Phaser.Scene { return; } + // Master of Vega combat simulator: ?movsim boots straight into a + // fleet-vs-fleet / fleet-vs-planet skirmish testbed for VegaCombat. + if (params.has('movsim')) { + window.history.replaceState(null, '', window.location.pathname + window.location.hash); + this.scene.start('MasterVegaSim'); + return; + } + // Deep link: index.html?game= jumps straight to the same place a // main-menu click would (GameMenuScene.openGame's branching logic). const deepLinkGame = getGame(params.get('game'));