Add Master of Vega combat simulator (`?movsim`)

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.
This commit is contained in:
Brian Fertig 2026-08-09 00:01:15 -06:00
parent 6a24ccd137
commit 016f91349a
5 changed files with 444 additions and 2 deletions

View File

@ -83,7 +83,7 @@
"title": "Meet N Greet" "title": "Meet N Greet"
}] }, }] },
"rrashaa": { "tracks": [{ "rrashaa": { "tracks": [{
"file": "vega/audience-rrasha.mp3", "file": "vega/audience-rrashaa.mp3",
"artist": "Ursaal", "artist": "Ursaal",
"title": "Meet N Greet" "title": "Meet N Greet"
}] }, }] },

View File

@ -938,7 +938,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
if (transportLayer) return; if (transportLayer) return;
const others = empireColonies(state, colony.empireIdx).filter((c) => c.id !== colony.id); const others = empireColonies(state, colony.empireIdx).filter((c) => c.id !== colony.id);
let dest = others[0] ?? null; let dest = others[0] ?? null;
let amount = 1; let amount = 0;
transportLayer = scene.add.container(0, 0); transportLayer = scene.add.container(0, 0);
root.add(transportLayer); root.add(transportLayer);

View File

@ -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));
}
}
}

View File

@ -104,6 +104,7 @@ import GooTowerGame from './games/gootower/GooTowerGame.js';
import GooTowerEditor from './games/gootower/GooTowerEditor.js'; import GooTowerEditor from './games/gootower/GooTowerEditor.js';
import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js'; import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js';
import MasterOfVegaGame from './games/mastervega/MasterOfVegaGame.js'; import MasterOfVegaGame from './games/mastervega/MasterOfVegaGame.js';
import VegaCombatSim from './games/mastervega/VegaCombatSim.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -220,6 +221,7 @@ const config = {
GooTowerGame, GooTowerGame,
ExcitebikeGame, ExcitebikeGame,
MasterOfVegaGame, MasterOfVegaGame,
VegaCombatSim,
GooTowerEditor, GooTowerEditor,
], ],
}; };

View File

@ -257,6 +257,14 @@ export default class PreloadScene extends Phaser.Scene {
return; 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=<slug> jumps straight to the same place a // Deep link: index.html?game=<slug> jumps straight to the same place a
// main-menu click would (GameMenuScene.openGame's branching logic). // main-menu click would (GameMenuScene.openGame's branching logic).
const deepLinkGame = getGame(params.get('game')); const deepLinkGame = getGame(params.get('game'));