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:
parent
543f751fe2
commit
80d4a79593
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
@ -930,4 +960,4 @@
|
|||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue