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:
parent
b721b6221e
commit
223c1fd191
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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." },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue