diff --git a/assets/videos/vega/research-kkrix.mp4 b/assets/videos/vega/research-kkrix.mp4 new file mode 100644 index 0000000..5b77a33 Binary files /dev/null and b/assets/videos/vega/research-kkrix.mp4 differ diff --git a/assets/videos/vega/research-mekhan.mp4 b/assets/videos/vega/research-mekhan.mp4 new file mode 100644 index 0000000..30ac45c Binary files /dev/null and b/assets/videos/vega/research-mekhan.mp4 differ diff --git a/assets/videos/vega/research-ursaal.mp4 b/assets/videos/vega/research-ursaal.mp4 new file mode 100644 index 0000000..f35369d Binary files /dev/null and b/assets/videos/vega/research-ursaal.mp4 differ diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index c1eaaf0..a39bca7 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -213,10 +213,10 @@ "researchVideos": { "human": { "key": "vega-research-human", "path": "assets/videos/vega/research-human.mp4" }, "kestrelli": { "key": "vega-research-kestrelli", "path": "assets/videos/vega/research-kestrelli.mp4" }, - "ursaal": { "key": "vega-research-ursaal", "path": null }, + "ursaal": { "key": "vega-research-ursaal", "path": "assets/videos/vega/research-ursaal.mp4" }, "umbrix": { "key": "vega-research-umbrix", "path": null }, - "kkrix": { "key": "vega-research-kkrix", "path": null }, - "mekhan": { "key": "vega-research-mekhan", "path": null }, + "kkrix": { "key": "vega-research-kkrix", "path": "assets/videos/vega/research-kkrix.mp4" }, + "mekhan": { "key": "vega-research-mekhan", "path": "assets/videos/vega/research-mekhan.mp4" }, "rrashaa": { "key": "vega-research-rrashaa", "path": null }, "cerebrai": { "key": "vega-research-cerebrai", "path": "assets/videos/vega/research-cerebrai.mp4" }, "ssakar": { "key": "vega-research-ssakar", "path": "assets/videos/vega/research-ssakar.mp4" }, diff --git a/data/mastervega-rules.json b/data/mastervega-rules.json index f1ab079..1741891 100644 --- a/data/mastervega-rules.json +++ b/data/mastervega-rules.json @@ -372,6 +372,11 @@ { "id": "elissedra", "name": "Elis Sedra", "kind": "captain", "hireCost": 210, "upkeep": 6, "portraitFrame": 15, "skills": { "shipAttack": 25, "shipDefense": 15, "initiative": 2 }, "bio": "Flag officer. Give her a battleship and stay out of the way." } ], + "leaderHiring": { + "_readme": "Each leader already on an empire's roster raises what the NEXT one costs that empire — see VegaLogic.js leaderHireCost. costScalePerOwned 0.5 means: 1st leader = list price, 2nd = 1.5x, 3rd = 2x, 4th = 2.5x, 5th = 3x, 6th (the roster cap) = 3.5x. Keeps the shared 16-leader pool from being cleared cheaply by whoever stockpiles BC fastest, without changing the cost of a first hire at all.", + "costScalePerOwned": 0.5 + }, + "galaxySizes": [ { "id": "small", "name": "Small", "stars": 24, "width": 2600, "height": 1700, "maxEmpires": 4, "desc": "Cramped. You will meet your neighbours early and often." }, { "id": "medium", "name": "Medium", "stars": 36, "width": 3400, "height": 2200, "maxEmpires": 5, "desc": "The standard galaxy." }, diff --git a/src/games/mastervega/VegaLeaders.js b/src/games/mastervega/VegaLeaders.js index fce42d7..73feb38 100644 --- a/src/games/mastervega/VegaLeaders.js +++ b/src/games/mastervega/VegaLeaders.js @@ -3,7 +3,9 @@ // A leader is hired once, costs upkeep forever, and does nothing at all until // posted. Admins run a colony; captains run a fleet. Headless. -import { empireColonies, empireFleets, hireLeader, assignLeader, fleetPower } from './VegaLogic.js'; +import { + empireColonies, empireFleets, hireLeader, assignLeader, fleetPower, leaderHireCost, +} from './VegaLogic.js'; // Leaders are a shared galactic pool — once an empire hires Nyx Holt, nobody // else can. That makes the offer worth taking when it appears. @@ -48,7 +50,7 @@ export function runLeaderTurn(rules, state, e) { const budget = emp.bc * 0.35; for (const offer of leaderOffers(rules, state, e)) { if (emp.leaders.length >= 6) break; - if (offer.hireCost > budget) continue; + if (leaderHireCost(rules, state, e, offer.id) > budget) continue; if (hireLeader(rules, state, e, offer.id)) break; // one hire per turn } diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 735178e..5f2c5c9 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -1967,14 +1967,29 @@ export function moveQueueRun(rules, state, colony, rows, ri, dir) { return true; } +// A leader's list price (rules.leaders[id].hireCost) is fixed; what an +// empire actually pays climbs with how many leaders it already has on +// staff, so stockpiling BC and clearing the shared pool cheaply stops being +// the dominant strategy once a roster gets going. The 1st hire is always +// list price. +export function leaderHireCost(rules, state, e, leaderId) { + const leader = rules.leaders[leaderId]; + if (!leader) return Infinity; + const owned = state.empires[e].leaders.length; + return Math.round(leader.hireCost * (1 + owned * rules.leaderHiring.costScalePerOwned)); +} + export function hireLeader(rules, state, e, leaderId) { const emp = state.empires[e]; const leader = rules.leaders[leaderId]; if (!leader || emp.leaders.some((l) => l.leaderId === leaderId)) return false; - if (emp.bc < leader.hireCost) return false; - emp.bc -= leader.hireCost; + const cost = leaderHireCost(rules, state, e, leaderId); + if (emp.bc < cost) return false; + emp.bc -= cost; emp.leaders.push({ leaderId, assignKind: null, assignId: -1 }); - pushEvent(state, { type: 'leaderHired', empire: e, leaderId, turn: state.turn }); + pushEvent(state, { + type: 'leaderHired', empire: e, leaderId, cost, turn: state.turn, + }); return true; } @@ -1994,6 +2009,15 @@ export function assignLeader(rules, state, e, leaderId, kind, id) { return true; } +export function unassignLeader(rules, state, e, leaderId) { + const emp = state.empires[e]; + const l = emp.leaders.find((x) => x.leaderId === leaderId); + if (!l || l.assignKind === null) return false; + l.assignKind = null; + l.assignId = -1; + return true; +} + // -------------------------------------------------------------------------- // Serialisation diff --git a/src/games/mastervega/VegaRules.js b/src/games/mastervega/VegaRules.js index 1dd6f3c..9113576 100644 --- a/src/games/mastervega/VegaRules.js +++ b/src/games/mastervega/VegaRules.js @@ -18,6 +18,7 @@ export function compileRules(json) { need(json.combat && typeof json.combat === 'object', 'combat block missing'); need(json.council && typeof json.council === 'object', 'council block missing'); need(json.diplomacy && typeof json.diplomacy === 'object', 'diplomacy block missing'); + need(json.leaderHiring && typeof json.leaderHiring === 'object', 'leaderHiring block missing'); if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`); const byId = (list, label) => { @@ -260,6 +261,7 @@ export function compileRules(json) { combat: json.combat, council: json.council, diplomacy: json.diplomacy, + leaderHiring: json.leaderHiring, victory: json.victory ?? { conquest: true, council: true, turnCap: 800 }, starNames: json.starNames, }; diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index bf6699f..4e50ecf 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -7,7 +7,9 @@ import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { Button } from '../../ui/Button.js'; -import { empireColonies, hireLeader } from './VegaLogic.js'; +import { + empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost, +} from './VegaLogic.js'; import { attitudeOf, moodOf, powerOf, canNegotiate, } from './VegaDiplomacy.js'; @@ -252,6 +254,47 @@ export function openCouncilScreen(scene, rules, state, onClose) { // -------------------------------------------------------------------------- +/** "3× Cruiser, 2× Frigate (at Vega Prime)" / "(en route to Vega Prime)". */ +function fleetLabel(rules, state, f) { + const ships = f.ships.filter((s) => s.count > 0) + .map((s) => `${s.count}× ${rules.hulls[s.hullId]?.name}`).join(', ') || 'empty fleet'; + const where = f.starIdx >= 0 + ? `at ${state.galaxy.stars[f.starIdx]?.name}` + : `en route to ${state.galaxy.stars[f.toStar]?.name ?? '?'}`; + return `${ships} (${where})`; +} + +/** A vertical list picker overlaid on `shell` — same mechanism as + * VegaAudience.js's pickFromList, generalised to any {label, onPick} list + * since this screen has no existing picker of its own. */ +function openListPicker(scene, shell, title, items) { + const bx = GAME_WIDTH / 2; + const by = GAME_HEIGHT / 2; + const box = scene.add.container(0, 0); + shell.add(box); + const h = Math.min(520, 100 + Math.max(items.length, 1) * 34); + box.add(scene.add.rectangle(bx, by, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001).setOrigin(0.5).setInteractive()); + box.add(scene.add.rectangle(bx, by, 460, h, PANEL, 0.98).setStrokeStyle(2, ACCENT)); + box.add(scene.add.text(bx, by - h / 2 + 26, title, { fontFamily: FONT, fontSize: '19px', color: '#ffd88a' }).setOrigin(0.5)); + if (!items.length) { + box.add(scene.add.text(bx, by, 'Nothing available.', { + fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', + }).setOrigin(0.5)); + } + items.slice(0, 12).forEach(([label, fn], i) => { + const t = scene.add.text(bx, by - h / 2 + 66 + i * 34, label, { + fontFamily: FONT, fontSize: '16px', color: '#e8f4ff', + }).setOrigin(0.5).setInteractive({ useHandCursor: true }); + t.on('pointerover', () => t.setColor('#ffd88a')); + t.on('pointerout', () => t.setColor('#e8f4ff')); + t.on('pointerdown', () => { box.destroy(true); fn(); }); + box.add(t); + }); + const cancel = new Button(scene, bx, by + h / 2 - 34, 'Cancel', () => box.destroy(true), + { width: 160, height: 40, fontSize: 16, variant: 'ghost' }); + box.add(cancel); +} + export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged) { const shell = modalShell(scene, 'Leaders', onClose, { width: 1180, height: 760 }); const emp = state.empires[e]; @@ -261,7 +304,8 @@ export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged })); let y = shell.body.y + 34; for (const offer of leaderOffers(rules, state, e)) { - const affordable = emp.bc >= offer.hireCost; + const cost = leaderHireCost(rules, state, e, offer.id); + const affordable = emp.bc >= cost; shell.add(scene.add.image(shell.body.x + 32, y + 30, art.leaders, offer.portraitFrame) .setDisplaySize(60, 60)); shell.add(scene.add.text(shell.body.x + 76, y + 4, `${offer.name} — ${offer.kind}`, { @@ -275,7 +319,7 @@ export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged fontFamily: FONT, fontSize: '13px', color: '#7fd8a0', })); const b = new Button(scene, shell.body.x + shell.body.w - 130, y + 30, - `Hire ${offer.hireCost} BC`, () => { + `Hire ${cost} BC`, () => { if (hireLeader(rules, state, e, offer.id)) { shell.destroy(); openLeaderScreen(scene, rules, state, e, art, onClose, onChanged); @@ -298,18 +342,50 @@ export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged } for (const l of emp.leaders) { const def = rules.leaders[l.leaderId]; - const posting = l.assignKind === 'colony' - ? (empireColonies(state, e).find((c) => c.id === l.assignId)?.starIdx ?? -1) - : -1; - const where = l.assignKind === 'colony' && posting >= 0 - ? state.galaxy.stars[posting].name - : (l.assignKind === 'fleet' ? `Fleet ${l.assignId}` : 'unassigned'); + let where = 'unassigned'; + if (l.assignKind === 'colony') { + const c = empireColonies(state, e).find((c2) => c2.id === l.assignId); + where = c ? `${c.name} (${state.galaxy.stars[c.starIdx]?.name})` : 'unassigned'; + } else if (l.assignKind === 'fleet') { + const f = empireFleets(state, e).find((f2) => f2.id === l.assignId); + where = f ? fleetLabel(rules, state, f) : 'unassigned'; + } shell.add(scene.add.image(shell.body.x + 24, y + 18, art.leaders, def.portraitFrame) .setDisplaySize(40, 40)); shell.add(scene.add.text(shell.body.x + 56, y + 6, `${def.name} — ${def.kind} — ${where} — ${def.upkeep} BC/turn`, { fontFamily: FONT, fontSize: '16px', color: '#c8dcf0', + wordWrap: { width: shell.body.w - 320 }, })); + + // Posting picker: admins list colonies, captains list fleets. Re-opens + // the whole screen on pick/unassign, same refresh pattern the Hire + // button already uses above. + const refresh = () => { + shell.destroy(); + openLeaderScreen(scene, rules, state, e, art, onClose, onChanged); + onChanged?.(); + }; + const openPicker = () => { + const targets = def.kind === 'admin' + ? empireColonies(state, e).map((c) => [ + `${c.name} (${state.galaxy.stars[c.starIdx]?.name})`, + () => { assignLeader(rules, state, e, l.leaderId, 'colony', c.id); refresh(); }, + ]) + : empireFleets(state, e).filter((f) => f.starIdx >= 0 || f.toStar >= 0).map((f) => [ + fleetLabel(rules, state, f), + () => { assignLeader(rules, state, e, l.leaderId, 'fleet', f.id); refresh(); }, + ]); + openListPicker(scene, shell, def.kind === 'admin' ? 'POST TO WHICH COLONY?' : 'POST TO WHICH FLEET?', targets); + }; + shell.add(new Button(scene, shell.body.x + shell.body.w - 110, y + 20, + l.assignKind ? 'Reassign' : 'Assign', openPicker, { width: 118, height: 32, fontSize: 14 })); + if (l.assignKind) { + shell.add(new Button(scene, shell.body.x + shell.body.w - 250, y + 20, 'Unassign', () => { + unassignLeader(rules, state, e, l.leaderId); + refresh(); + }, { width: 118, height: 32, fontSize: 14, variant: 'ghost' })); + } y += 44; } return shell;