Implement dynamic leader hiring costs and improved assignment UI

- Add scaling leader hire costs based on number of leaders already owned (costScalePerOwned: 0.5), making it progressively more expensive to hire multiple leaders and preventing cheap roster stockpiling
- Add leaderHireCost() function in VegaLogic.js to calculate scaled hire costs
- Add unassignLeader() function to allow removing leader assignments
- Update AI in VegaLeaders.js to use dynamic pricing when deciding to hire leaders
- Add research videos for ursaal, kkrix, and mekhan factions
- Enhance leader screen UI with:
  - Dynamic cost display reflecting current scaling
  - Fleet labeling showing ship composition and location
  - Assign/Reassign buttons with list picker for colonies (admins) and fleets (captains)
  - Unassign button for posted leaders
  - Better location display for assigned leaders
This commit is contained in:
Brian Fertig 2026-08-08 14:10:32 -06:00
parent b721b6221e
commit 223c1fd191
9 changed files with 126 additions and 17 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -213,10 +213,10 @@
"researchVideos": { "researchVideos": {
"human": { "key": "vega-research-human", "path": "assets/videos/vega/research-human.mp4" }, "human": { "key": "vega-research-human", "path": "assets/videos/vega/research-human.mp4" },
"kestrelli": { "key": "vega-research-kestrelli", "path": "assets/videos/vega/research-kestrelli.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 }, "umbrix": { "key": "vega-research-umbrix", "path": null },
"kkrix": { "key": "vega-research-kkrix", "path": null }, "kkrix": { "key": "vega-research-kkrix", "path": "assets/videos/vega/research-kkrix.mp4" },
"mekhan": { "key": "vega-research-mekhan", "path": null }, "mekhan": { "key": "vega-research-mekhan", "path": "assets/videos/vega/research-mekhan.mp4" },
"rrashaa": { "key": "vega-research-rrashaa", "path": null }, "rrashaa": { "key": "vega-research-rrashaa", "path": null },
"cerebrai": { "key": "vega-research-cerebrai", "path": "assets/videos/vega/research-cerebrai.mp4" }, "cerebrai": { "key": "vega-research-cerebrai", "path": "assets/videos/vega/research-cerebrai.mp4" },
"ssakar": { "key": "vega-research-ssakar", "path": "assets/videos/vega/research-ssakar.mp4" }, "ssakar": { "key": "vega-research-ssakar", "path": "assets/videos/vega/research-ssakar.mp4" },

View File

@ -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." } { "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": [ "galaxySizes": [
{ "id": "small", "name": "Small", "stars": 24, "width": 2600, "height": 1700, "maxEmpires": 4, "desc": "Cramped. You will meet your neighbours early and often." }, { "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." }, { "id": "medium", "name": "Medium", "stars": 36, "width": 3400, "height": 2200, "maxEmpires": 5, "desc": "The standard galaxy." },

View File

@ -3,7 +3,9 @@
// A leader is hired once, costs upkeep forever, and does nothing at all until // A leader is hired once, costs upkeep forever, and does nothing at all until
// posted. Admins run a colony; captains run a fleet. Headless. // 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 // Leaders are a shared galactic pool — once an empire hires Nyx Holt, nobody
// else can. That makes the offer worth taking when it appears. // 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; const budget = emp.bc * 0.35;
for (const offer of leaderOffers(rules, state, e)) { for (const offer of leaderOffers(rules, state, e)) {
if (emp.leaders.length >= 6) break; 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 if (hireLeader(rules, state, e, offer.id)) break; // one hire per turn
} }

View File

@ -1967,14 +1967,29 @@ export function moveQueueRun(rules, state, colony, rows, ri, dir) {
return true; 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) { export function hireLeader(rules, state, e, leaderId) {
const emp = state.empires[e]; const emp = state.empires[e];
const leader = rules.leaders[leaderId]; const leader = rules.leaders[leaderId];
if (!leader || emp.leaders.some((l) => l.leaderId === leaderId)) return false; if (!leader || emp.leaders.some((l) => l.leaderId === leaderId)) return false;
if (emp.bc < leader.hireCost) return false; const cost = leaderHireCost(rules, state, e, leaderId);
emp.bc -= leader.hireCost; if (emp.bc < cost) return false;
emp.bc -= cost;
emp.leaders.push({ leaderId, assignKind: null, assignId: -1 }); 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; return true;
} }
@ -1994,6 +2009,15 @@ export function assignLeader(rules, state, e, leaderId, kind, id) {
return true; 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 // Serialisation

View File

@ -18,6 +18,7 @@ export function compileRules(json) {
need(json.combat && typeof json.combat === 'object', 'combat block missing'); need(json.combat && typeof json.combat === 'object', 'combat block missing');
need(json.council && typeof json.council === 'object', 'council block missing'); need(json.council && typeof json.council === 'object', 'council block missing');
need(json.diplomacy && typeof json.diplomacy === 'object', 'diplomacy 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('; ')}`); if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`);
const byId = (list, label) => { const byId = (list, label) => {
@ -260,6 +261,7 @@ export function compileRules(json) {
combat: json.combat, combat: json.combat,
council: json.council, council: json.council,
diplomacy: json.diplomacy, diplomacy: json.diplomacy,
leaderHiring: json.leaderHiring,
victory: json.victory ?? { conquest: true, council: true, turnCap: 800 }, victory: json.victory ?? { conquest: true, council: true, turnCap: 800 },
starNames: json.starNames, starNames: json.starNames,
}; };

View File

@ -7,7 +7,9 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from '../../ui/Button.js';
import { empireColonies, hireLeader } from './VegaLogic.js'; import {
empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost,
} from './VegaLogic.js';
import { import {
attitudeOf, moodOf, powerOf, canNegotiate, attitudeOf, moodOf, powerOf, canNegotiate,
} from './VegaDiplomacy.js'; } 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) { export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged) {
const shell = modalShell(scene, 'Leaders', onClose, { width: 1180, height: 760 }); const shell = modalShell(scene, 'Leaders', onClose, { width: 1180, height: 760 });
const emp = state.empires[e]; 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; let y = shell.body.y + 34;
for (const offer of leaderOffers(rules, state, e)) { 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) shell.add(scene.add.image(shell.body.x + 32, y + 30, art.leaders, offer.portraitFrame)
.setDisplaySize(60, 60)); .setDisplaySize(60, 60));
shell.add(scene.add.text(shell.body.x + 76, y + 4, `${offer.name}${offer.kind}`, { 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', fontFamily: FONT, fontSize: '13px', color: '#7fd8a0',
})); }));
const b = new Button(scene, shell.body.x + shell.body.w - 130, y + 30, 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)) { if (hireLeader(rules, state, e, offer.id)) {
shell.destroy(); shell.destroy();
openLeaderScreen(scene, rules, state, e, art, onClose, onChanged); 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) { for (const l of emp.leaders) {
const def = rules.leaders[l.leaderId]; const def = rules.leaders[l.leaderId];
const posting = l.assignKind === 'colony' let where = 'unassigned';
? (empireColonies(state, e).find((c) => c.id === l.assignId)?.starIdx ?? -1) if (l.assignKind === 'colony') {
: -1; const c = empireColonies(state, e).find((c2) => c2.id === l.assignId);
const where = l.assignKind === 'colony' && posting >= 0 where = c ? `${c.name} (${state.galaxy.stars[c.starIdx]?.name})` : 'unassigned';
? state.galaxy.stars[posting].name } else if (l.assignKind === 'fleet') {
: (l.assignKind === 'fleet' ? `Fleet ${l.assignId}` : 'unassigned'); 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) shell.add(scene.add.image(shell.body.x + 24, y + 18, art.leaders, def.portraitFrame)
.setDisplaySize(40, 40)); .setDisplaySize(40, 40));
shell.add(scene.add.text(shell.body.x + 56, y + 6, shell.add(scene.add.text(shell.body.x + 56, y + 6,
`${def.name}${def.kind}${where}${def.upkeep} BC/turn`, { `${def.name}${def.kind}${where}${def.upkeep} BC/turn`, {
fontFamily: FONT, fontSize: '16px', color: '#c8dcf0', 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; y += 44;
} }
return shell; return shell;