feat(civ): add leader trait system with bonuses, starting techs, and UI

Introduce civTraits data structure with 8 traits (scientific, industrious,
commercial, philosophical, zealous, mercantile, pioneering, wealthy) that
provide science/gold/shield multipliers, starting gold, and starting
technologies. Assign each opponent a trait in opponents.json.

- Apply trait bonuses to starting gold and grant starting techs during
  game creation in CivilizationLogic
- Route trait multipliers into cityYields for science, gold, and production
- Add a leader detail panel to the setup screen showing name, trait name,
  and description, updated live when selecting leaders
- Add validation for civTraits in CivilizationRules (multipliers positive,
  starting techs valid, etc.)
- Add verification tests for trait application and city yield multipliers
- Nudge unit sprite position 5px down for better tile alignment
This commit is contained in:
Brian Fertig 2026-07-16 10:31:02 -06:00
parent 543f751fe2
commit 80d4a79593
7 changed files with 175 additions and 15 deletions

View File

@ -257,6 +257,26 @@
{ "id": "emperor", "name": "Emperor", "humanResearchFactor": 1.2, "aiProdBonus": 1.5, "aiScienceBonus": 1.5, "aiStartUnits": 1, "aiAggression": 0.8 }
],
"civTraits": [
{ "id": "scientific", "name": "Scientific", "description": "+30% Science",
"scienceMult": 1.3, "goldMult": 1, "shieldMult": 1, "startingGold": 0, "startingTechs": [] },
{ "id": "industrious", "name": "Industrious", "description": "+30% Production",
"scienceMult": 1, "goldMult": 1, "shieldMult": 1.3, "startingGold": 0, "startingTechs": [] },
{ "id": "commercial", "name": "Commercial", "description": "+30% Gold",
"scienceMult": 1, "goldMult": 1.3, "shieldMult": 1, "startingGold": 0, "startingTechs": [] },
{ "id": "philosophical", "name": "Philosophical", "description": "+50% Science, -20% Production",
"scienceMult": 1.5, "goldMult": 1, "shieldMult": 0.8, "startingGold": 0, "startingTechs": [] },
{ "id": "zealous", "name": "Zealous", "description": "+50% Production, -20% Gold",
"scienceMult": 1, "goldMult": 0.8, "shieldMult": 1.5, "startingGold": 0, "startingTechs": [] },
{ "id": "mercantile", "name": "Mercantile", "description": "+50% Gold, -20% Science",
"scienceMult": 0.8, "goldMult": 1.5, "shieldMult": 1, "startingGold": 0, "startingTechs": [] },
{ "id": "pioneering", "name": "Pioneering", "description": "Starts knowing Alphabet and Bronze Working",
"scienceMult": 1, "goldMult": 1, "shieldMult": 1, "startingGold": 0,
"startingTechs": ["alphabet", "bronzeworking"] },
{ "id": "wealthy", "name": "Wealthy", "description": "+100 starting Gold",
"scienceMult": 1, "goldMult": 1, "shieldMult": 1, "startingGold": 100, "startingTechs": [] }
],
"worldSizes": [
{ "id": "small", "name": "Small", "cols": 40, "rows": 32, "startSettlers": 1 },
{ "id": "medium", "name": "Medium", "cols": 56, "rows": 44, "startSettlers": 2 },

View File

@ -4,6 +4,7 @@
"id": "cybro",
"spriteIndex": 0,
"name": "Cy-Bro",
"trait": "scientific",
"bio": "AI Bro sent from the future to show the superiority of the future.",
"voice": "Legion",
"convo": [
@ -39,6 +40,7 @@
"id": "brad",
"spriteIndex": 1,
"name": "Brad",
"trait": "wealthy",
"bio": "Came for the honey, stayed for the salmon!",
"voice": "Jewel Weaver - Bear",
"convo": [
@ -74,6 +76,7 @@
"id": "victor",
"spriteIndex": 2,
"name": "Victor",
"trait": "pioneering",
"bio": "Patient and calculating. Moves are made using ancient technology.",
"voice": "Starfield - Vasco",
"convo": [
@ -109,6 +112,7 @@
"id": "croc",
"spriteIndex": 3,
"name": "Croc",
"trait": "commercial",
"bio": "Wassssuppp everybody! I'm here for the party!!",
"voice": "Batman - Killer Croc",
"convo": [
@ -144,6 +148,7 @@
"id": "mario",
"spriteIndex": 4,
"name": "Mario",
"trait": "philosophical",
"bio": "Can you find your way out of my maze of puzzles, games and fun!",
"speech": {
"intro": [
@ -166,6 +171,7 @@
"id": "ethel",
"spriteIndex": 5,
"name": "Ethel",
"trait": "wealthy",
"bio": "Thank you for visiting with me.",
"voice": "Betty White",
"convo": [
@ -201,6 +207,7 @@
"id": "jeff",
"spriteIndex": 6,
"name": "Jeff",
"trait": "philosophical",
"bio": "I play slow, I win fast!",
"voice": "SlowAltered",
"convo": [
@ -236,6 +243,7 @@
"id": "gerome",
"spriteIndex": 7,
"name": "Gerome",
"trait": "zealous",
"bio": "I'm here for the thrill of extreme victory!",
"voice": "Mass Effect - Commander Shepard",
"convo": [
@ -271,6 +279,7 @@
"id": "fireball",
"spriteIndex": 8,
"name": "Fireball",
"trait": "industrious",
"bio": "Come on let's play! I promise not to use my x-ray eyes.",
"voice": "Zero - Borderlands 3",
"convo": [
@ -306,6 +315,7 @@
"id": "bernie",
"spriteIndex": 9,
"name": "Bernie",
"trait": "mercantile",
"bio": "Having fun and playing is the name of the game for me!",
"speech": {
"intro": [
@ -328,6 +338,7 @@
"id": "jerry",
"spriteIndex": 10,
"name": "Jerry",
"trait": "commercial",
"bio": "Well now yall know I want to play some them games!",
"voice": "Life is Strange - Jed Lucan",
"convo": [
@ -363,6 +374,7 @@
"id": "natasha",
"spriteIndex": 11,
"name": "Natasha",
"trait": "pioneering",
"bio": "Perhaps if I win, you will spill the secrets you've been keeping!",
"voice": "Street Fighter 6 - Marisa",
"convo": [
@ -398,6 +410,7 @@
"id": "zanthor",
"spriteIndex": 12,
"name": "Zanthor",
"trait": "philosophical",
"bio": "Hocus Pocus Alacazam!!!!",
"convo": [
"terry",
@ -432,6 +445,7 @@
"id": "kona",
"spriteIndex": 14,
"name": "Kona",
"trait": "industrious",
"bio": "Woof! Woof!",
"speech": {
"intro": [
@ -454,6 +468,7 @@
"id": "michael",
"spriteIndex": 15,
"name": "Michael",
"trait": "commercial",
"bio": "Hey mon! Yous be knowin' I been wantin' ta play on da Fertig Games for a while now mon!",
"voice": "Djed Spence",
"convo": [
@ -488,6 +503,7 @@
"id": "juliet",
"spriteIndex": 13,
"name": "Juliet",
"trait": "wealthy",
"bio": "Let's take a break on a warm summer day to spend time playing games!",
"voice": "Street Fighter 6 - Crimson Viper",
"speech": {
@ -518,6 +534,7 @@
"id": "balam",
"spriteIndex": 16,
"name": "Balam",
"trait": "pioneering",
"bio": "Mystical Powers and a knack for games.",
"speech": {
"intro": [
@ -547,6 +564,7 @@
"id": "steve",
"spriteIndex": 17,
"name": "Steve",
"trait": "scientific",
"bio": "I traveled WAY to far for this stupid Earth Internet.",
"speech": {
"intro": [
@ -576,6 +594,7 @@
"id": "beth",
"spriteIndex": 18,
"name": "Beth",
"trait": "industrious",
"bio": "I ain't seen you 'round these parts before.",
"speech": {
"intro": [
@ -605,6 +624,7 @@
"id": "blackwind",
"spriteIndex": 19,
"name": "Blackwind",
"trait": "mercantile",
"bio": "Aaaaaaargh! Me be playin' these games matey!",
"voice": "pirate",
"speech": {
@ -635,6 +655,7 @@
"id": "nadia",
"spriteIndex": 20,
"name": "Nadia",
"trait": "scientific",
"bio": "She's here from the future to learn our games and study our culture.",
"voice": "Invincible VS - Atom Eve",
"speech": {
@ -665,6 +686,7 @@
"id": "smasher",
"spriteIndex": 21,
"name": "The Smasher",
"trait": "zealous",
"bio": "Oooooooh yeah brother!!! You wanna play with The Smasher?!",
"speech": {
"intro": [
@ -694,6 +716,7 @@
"id": "dv-8-2303",
"spriteIndex": 22,
"name": "DV-8-2303",
"trait": "pioneering",
"bio": "Cybernetic Systems online. Activate human defeat protocol... EOF",
"speech": {
"intro": [
@ -723,6 +746,7 @@
"id": "nicole",
"spriteIndex": 23,
"name": "Nicole",
"trait": "scientific",
"bio": "I've disabled the firewall protections on this website. Now to hack the core!",
"voice": "Life is Strange - Rachael Amber",
"speech": {
@ -753,6 +777,7 @@
"id": "kage",
"spriteIndex": 24,
"name": "Kage",
"trait": "philosophical",
"bio": "I have traveled far to play these games. Honor me and let us play!",
"speech": {
"intro": [
@ -782,6 +807,7 @@
"id": "terry",
"spriteIndex": 25,
"name": "Terry",
"trait": "industrious",
"bio": "I haven't had this much fun since the Mezasoic era!",
"speech": {
"intro": [
@ -811,6 +837,7 @@
"id": "schooner",
"spriteIndex": 26,
"name": "Schooner",
"trait": "commercial",
"bio": "Ahoy you land lovers!",
"voice": "Defiant Calder",
"convo": [
@ -846,6 +873,7 @@
"id": "maurice",
"spriteIndex": 27,
"name": "Maurice",
"trait": "industrious",
"bio": "Bucky Beaver and I are there to TRAP all the fun and games!",
"speech": {
"intro": [
@ -875,6 +903,7 @@
"id": "aiko",
"spriteIndex": 28,
"name": "Aiko",
"trait": "scientific",
"bio": "From a different galaxy, but has the same great Earth based lifeforms taste for games!",
"speech": {
"intro": [
@ -904,6 +933,7 @@
"id": "klaxon",
"spriteIndex": 29,
"name": "Klaxon",
"trait": "zealous",
"bio": "MISSION OBJECTIVE: Victory.... all other priorities recinded.",
"speech": {
"intro": [

View File

@ -151,6 +151,7 @@ export default class CivilizationGame extends Phaser.Scene {
m.ring.setStrokeStyle(3, j === i ? COLORS.gold : COLORS.muted);
m.label.setColor(j === i ? COLORS.goldHex : COLORS.mutedHex);
});
this.updateLeaderDetailPanel();
});
this.leaderMarks.push({ ring, label });
root.add([ring, face, label]);
@ -211,6 +212,40 @@ export default class CivilizationGame extends Phaser.Scene {
}, { width: 280, height: 64, fontSize: 26, variant: 'ghost' });
root.add(resumeBtn);
}
// --- leader details panel (below BEGIN/RESUME), updated live from the
// leader-pick ring handler above — see updateLeaderDetailPanel().
const panelY = oy + 20 + 32 + 24;
const panelW = 900;
const panelH = 150;
const panelBg = this.add.rectangle(cx, panelY + panelH / 2, panelW, panelH, COLORS.panel, 0.92)
.setStrokeStyle(2, COLORS.accent, 0.7);
const nameText = this.add.text(cx, panelY + 26, '', {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
}).setOrigin(0.5);
const traitText = this.add.text(cx, panelY + 66, '', {
fontFamily: FONT, fontSize: '20px', color: COLORS.textHex,
}).setOrigin(0.5);
const descText = this.add.text(cx, panelY + 100, '', {
fontFamily: FONT, fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
root.add([panelBg, nameText, traitText, descText]);
this.leaderDetailPanel = { nameText, traitText, descText };
this.updateLeaderDetailPanel();
}
// Reflects the currently-picked leader's starting-condition trait in the
// setup-screen details panel. Called once when the panel is first built,
// and again from the leader-pick ring's click handler — both share this
// one code path so there's no separate "initial" vs "on click" logic.
updateLeaderDetailPanel() {
if (!this.leaderDetailPanel) return;
const op = this.setupLeaders[this.pickedLeader];
const trait = this.rules.civTraits[op?.trait];
const { nameText, traitText, descText } = this.leaderDetailPanel;
nameText.setText(op?.name ?? '');
traitText.setText(trait ? trait.name : 'Balanced (no bonuses)');
descText.setText(trait ? trait.description : '');
}
hasSave() {
@ -228,7 +263,7 @@ export default class CivilizationGame extends Phaser.Scene {
[rivals[i], rivals[j]] = [rivals[j], rivals[i]];
}
const leaders = [me, ...rivals.slice(0, opponents)]
.map((op) => ({ id: op.id, name: op.name }));
.map((op) => ({ id: op.id, name: op.name, trait: op.trait }));
const seed = (Date.now() % 1000000) + 1;
try {
this.state = Logic.createGame(this.rules, {

View File

@ -88,9 +88,14 @@ export function createGame(rules, opts) {
for (let j = 0; j < numCivs; j += 1) {
if (j !== i) { relations[j] = 'nocontact'; attitude[j] = 0; }
}
state.civs.push({
// Leader starting-condition trait (see data/civilization-rules.json
// civTraits) — trait id may be missing/unknown (old saves, setup-screen
// dummy-leader fallback), every consumer degrades to neutral via ?./??.
const trait = rules.civTraits[leaders[i].trait];
const civ = {
id: i,
leaderId: leaders[i].id,
traitId: leaders[i].trait ?? null,
name: leaders[i].name,
color: rules.playerColors[i % rules.playerColors.length],
human: i === humanIndex,
@ -98,7 +103,7 @@ export function createGame(rules, opts) {
government: 'despotism',
revolutionTurns: 0,
pendingGovernment: null,
gold: 50,
gold: 50 + (trait?.startingGold ?? 0),
beakers: 0,
researching: null,
known: {},
@ -110,7 +115,9 @@ export function createGame(rules, opts) {
nameCursor: i, // stride through the shared shuffled name pool
nameOrder,
score: 0,
});
};
for (const techId of trait?.startingTechs ?? []) grantTech(rules, state, civ, techId);
state.civs.push(civ);
state.explored.push(new Array(n).fill(0));
}
@ -330,9 +337,10 @@ export function cityYields(rules, state, city) {
const anarchy = gov.noScience === true;
const baseGold = Math.floor(netTrade / 2);
const baseScience = anarchy ? 0 : Math.ceil(netTrade / 2);
let goldMult = 1;
let sciMult = 1;
let shieldMult = 1;
const trait = rules.civTraits[civ.traitId];
let goldMult = trait?.goldMult ?? 1;
let sciMult = trait?.scienceMult ?? 1;
let shieldMult = trait?.shieldMult ?? 1;
for (const bId of Object.keys(city.buildings)) {
const b = rules.buildings[bId];
if (!b) continue;

View File

@ -561,14 +561,14 @@ export class CivilizationMapView {
const y = this.isoY(c, r) + TILE_H / 2;
const container = scene.add.container(x, y);
// Sprite mode: lift the unit frame so its bottom edge lands at the tile's
// vertical middle (roughly the middle of the colored civ ring) rather
// than the diamond's lower point, so units read as standing on the tile
// instead of at its front edge.
// Sprite mode: lift the unit frame so its bottom edge lands just below
// the tile's vertical middle (roughly the middle of the colored civ
// ring, nudged 5px down) rather than the diamond's lower point, so units
// read as standing on the tile instead of at its front edge.
const spriteMode = scene.textures.exists('civilization-units');
if (spriteMode) {
const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1);
const img = scene.add.image(0, -UNIT_FRAME_H / 2, 'civilization-units', def.frame);
const img = scene.add.image(0, -UNIT_FRAME_H / 2 + 5, 'civilization-units', def.frame);
container.add([ring, img]);
} else {
const g = scene.add.graphics();
@ -723,7 +723,7 @@ export class CivilizationMapView {
let roundel = null;
if (spriteMode) {
const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1);
img = scene.add.image(0, -UNIT_FRAME_H / 2, 'civilization-units', def.frame);
img = scene.add.image(0, -UNIT_FRAME_H / 2 + 5, 'civilization-units', def.frame);
container.add([ring, img]);
} else {
roundel = scene.add.graphics();

View File

@ -14,6 +14,7 @@ export function compileRules(json) {
need(Array.isArray(json.buildings) && json.buildings.length > 0, 'buildings missing');
need(Array.isArray(json.governments) && json.governments.length > 0, 'governments missing');
need(Array.isArray(json.difficulties) && json.difficulties.length > 0, 'difficulties missing');
need(Array.isArray(json.civTraits) && json.civTraits.length > 0, 'civTraits missing');
if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`);
const byId = (list, label) => {
@ -33,6 +34,7 @@ export function compileRules(json) {
const buildings = byId(json.buildings, 'building');
const governments = byId(json.governments, 'government');
const difficulties = byId(json.difficulties, 'difficulty');
const civTraits = byId(json.civTraits, 'civTrait');
const improvements = byId(json.improvements ?? [], 'improvement');
const worldSizes = byId(json.worldSizes ?? [], 'worldSize');
@ -42,6 +44,15 @@ export function compileRules(json) {
for (const p of t.prereqs) need(!!techs[p], `tech ${t.id} prereq ${p} unknown`);
need(['ancient', 'medieval', 'industrial', 'modern'].includes(t.era), `tech ${t.id} bad era`);
}
for (const ct of json.civTraits) {
need(typeof ct.name === 'string' && ct.name.length > 0, `civTrait ${ct.id} missing name`);
need(typeof ct.description === 'string' && ct.description.length > 0, `civTrait ${ct.id} missing description`);
need(typeof ct.scienceMult === 'number' && ct.scienceMult > 0, `civTrait ${ct.id} bad scienceMult`);
need(typeof ct.goldMult === 'number' && ct.goldMult > 0, `civTrait ${ct.id} bad goldMult`);
need(typeof ct.shieldMult === 'number' && ct.shieldMult > 0, `civTrait ${ct.id} bad shieldMult`);
need(typeof ct.startingGold === 'number' && ct.startingGold >= 0, `civTrait ${ct.id} bad startingGold`);
need(Array.isArray(ct.startingTechs), `civTrait ${ct.id} startingTechs must be an array`);
}
if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`);
// Topological rank: rank 0 = no prereqs; rank(t) = 1 + max(rank(prereqs)).
@ -82,6 +93,9 @@ export function compileRules(json) {
for (const t of json.terrains) {
if (t.transform) need(!!terrains[t.transform], `terrain ${t.id} transform ${t.transform} unknown`);
}
for (const ct of json.civTraits) {
for (const techId of ct.startingTechs) need(!!techs[techId], `civTrait ${ct.id} startingTechs ${techId} unknown`);
}
for (const imp of json.improvements ?? []) {
if (imp.prereq) need(!!techs[imp.prereq], `improvement ${imp.id} prereq tech ${imp.prereq} unknown`);
if (imp.requires) need(!!improvements[imp.requires], `improvement ${imp.id} requires ${imp.requires} unknown`);
@ -107,7 +121,7 @@ export function compileRules(json) {
return {
version: json.version ?? 1,
raw: json,
techs, units, terrains, specials, buildings, governments, difficulties,
techs, units, terrains, specials, buildings, governments, difficulties, civTraits,
improvements, worldSizes,
techList: json.techs,
unitList: json.units,
@ -116,6 +130,7 @@ export function compileRules(json) {
buildingList: json.buildings,
governmentList: json.governments,
difficultyList: json.difficulties,
civTraitList: json.civTraits,
improvementList: json.improvements ?? [],
worldSizeList: json.worldSizes ?? [],
techRank: rank,

View File

@ -141,6 +141,16 @@ if (RULES) {
check('difficulty ordering', RULES.difficultyList[0].aiProdBonus
< RULES.difficultyList[4].aiProdBonus);
// Leader starting-condition traits.
check('8 civ traits', RULES.civTraitList.length === 8, `${RULES.civTraitList.length}`);
for (const id of Object.keys(RULES.civTraits)) {
const t = RULES.civTraits[id];
check(`civTrait ${id} mults positive`,
t.scienceMult > 0 && t.goldMult > 0 && t.shieldMult > 0);
check(`civTrait ${id} startingGold >= 0`, t.startingGold >= 0);
for (const techId of t.startingTechs) check(`civTrait ${id} startingTechs ${techId} known tech`, !!RULES.techs[techId]);
}
// World sizes / colors / names.
check('3 world sizes', RULES.worldSizeList.length === 3);
for (const w of RULES.worldSizeList) check(`world ${w.id} dims`, w.cols >= 32 && w.rows >= 24);
@ -178,6 +188,48 @@ if (RULES) {
}
}
// ---------------------------------------------------------------------------
section('1b. opponents data');
const opponentsJson = JSON.parse(readFileSync(join(root, 'data/opponents.json'), 'utf8'));
check('opponents present', Array.isArray(opponentsJson.opponents) && opponentsJson.opponents.length > 0);
if (RULES) {
for (const op of opponentsJson.opponents ?? []) {
check(`opponent ${op.id} has trait`, typeof op.trait === 'string' && op.trait.length > 0);
check(`opponent ${op.id} trait resolves`, !!RULES.civTraits[op.trait], op.trait);
}
}
// ---------------------------------------------------------------------------
section('1c. trait application');
if (RULES) {
const leaders = [
{ id: 'a', name: 'A', trait: 'scientific' },
{ id: 'b', name: 'B', trait: 'wealthy' },
{ id: 'c', name: 'C', trait: 'pioneering' },
{ id: 'd', name: 'D', trait: undefined },
];
const st = Logic.createGame(RULES, { sizeId: 'small', seed: 5, difficultyId: 'prince', leaders });
check('wealthy gets +100 starting gold', st.civs[1].gold === 150, `${st.civs[1].gold}`);
check('pioneering knows alphabet', st.civs[2].known.alphabet === true);
check('pioneering knows bronzeworking', st.civs[2].known.bronzeworking === true);
check('no-trait civ unaffected', st.civs[3].gold === 50 && Object.keys(st.civs[3].known).length === 0,
`${st.civs[3].gold}`);
// scienceMult/goldMult/shieldMult actually flow into cityYields: two
// synthetic cities sharing the exact same tile/buildings/size, differing
// only in which civ owns them (civ 0 = scientific, civ 3 = no trait). A
// large fixed trade-route amount swamps any terrain-dependent tile yield,
// so the only meaningful difference in output is the trait multiplier —
// deterministic regardless of what terrain worldgen placed at (5,5).
const baseCity = { x: 5, y: 5, worked: [], routes: [{ amount: 200 }], buildings: {}, size: 1 };
const sciY = Logic.cityYields(RULES, st, { ...baseCity, civ: 0 });
const plainY = Logic.cityYields(RULES, st, { ...baseCity, civ: 3 });
check('scientific trait raises science output', sciY.science > plainY.science,
`${sciY.science} vs ${plainY.science}`);
}
// ---------------------------------------------------------------------------
section('2. world generation');