feat(civ): add per-leader starting techs, union with trait techs, and setup background

Allow each opponent to declare their own 2-3 starting technologies in
opponents.json, independent of their civ trait. During game creation,
personal and trait-based starting techs are unioned (deduped) so
pioneering leaders with overlapping lists don't double-grant.

- Add optional setup screen background image (civilization-setup-bg)
- Update leader detail panel description to list personal starting techs
- Deduplicate trait + personal techs via Set in CivilizationLogic
- Add validation for startingTechs arrays, tech existence, 2-3 total count,
  and variety across leaders in verifyCivilization
- Add integration tests for personal tech granting and overlap deduplication
This commit is contained in:
Brian Fertig 2026-07-16 10:31:25 -06:00
parent 80d4a79593
commit def5c9904a
5 changed files with 161 additions and 4 deletions

View File

@ -5,6 +5,10 @@
"spriteIndex": 0,
"name": "Cy-Bro",
"trait": "scientific",
"startingTechs": [
"alphabet",
"pottery"
],
"bio": "AI Bro sent from the future to show the superiority of the future.",
"voice": "Legion",
"convo": [
@ -41,6 +45,11 @@
"spriteIndex": 1,
"name": "Brad",
"trait": "wealthy",
"startingTechs": [
"horsebackriding",
"bronzeworking",
"thewheel"
],
"bio": "Came for the honey, stayed for the salmon!",
"voice": "Jewel Weaver - Bear",
"convo": [
@ -77,6 +86,7 @@
"spriteIndex": 2,
"name": "Victor",
"trait": "pioneering",
"startingTechs": [],
"bio": "Patient and calculating. Moves are made using ancient technology.",
"voice": "Starfield - Vasco",
"convo": [
@ -113,6 +123,10 @@
"spriteIndex": 3,
"name": "Croc",
"trait": "commercial",
"startingTechs": [
"bronzeworking",
"masonry"
],
"bio": "Wassssuppp everybody! I'm here for the party!!",
"voice": "Batman - Killer Croc",
"convo": [
@ -149,6 +163,11 @@
"spriteIndex": 4,
"name": "Mario",
"trait": "philosophical",
"startingTechs": [
"alphabet",
"warriorcode",
"thewheel"
],
"bio": "Can you find your way out of my maze of puzzles, games and fun!",
"speech": {
"intro": [
@ -172,6 +191,10 @@
"spriteIndex": 5,
"name": "Ethel",
"trait": "wealthy",
"startingTechs": [
"horsebackriding",
"masonry"
],
"bio": "Thank you for visiting with me.",
"voice": "Betty White",
"convo": [
@ -208,6 +231,10 @@
"spriteIndex": 6,
"name": "Jeff",
"trait": "philosophical",
"startingTechs": [
"horsebackriding",
"pottery"
],
"bio": "I play slow, I win fast!",
"voice": "SlowAltered",
"convo": [
@ -244,6 +271,11 @@
"spriteIndex": 7,
"name": "Gerome",
"trait": "zealous",
"startingTechs": [
"horsebackriding",
"warriorcode",
"masonry"
],
"bio": "I'm here for the thrill of extreme victory!",
"voice": "Mass Effect - Commander Shepard",
"convo": [
@ -280,6 +312,11 @@
"spriteIndex": 8,
"name": "Fireball",
"trait": "industrious",
"startingTechs": [
"alphabet",
"thewheel",
"bronzeworking"
],
"bio": "Come on let's play! I promise not to use my x-ray eyes.",
"voice": "Zero - Borderlands 3",
"convo": [
@ -316,6 +353,11 @@
"spriteIndex": 9,
"name": "Bernie",
"trait": "mercantile",
"startingTechs": [
"pottery",
"ceremonialburial",
"bronzeworking"
],
"bio": "Having fun and playing is the name of the game for me!",
"speech": {
"intro": [
@ -339,6 +381,10 @@
"spriteIndex": 10,
"name": "Jerry",
"trait": "commercial",
"startingTechs": [
"pottery",
"alphabet"
],
"bio": "Well now yall know I want to play some them games!",
"voice": "Life is Strange - Jed Lucan",
"convo": [
@ -375,6 +421,7 @@
"spriteIndex": 11,
"name": "Natasha",
"trait": "pioneering",
"startingTechs": [],
"bio": "Perhaps if I win, you will spill the secrets you've been keeping!",
"voice": "Street Fighter 6 - Marisa",
"convo": [
@ -411,6 +458,10 @@
"spriteIndex": 12,
"name": "Zanthor",
"trait": "philosophical",
"startingTechs": [
"thewheel",
"alphabet"
],
"bio": "Hocus Pocus Alacazam!!!!",
"convo": [
"terry",
@ -446,6 +497,11 @@
"spriteIndex": 14,
"name": "Kona",
"trait": "industrious",
"startingTechs": [
"pottery",
"masonry",
"ceremonialburial"
],
"bio": "Woof! Woof!",
"speech": {
"intro": [
@ -469,6 +525,10 @@
"spriteIndex": 15,
"name": "Michael",
"trait": "commercial",
"startingTechs": [
"warriorcode",
"masonry"
],
"bio": "Hey mon! Yous be knowin' I been wantin' ta play on da Fertig Games for a while now mon!",
"voice": "Djed Spence",
"convo": [
@ -504,6 +564,10 @@
"spriteIndex": 13,
"name": "Juliet",
"trait": "wealthy",
"startingTechs": [
"thewheel",
"alphabet"
],
"bio": "Let's take a break on a warm summer day to spend time playing games!",
"voice": "Street Fighter 6 - Crimson Viper",
"speech": {
@ -535,6 +599,7 @@
"spriteIndex": 16,
"name": "Balam",
"trait": "pioneering",
"startingTechs": [],
"bio": "Mystical Powers and a knack for games.",
"speech": {
"intro": [
@ -565,6 +630,11 @@
"spriteIndex": 17,
"name": "Steve",
"trait": "scientific",
"startingTechs": [
"pottery",
"masonry",
"bronzeworking"
],
"bio": "I traveled WAY to far for this stupid Earth Internet.",
"speech": {
"intro": [
@ -595,6 +665,10 @@
"spriteIndex": 18,
"name": "Beth",
"trait": "industrious",
"startingTechs": [
"alphabet",
"pottery"
],
"bio": "I ain't seen you 'round these parts before.",
"speech": {
"intro": [
@ -625,6 +699,10 @@
"spriteIndex": 19,
"name": "Blackwind",
"trait": "mercantile",
"startingTechs": [
"masonry",
"alphabet"
],
"bio": "Aaaaaaargh! Me be playin' these games matey!",
"voice": "pirate",
"speech": {
@ -656,6 +734,10 @@
"spriteIndex": 20,
"name": "Nadia",
"trait": "scientific",
"startingTechs": [
"bronzeworking",
"horsebackriding"
],
"bio": "She's here from the future to learn our games and study our culture.",
"voice": "Invincible VS - Atom Eve",
"speech": {
@ -687,6 +769,11 @@
"spriteIndex": 21,
"name": "The Smasher",
"trait": "zealous",
"startingTechs": [
"warriorcode",
"pottery",
"ceremonialburial"
],
"bio": "Oooooooh yeah brother!!! You wanna play with The Smasher?!",
"speech": {
"intro": [
@ -717,6 +804,7 @@
"spriteIndex": 22,
"name": "DV-8-2303",
"trait": "pioneering",
"startingTechs": [],
"bio": "Cybernetic Systems online. Activate human defeat protocol... EOF",
"speech": {
"intro": [
@ -747,6 +835,10 @@
"spriteIndex": 23,
"name": "Nicole",
"trait": "scientific",
"startingTechs": [
"pottery",
"ceremonialburial"
],
"bio": "I've disabled the firewall protections on this website. Now to hack the core!",
"voice": "Life is Strange - Rachael Amber",
"speech": {
@ -778,6 +870,10 @@
"spriteIndex": 24,
"name": "Kage",
"trait": "philosophical",
"startingTechs": [
"masonry",
"pottery"
],
"bio": "I have traveled far to play these games. Honor me and let us play!",
"speech": {
"intro": [
@ -808,6 +904,10 @@
"spriteIndex": 25,
"name": "Terry",
"trait": "industrious",
"startingTechs": [
"ceremonialburial",
"masonry"
],
"bio": "I haven't had this much fun since the Mezasoic era!",
"speech": {
"intro": [
@ -838,6 +938,10 @@
"spriteIndex": 26,
"name": "Schooner",
"trait": "commercial",
"startingTechs": [
"ceremonialburial",
"horsebackriding"
],
"bio": "Ahoy you land lovers!",
"voice": "Defiant Calder",
"convo": [
@ -874,6 +978,11 @@
"spriteIndex": 27,
"name": "Maurice",
"trait": "industrious",
"startingTechs": [
"masonry",
"pottery",
"thewheel"
],
"bio": "Bucky Beaver and I are there to TRAP all the fun and games!",
"speech": {
"intro": [
@ -904,6 +1013,10 @@
"spriteIndex": 28,
"name": "Aiko",
"trait": "scientific",
"startingTechs": [
"pottery",
"thewheel"
],
"bio": "From a different galaxy, but has the same great Earth based lifeforms taste for games!",
"speech": {
"intro": [
@ -934,6 +1047,10 @@
"spriteIndex": 29,
"name": "Klaxon",
"trait": "zealous",
"startingTechs": [
"horsebackriding",
"thewheel"
],
"bio": "MISSION OBJECTIVE: Victory.... all other priorities recinded.",
"speech": {
"intro": [

View File

@ -157,6 +157,7 @@ export const MANIFEST = {
// (classic today; asian etc. drop in later with no code change).
civilization: [
{ type: 'json', key: 'civilization-rules', path: 'data/civilization-rules.json' },
image('civilization-setup-bg', 'assets/images/civilization/background.png'),
(scene) => sheetsFrom(scene, 'civilization-artwork',
['terrainSheet', 'resourceSheet', 'improvementSheet', 'unitSheet', 'iconSheet', 'citySheets']),
],

View File

@ -100,6 +100,14 @@ export default class CivilizationGame extends Phaser.Scene {
const root = this.setupRoot;
const cx = GAME_WIDTH / 2;
// Background art is optional (assets/images/civilization/background.png,
// registered in src/data/assetManifest.js) — the scene's base dark fill
// from create() already covers the fallback if it isn't loaded, so this
// is simply skipped when missing rather than needing its own else-branch.
if (this.textures.exists('civilization-setup-bg')) {
root.add(this.add.image(cx, GAME_HEIGHT / 2, 'civilization-setup-bg'));
}
root.add(this.add.text(cx, 52, 'CIVILIZATION', {
fontFamily: 'Righteous', fontSize: '54px', color: COLORS.accentHex,
}).setOrigin(0.5));
@ -245,7 +253,11 @@ export default class CivilizationGame extends Phaser.Scene {
const { nameText, traitText, descText } = this.leaderDetailPanel;
nameText.setText(op?.name ?? '');
traitText.setText(trait ? trait.name : 'Balanced (no bonuses)');
descText.setText(trait ? trait.description : '');
const techNames = (op?.startingTechs ?? []).map((id) => this.rules.techs[id]?.name ?? id);
const lines = [];
if (trait?.description) lines.push(trait.description);
if (techNames.length) lines.push(`Starts knowing ${techNames.join(', ')}`);
descText.setText(lines.join(' • '));
}
hasSave() {
@ -263,7 +275,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, trait: op.trait }));
.map((op) => ({ id: op.id, name: op.name, trait: op.trait, startingTechs: op.startingTechs }));
const seed = (Date.now() % 1000000) + 1;
try {
this.state = Logic.createGame(this.rules, {

View File

@ -116,7 +116,13 @@ export function createGame(rules, opts) {
nameOrder,
score: 0,
};
for (const techId of trait?.startingTechs ?? []) grantTech(rules, state, civ, techId);
// Every leader personally lists 2-3 starting techs (data/opponents.json
// startingTechs) independent of their trait — pioneering leaders already
// get 2 from their trait above and list none of their own, everyone else
// gets their personal pick. Union (not concat) guards against double-
// granting if a personal list ever overlaps a trait-granted tech.
const startingTechs = new Set([...(trait?.startingTechs ?? []), ...(leaders[i].startingTechs ?? [])]);
for (const techId of startingTechs) grantTech(rules, state, civ, techId);
state.civs.push(civ);
state.explored.push(new Array(n).fill(0));
}

View File

@ -197,7 +197,20 @@ 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);
check(`opponent ${op.id} has startingTechs array`, Array.isArray(op.startingTechs));
for (const techId of op.startingTechs ?? []) {
check(`opponent ${op.id} startingTechs ${techId} known tech`, !!RULES.techs[techId]);
}
const traitTechs = RULES.civTraits[op.trait]?.startingTechs ?? [];
const total = new Set([...traitTechs, ...(op.startingTechs ?? [])]).size;
check(`opponent ${op.id} has 2-3 total starting techs`, total === 2 || total === 3, `${total}`);
}
// Some variety across leaders — not every leader with the same trait
// should end up with an identical personal tech list.
const nonPioneering = opponentsJson.opponents.filter((op) => op.trait !== 'pioneering');
const distinctLists = new Set(nonPioneering.map((op) => [...op.startingTechs].sort().join(',')));
check('starting tech lists show variety across leaders', distinctLists.size > 5, `${distinctLists.size}`);
}
// ---------------------------------------------------------------------------
@ -205,17 +218,25 @@ section('1c. trait application');
if (RULES) {
const leaders = [
{ id: 'a', name: 'A', trait: 'scientific' },
{ id: 'a', name: 'A', trait: 'scientific', startingTechs: ['pottery', 'masonry'] },
{ id: 'b', name: 'B', trait: 'wealthy' },
{ id: 'c', name: 'C', trait: 'pioneering' },
{ id: 'd', name: 'D', trait: undefined },
// trait's alphabet/bronzeworking overlaps this leader's own list — the
// union must dedupe, not error or double-grant.
{ id: 'e', name: 'E', trait: 'pioneering', startingTechs: ['alphabet', 'thewheel'] },
];
const st = Logic.createGame(RULES, { sizeId: 'small', seed: 5, difficultyId: 'prince', leaders });
check('personal startingTechs granted', st.civs[0].known.pottery === true && st.civs[0].known.masonry === true);
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}`);
check('overlapping trait + personal techs dedupe to 3 known',
Object.keys(st.civs[4].known).length === 3
&& st.civs[4].known.alphabet && st.civs[4].known.bronzeworking && st.civs[4].known.thewheel,
`${Object.keys(st.civs[4].known).join(',')}`);
// scienceMult/goldMult/shieldMult actually flow into cityYields: two
// synthetic cities sharing the exact same tile/buildings/size, differing