diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index 262d8bc..6bda30e 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index a60e230..30c662e 100644 Binary files a/assets/images/game-icons.psd and b/assets/images/game-icons.psd differ diff --git a/data/civilization-artwork.json b/data/civilization-artwork.json new file mode 100644 index 0000000..d9936a5 --- /dev/null +++ b/data/civilization-artwork.json @@ -0,0 +1,19 @@ +{ + "_readme": [ + "Drop-in art for Civilization. Full spec with frame maps: src/games/civilization/sprites.md.", + "Every sheet is optional: path null = not painted yet, the game renders that layer procedurally.", + "To ship a sheet: put the PNG at the documented path under public/assets/images/civilization/", + "and set `path` here (paths are relative to the web root, no leading slash).", + "Frame order is row-major, 0-based. Frame indexes are pinned in data/civilization-rules.json", + "(units[].frame, terrains[].frame, specials[].frame) — append-only, never renumber.", + "citySheets is keyed by theme. Adding e.g. an `asian` entry makes it selectable with no code change." + ], + "terrainSheet": { "key": "civilization-terrain", "path": null, "frameWidth": 128, "frameHeight": 96 }, + "resourceSheet": { "key": "civilization-resources", "path": null, "frameWidth": 64, "frameHeight": 64 }, + "improvementSheet": { "key": "civilization-improvements", "path": null, "frameWidth": 64, "frameHeight": 64 }, + "unitSheet": { "key": "civilization-units", "path": null, "frameWidth": 64, "frameHeight": 64 }, + "iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 }, + "citySheets": { + "classic": { "key": "civilization-cities-classic", "path": null, "frameWidth": 128, "frameHeight": 96 } + } +} diff --git a/data/civilization-rules.json b/data/civilization-rules.json new file mode 100644 index 0000000..f87f03d --- /dev/null +++ b/data/civilization-rules.json @@ -0,0 +1,288 @@ +{ + "_readme": [ + "Civilization (Civ II-lite) rules data. All techs/units/buildings/terrain are data-driven;", + "src/games/civilization/CivilizationRules.js compiles and validates this file, and", + "tools/verifyCivilization.js enforces integrity (acyclic tech DAG, all prereqs resolve, etc.).", + "Simplifications vs Civ II (by design): no happiness/tax sliders, no senate, no wonders,", + "no pollution, no espionage units, no rivers. Cut techs: Theology, Espionage, Fundamentalism,", + "Environmentalism, Genetic Engineering, Recycling. Cut buildings: Temple, Colosseum, Cathedral,", + "Police Station, Mass Transit, Recycling Center, Solar Plant (happiness/pollution systems removed).", + "Frame indexes reference the sheets documented in src/games/civilization/sprites.md;", + "all sheets are optional (procedural fallback), see data/civilization-artwork.json." + ], + "version": 1, + + "techs": [ + { "id": "alphabet", "name": "Alphabet", "era": "ancient", "prereqs": [] }, + { "id": "bronzeworking", "name": "Bronze Working", "era": "ancient", "prereqs": [] }, + { "id": "ceremonialburial", "name": "Ceremonial Burial", "era": "ancient", "prereqs": [] }, + { "id": "horsebackriding", "name": "Horseback Riding", "era": "ancient", "prereqs": [] }, + { "id": "masonry", "name": "Masonry", "era": "ancient", "prereqs": [] }, + { "id": "pottery", "name": "Pottery", "era": "ancient", "prereqs": [] }, + { "id": "thewheel", "name": "The Wheel", "era": "ancient", "prereqs": [] }, + { "id": "warriorcode", "name": "Warrior Code", "era": "ancient", "prereqs": [] }, + { "id": "codeoflaws", "name": "Code of Laws", "era": "ancient", "prereqs": ["alphabet"] }, + { "id": "writing", "name": "Writing", "era": "ancient", "prereqs": ["alphabet"] }, + { "id": "mapmaking", "name": "Map Making", "era": "ancient", "prereqs": ["alphabet"] }, + { "id": "mathematics", "name": "Mathematics", "era": "ancient", "prereqs": ["alphabet", "masonry"] }, + { "id": "currency", "name": "Currency", "era": "ancient", "prereqs": ["bronzeworking"] }, + { "id": "ironworking", "name": "Iron Working", "era": "ancient", "prereqs": ["bronzeworking", "warriorcode"] }, + { "id": "monarchy", "name": "Monarchy", "era": "ancient", "prereqs": ["ceremonialburial", "codeoflaws"] }, + { "id": "polytheism", "name": "Polytheism", "era": "ancient", "prereqs": ["horsebackriding", "ceremonialburial"] }, + { "id": "literacy", "name": "Literacy", "era": "ancient", "prereqs": ["writing", "codeoflaws"] }, + { "id": "therepublic", "name": "The Republic", "era": "ancient", "prereqs": ["codeoflaws", "literacy"] }, + { "id": "philosophy", "name": "Philosophy", "era": "ancient", "prereqs": ["codeoflaws", "literacy"] }, + { "id": "mysticism", "name": "Mysticism", "era": "ancient", "prereqs": ["ceremonialburial", "philosophy"] }, + { "id": "astronomy", "name": "Astronomy", "era": "ancient", "prereqs": ["mysticism", "mathematics"] }, + { "id": "construction", "name": "Construction", "era": "ancient", "prereqs": ["masonry", "currency"] }, + { "id": "trade", "name": "Trade", "era": "ancient", "prereqs": ["currency", "codeoflaws"] }, + { "id": "seafaring", "name": "Seafaring", "era": "ancient", "prereqs": ["pottery", "mapmaking"] }, + + { "id": "feudalism", "name": "Feudalism", "era": "medieval", "prereqs": ["warriorcode", "monarchy"] }, + { "id": "chivalry", "name": "Chivalry", "era": "medieval", "prereqs": ["feudalism", "horsebackriding"] }, + { "id": "monotheism", "name": "Monotheism", "era": "medieval", "prereqs": ["philosophy", "polytheism"] }, + { "id": "banking", "name": "Banking", "era": "medieval", "prereqs": ["trade", "therepublic"] }, + { "id": "university", "name": "University", "era": "medieval", "prereqs": ["astronomy", "philosophy"] }, + { "id": "navigation", "name": "Navigation", "era": "medieval", "prereqs": ["seafaring", "astronomy"] }, + { "id": "physics", "name": "Physics", "era": "medieval", "prereqs": ["literacy", "navigation"] }, + { "id": "engineering", "name": "Engineering", "era": "medieval", "prereqs": ["thewheel", "construction"] }, + { "id": "invention", "name": "Invention", "era": "medieval", "prereqs": ["writing", "engineering"] }, + { "id": "gunpowder", "name": "Gunpowder", "era": "medieval", "prereqs": ["invention", "ironworking"] }, + { "id": "medicine", "name": "Medicine", "era": "medieval", "prereqs": ["philosophy", "trade"] }, + { "id": "bridgebuilding", "name": "Bridge Building", "era": "medieval", "prereqs": ["ironworking", "construction"] }, + { "id": "sanitation", "name": "Sanitation", "era": "medieval", "prereqs": ["engineering", "medicine"] }, + { "id": "economics", "name": "Economics", "era": "medieval", "prereqs": ["banking", "university"] }, + { "id": "chemistry", "name": "Chemistry", "era": "medieval", "prereqs": ["university", "medicine"] }, + { "id": "theoryofgravity", "name": "Theory of Gravity", "era": "medieval", "prereqs": ["astronomy", "physics"] }, + { "id": "magnetism", "name": "Magnetism", "era": "medieval", "prereqs": ["physics", "ironworking"] }, + { "id": "metallurgy", "name": "Metallurgy", "era": "medieval", "prereqs": ["gunpowder", "university"] }, + { "id": "leadership", "name": "Leadership", "era": "medieval", "prereqs": ["chivalry", "gunpowder"] }, + + { "id": "democracy", "name": "Democracy", "era": "industrial", "prereqs": ["banking", "invention"] }, + { "id": "conscription", "name": "Conscription", "era": "industrial", "prereqs": ["democracy", "metallurgy"] }, + { "id": "explosives", "name": "Explosives", "era": "industrial", "prereqs": ["gunpowder", "chemistry"] }, + { "id": "steamengine", "name": "Steam Engine", "era": "industrial", "prereqs": ["physics", "invention"] }, + { "id": "railroad", "name": "Railroad", "era": "industrial", "prereqs": ["steamengine", "bridgebuilding"] }, + { "id": "industrialization", "name": "Industrialization", "era": "industrial", "prereqs": ["railroad", "banking"] }, + { "id": "thecorporation", "name": "The Corporation", "era": "industrial", "prereqs": ["economics", "industrialization"] }, + { "id": "refining", "name": "Refining", "era": "industrial", "prereqs": ["chemistry", "thecorporation"] }, + { "id": "electricity", "name": "Electricity", "era": "industrial", "prereqs": ["metallurgy", "magnetism"] }, + { "id": "steel", "name": "Steel", "era": "industrial", "prereqs": ["electricity", "industrialization"] }, + { "id": "combustion", "name": "Combustion", "era": "industrial", "prereqs": ["refining", "explosives"] }, + { "id": "automobile", "name": "Automobile", "era": "industrial", "prereqs": ["combustion", "steel"] }, + { "id": "tactics", "name": "Tactics", "era": "industrial", "prereqs": ["conscription", "leadership"] }, + { "id": "communism", "name": "Communism", "era": "industrial", "prereqs": ["philosophy", "industrialization"] }, + { "id": "guerrillawarfare", "name": "Guerrilla Warfare", "era": "industrial", "prereqs": ["communism", "tactics"] }, + { "id": "atomictheory", "name": "Atomic Theory", "era": "industrial", "prereqs": ["theoryofgravity", "physics"] }, + { "id": "electronics", "name": "Electronics", "era": "industrial", "prereqs": ["electricity", "engineering"] }, + { "id": "refrigeration", "name": "Refrigeration", "era": "industrial", "prereqs": ["electricity", "sanitation"] }, + { "id": "machinetools", "name": "Machine Tools", "era": "industrial", "prereqs": ["steel", "tactics"] }, + { "id": "flight", "name": "Flight", "era": "industrial", "prereqs": ["combustion", "theoryofgravity"] }, + { "id": "radio", "name": "Radio", "era": "industrial", "prereqs": ["flight", "electricity"] }, + { "id": "amphibiouswarfare", "name": "Amphibious Warfare", "era": "industrial", "prereqs": ["navigation", "tactics"] }, + + { "id": "advancedflight", "name": "Advanced Flight", "era": "modern", "prereqs": ["radio", "machinetools"] }, + { "id": "rocketry", "name": "Rocketry", "era": "modern", "prereqs": ["advancedflight", "electronics"] }, + { "id": "mobilewarfare", "name": "Mobile Warfare", "era": "modern", "prereqs": ["automobile", "tactics"] }, + { "id": "combinedarms", "name": "Combined Arms", "era": "modern", "prereqs": ["mobilewarfare", "advancedflight"] }, + { "id": "massproduction", "name": "Mass Production", "era": "modern", "prereqs": ["automobile", "thecorporation"] }, + { "id": "laborunion", "name": "Labor Union", "era": "modern", "prereqs": ["massproduction", "guerrillawarfare"] }, + { "id": "nuclearfission", "name": "Nuclear Fission", "era": "modern", "prereqs": ["massproduction", "atomictheory"] }, + { "id": "nuclearpower", "name": "Nuclear Power", "era": "modern", "prereqs": ["nuclearfission", "electronics"] }, + { "id": "laser", "name": "Laser", "era": "modern", "prereqs": ["massproduction", "nuclearpower"] }, + { "id": "miniaturization", "name": "Miniaturization", "era": "modern", "prereqs": ["machinetools", "electronics"] }, + { "id": "computers", "name": "Computers", "era": "modern", "prereqs": ["massproduction", "miniaturization"] }, + { "id": "plastics", "name": "Plastics", "era": "modern", "prereqs": ["refining", "spaceflight"] }, + { "id": "spaceflight", "name": "Space Flight", "era": "modern", "prereqs": ["computers", "rocketry"] }, + { "id": "superconductor", "name": "Superconductor", "era": "modern", "prereqs": ["nuclearpower", "laser"] }, + { "id": "robotics", "name": "Robotics", "era": "modern", "prereqs": ["plastics", "mobilewarfare"] }, + { "id": "stealth", "name": "Stealth", "era": "modern", "prereqs": ["superconductor", "advancedflight"] }, + { "id": "fusionpower", "name": "Fusion Power", "era": "modern", "prereqs": ["nuclearpower", "superconductor"] }, + { "id": "futuretech", "name": "Future Tech", "era": "modern", "prereqs": ["fusionpower"], "repeatable": true } + ], + + "units": [ + { "id": "settlers", "name": "Settlers", "domain": "land", "attack": 0, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 40, "prereq": null, "obsoletedBy": "engineers", "flags": ["settler", "noncombat"], "abbr": "ST", "frame": 0 }, + { "id": "engineers", "name": "Engineers", "domain": "land", "attack": 0, "defense": 2, "move": 2, "hp": 20, "fp": 1, "cost": 40, "prereq": "explosives", "obsoletedBy": null, "flags": ["settler", "engineer", "noncombat"], "abbr": "EN", "frame": 1 }, + { "id": "explorer", "name": "Explorer", "domain": "land", "attack": 0, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 30, "prereq": "seafaring", "obsoletedBy": null, "flags": ["ignoreterrain", "noncombat"], "abbr": "EX", "frame": 2 }, + { "id": "caravan", "name": "Caravan", "domain": "land", "attack": 0, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 50, "prereq": "trade", "obsoletedBy": "freight", "flags": ["caravan", "noncombat"], "abbr": "CV", "frame": 3 }, + { "id": "freight", "name": "Freight", "domain": "land", "attack": 0, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 50, "prereq": "thecorporation", "obsoletedBy": null, "flags": ["caravan", "noncombat"], "abbr": "FR", "frame": 4 }, + + { "id": "warriors", "name": "Warriors", "domain": "land", "attack": 1, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 10, "prereq": null, "obsoletedBy": "pikemen", "flags": [], "abbr": "WA", "frame": 5 }, + { "id": "phalanx", "name": "Phalanx", "domain": "land", "attack": 1, "defense": 2, "move": 1, "hp": 10, "fp": 1, "cost": 20, "prereq": "bronzeworking", "obsoletedBy": "pikemen", "flags": [], "abbr": "PH", "frame": 6 }, + { "id": "archers", "name": "Archers", "domain": "land", "attack": 3, "defense": 2, "move": 1, "hp": 10, "fp": 1, "cost": 30, "prereq": "warriorcode", "obsoletedBy": "musketeers", "flags": [], "abbr": "AR", "frame": 7 }, + { "id": "legion", "name": "Legion", "domain": "land", "attack": 4, "defense": 2, "move": 1, "hp": 10, "fp": 1, "cost": 40, "prereq": "ironworking", "obsoletedBy": "musketeers", "flags": [], "abbr": "LG", "frame": 8 }, + { "id": "pikemen", "name": "Pikemen", "domain": "land", "attack": 1, "defense": 2, "move": 1, "hp": 10, "fp": 1, "cost": 20, "prereq": "feudalism", "obsoletedBy": "musketeers", "flags": ["antimounted"], "abbr": "PK", "frame": 9 }, + { "id": "musketeers", "name": "Musketeers", "domain": "land", "attack": 3, "defense": 3, "move": 1, "hp": 20, "fp": 1, "cost": 30, "prereq": "gunpowder", "obsoletedBy": "riflemen", "flags": [], "abbr": "MK", "frame": 10 }, + { "id": "riflemen", "name": "Riflemen", "domain": "land", "attack": 5, "defense": 4, "move": 1, "hp": 20, "fp": 1, "cost": 40, "prereq": "conscription", "obsoletedBy": null, "flags": [], "abbr": "RF", "frame": 11 }, + { "id": "alpinetroops", "name": "Alpine Troops", "domain": "land", "attack": 5, "defense": 5, "move": 1, "hp": 20, "fp": 1, "cost": 50, "prereq": "tactics", "obsoletedBy": null, "flags": ["ignoreterrain"], "abbr": "AL", "frame": 12 }, + { "id": "partisans", "name": "Partisans", "domain": "land", "attack": 4, "defense": 4, "move": 1, "hp": 20, "fp": 1, "cost": 50, "prereq": "guerrillawarfare", "obsoletedBy": null, "flags": ["ignoreterrain"], "abbr": "PS", "frame": 13 }, + { "id": "marines", "name": "Marines", "domain": "land", "attack": 8, "defense": 5, "move": 1, "hp": 20, "fp": 1, "cost": 60, "prereq": "amphibiouswarfare", "obsoletedBy": null, "flags": ["amphibious"], "abbr": "MN", "frame": 14 }, + { "id": "paratroopers", "name": "Paratroopers", "domain": "land", "attack": 6, "defense": 4, "move": 1, "hp": 20, "fp": 1, "cost": 60, "prereq": "combinedarms", "obsoletedBy": null, "flags": ["paradrop"], "abbr": "PT", "frame": 15 }, + { "id": "mechinf", "name": "Mech. Inf.", "domain": "land", "attack": 6, "defense": 6, "move": 3, "hp": 30, "fp": 1, "cost": 50, "prereq": "laborunion", "obsoletedBy": null, "flags": [], "abbr": "MI", "frame": 16 }, + + { "id": "horsemen", "name": "Horsemen", "domain": "land", "attack": 2, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 20, "prereq": "horsebackriding", "obsoletedBy": "knights", "flags": ["mounted"], "abbr": "HS", "frame": 17 }, + { "id": "chariot", "name": "Chariot", "domain": "land", "attack": 3, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 30, "prereq": "thewheel", "obsoletedBy": "knights", "flags": ["mounted"], "abbr": "CH", "frame": 18 }, + { "id": "elephant", "name": "Elephant", "domain": "land", "attack": 4, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 40, "prereq": "polytheism", "obsoletedBy": "crusaders", "flags": ["mounted"], "abbr": "EL", "frame": 19 }, + { "id": "knights", "name": "Knights", "domain": "land", "attack": 4, "defense": 2, "move": 2, "hp": 10, "fp": 1, "cost": 40, "prereq": "chivalry", "obsoletedBy": "dragoons", "flags": ["mounted"], "abbr": "KN", "frame": 20 }, + { "id": "crusaders", "name": "Crusaders", "domain": "land", "attack": 5, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 40, "prereq": "monotheism", "obsoletedBy": "dragoons", "flags": ["mounted"], "abbr": "CS", "frame": 21 }, + { "id": "dragoons", "name": "Dragoons", "domain": "land", "attack": 5, "defense": 2, "move": 2, "hp": 20, "fp": 1, "cost": 50, "prereq": "leadership", "obsoletedBy": "cavalry", "flags": ["mounted"], "abbr": "DG", "frame": 22 }, + { "id": "cavalry", "name": "Cavalry", "domain": "land", "attack": 8, "defense": 3, "move": 2, "hp": 20, "fp": 1, "cost": 60, "prereq": "tactics", "obsoletedBy": "armor", "flags": ["mounted"], "abbr": "CY", "frame": 23 }, + { "id": "armor", "name": "Armor", "domain": "land", "attack": 10, "defense": 5, "move": 3, "hp": 30, "fp": 1, "cost": 80, "prereq": "mobilewarfare", "obsoletedBy": null, "flags": ["mounted"], "abbr": "AM", "frame": 24 }, + + { "id": "catapult", "name": "Catapult", "domain": "land", "attack": 6, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 40, "prereq": "mathematics", "obsoletedBy": "cannon", "flags": [], "abbr": "CP", "frame": 25 }, + { "id": "cannon", "name": "Cannon", "domain": "land", "attack": 8, "defense": 1, "move": 1, "hp": 20, "fp": 1, "cost": 40, "prereq": "metallurgy", "obsoletedBy": "artillery", "flags": [], "abbr": "CN", "frame": 26 }, + { "id": "artillery", "name": "Artillery", "domain": "land", "attack": 10, "defense": 1, "move": 1, "hp": 20, "fp": 2, "cost": 50, "prereq": "machinetools", "obsoletedBy": "howitzer", "flags": [], "abbr": "AT", "frame": 27 }, + { "id": "howitzer", "name": "Howitzer", "domain": "land", "attack": 12, "defense": 2, "move": 2, "hp": 30, "fp": 2, "cost": 70, "prereq": "robotics", "obsoletedBy": null, "flags": ["ignorewalls"], "abbr": "HW", "frame": 28 }, + + { "id": "fighter", "name": "Fighter", "domain": "air", "attack": 4, "defense": 3, "move": 10, "hp": 20, "fp": 2, "cost": 60, "prereq": "flight", "obsoletedBy": "stealthfighter", "flags": ["fighter"], "abbr": "FT", "frame": 29 }, + { "id": "bomber", "name": "Bomber", "domain": "air", "attack": 12, "defense": 1, "move": 8, "hp": 20, "fp": 2, "cost": 120, "prereq": "advancedflight", "obsoletedBy": "stealthbomber", "flags": [], "abbr": "BM", "frame": 30 }, + { "id": "helicopter", "name": "Helicopter", "domain": "air", "attack": 10, "defense": 3, "move": 6, "hp": 20, "fp": 2, "cost": 100, "prereq": "combinedarms", "obsoletedBy": null, "flags": [], "abbr": "HC", "frame": 31 }, + { "id": "stealthfighter", "name": "Stealth Ftr.", "domain": "air", "attack": 8, "defense": 4, "move": 14, "hp": 20, "fp": 2, "cost": 80, "prereq": "stealth", "obsoletedBy": null, "flags": ["fighter"], "abbr": "SF", "frame": 32 }, + { "id": "stealthbomber", "name": "Stealth Bmb.", "domain": "air", "attack": 14, "defense": 5, "move": 12, "hp": 20, "fp": 2, "cost": 160, "prereq": "stealth", "obsoletedBy": null, "flags": [], "abbr": "SB", "frame": 33 }, + { "id": "cruisemsl", "name": "Cruise Msl.", "domain": "air", "attack": 18, "defense": 0, "move": 12, "hp": 10, "fp": 3, "cost": 60, "prereq": "rocketry", "obsoletedBy": null, "flags": ["missile"], "abbr": "CM", "frame": 34 }, + { "id": "nuclearmsl", "name": "Nuclear Msl.", "domain": "air", "attack": 99, "defense": 0, "move": 16, "hp": 10, "fp": 1, "cost": 160, "prereq": "nuclearfission", "obsoletedBy": null, "flags": ["missile", "nuke"], "abbr": "NM", "frame": 35 }, + + { "id": "trireme", "name": "Trireme", "domain": "sea", "attack": 1, "defense": 1, "move": 3, "hp": 10, "fp": 1, "cost": 40, "prereq": "mapmaking", "obsoletedBy": "caravel", "flags": ["coastal"], "cargo": 2, "abbr": "TR", "frame": 36 }, + { "id": "caravel", "name": "Caravel", "domain": "sea", "attack": 2, "defense": 1, "move": 3, "hp": 10, "fp": 1, "cost": 40, "prereq": "navigation", "obsoletedBy": "galleon", "flags": [], "cargo": 3, "abbr": "CL", "frame": 37 }, + { "id": "galleon", "name": "Galleon", "domain": "sea", "attack": 0, "defense": 2, "move": 4, "hp": 20, "fp": 1, "cost": 40, "prereq": "magnetism", "obsoletedBy": "transport", "flags": [], "cargo": 4, "abbr": "GL", "frame": 38 }, + { "id": "frigate", "name": "Frigate", "domain": "sea", "attack": 4, "defense": 2, "move": 4, "hp": 20, "fp": 1, "cost": 50, "prereq": "magnetism", "obsoletedBy": "destroyer", "flags": [], "cargo": 2, "abbr": "FG", "frame": 39 }, + { "id": "ironclad", "name": "Ironclad", "domain": "sea", "attack": 4, "defense": 4, "move": 4, "hp": 30, "fp": 1, "cost": 60, "prereq": "steamengine", "obsoletedBy": "destroyer", "flags": [], "abbr": "IC", "frame": 40 }, + { "id": "destroyer", "name": "Destroyer", "domain": "sea", "attack": 4, "defense": 4, "move": 6, "hp": 30, "fp": 1, "cost": 60, "prereq": "combustion", "obsoletedBy": null, "flags": [], "abbr": "DS", "frame": 41 }, + { "id": "cruiser", "name": "Cruiser", "domain": "sea", "attack": 6, "defense": 6, "move": 5, "hp": 30, "fp": 2, "cost": 80, "prereq": "steel", "obsoletedBy": "aegiscruiser", "flags": [], "abbr": "CU", "frame": 42 }, + { "id": "aegiscruiser", "name": "AEGIS Cruiser", "domain": "sea", "attack": 8, "defense": 8, "move": 5, "hp": 30, "fp": 2, "cost": 100, "prereq": "rocketry", "obsoletedBy": null, "flags": ["aegis"], "abbr": "AE", "frame": 43 }, + { "id": "battleship", "name": "Battleship", "domain": "sea", "attack": 12, "defense": 12, "move": 4, "hp": 40, "fp": 2, "cost": 160, "prereq": "automobile", "obsoletedBy": null, "flags": [], "abbr": "BS", "frame": 44 }, + { "id": "submarine", "name": "Submarine", "domain": "sea", "attack": 10, "defense": 2, "move": 3, "hp": 30, "fp": 2, "cost": 60, "prereq": "combustion", "obsoletedBy": null, "flags": ["submarine"], "abbr": "SM", "frame": 45 }, + { "id": "carrier", "name": "Carrier", "domain": "sea", "attack": 1, "defense": 9, "move": 5, "hp": 40, "fp": 2, "cost": 160, "prereq": "advancedflight", "obsoletedBy": null, "flags": [], "cargoAir": 8, "abbr": "CR", "frame": 46 }, + { "id": "transport", "name": "Transport", "domain": "sea", "attack": 0, "defense": 3, "move": 5, "hp": 30, "fp": 1, "cost": 50, "prereq": "industrialization", "obsoletedBy": null, "flags": [], "cargo": 8, "abbr": "TP", "frame": 47 }, + + { "id": "ssstructural", "name": "SS Structural", "domain": "project", "attack": 0, "defense": 0, "move": 0, "hp": 1, "fp": 1, "cost": 80, "prereq": "spaceflight", "obsoletedBy": null, "flags": ["spaceship"], "abbr": "S1", "frame": 48 }, + { "id": "sscomponent", "name": "SS Component", "domain": "project", "attack": 0, "defense": 0, "move": 0, "hp": 1, "fp": 1, "cost": 160, "prereq": "plastics", "obsoletedBy": null, "flags": ["spaceship"], "abbr": "S2", "frame": 49 }, + { "id": "ssmodule", "name": "SS Module", "domain": "project", "attack": 0, "defense": 0, "move": 0, "hp": 1, "fp": 1, "cost": 320, "prereq": "superconductor", "obsoletedBy": null, "flags": ["spaceship"], "abbr": "S3", "frame": 50 } + ], + + "spaceship": { "structuralNeeded": 8, "componentsNeeded": 4, "modulesNeeded": 3, "travelTurns": 15 }, + + "terrains": [ + { "id": "grassland", "name": "Grassland", "food": 2, "shield": 0, "trade": 0, "move": 1, "defense": 1.0, "irrigate": 1, "mine": null, "transform": null, "water": false, "frame": 0, "color": "#5a9e44" }, + { "id": "plains", "name": "Plains", "food": 1, "shield": 1, "trade": 0, "move": 1, "defense": 1.0, "irrigate": 1, "mine": null, "transform": "grassland", "water": false, "frame": 2, "color": "#b8a04e" }, + { "id": "forest", "name": "Forest", "food": 1, "shield": 2, "trade": 0, "move": 2, "defense": 1.5, "irrigate": null, "mine": null, "transform": "plains", "water": false, "frame": 3, "color": "#2f6b2f" }, + { "id": "hills", "name": "Hills", "food": 1, "shield": 0, "trade": 0, "move": 2, "defense": 2.0, "irrigate": 1, "mine": 3, "transform": null, "water": false, "frame": 4, "color": "#8a7a4a" }, + { "id": "mountains", "name": "Mountains", "food": 0, "shield": 1, "trade": 0, "move": 3, "defense": 3.0, "irrigate": null, "mine": 2, "transform": "hills", "water": false, "frame": 5, "color": "#7d7d85" }, + { "id": "desert", "name": "Desert", "food": 0, "shield": 1, "trade": 0, "move": 1, "defense": 1.0, "irrigate": 1, "mine": 1, "transform": "plains", "water": false, "frame": 6, "color": "#d8c072" }, + { "id": "tundra", "name": "Tundra", "food": 1, "shield": 0, "trade": 0, "move": 1, "defense": 1.0, "irrigate": null, "mine": null, "transform": "plains", "water": false, "frame": 7, "color": "#a8b0a0" }, + { "id": "glacier", "name": "Glacier", "food": 0, "shield": 0, "trade": 0, "move": 2, "defense": 1.0, "irrigate": null, "mine": 1, "transform": "tundra", "water": false, "frame": 8, "color": "#e8f0f4" }, + { "id": "swamp", "name": "Swamp", "food": 1, "shield": 0, "trade": 0, "move": 2, "defense": 1.5, "irrigate": null, "mine": null, "transform": "grassland", "water": false, "frame": 9, "color": "#4a6b52" }, + { "id": "jungle", "name": "Jungle", "food": 1, "shield": 0, "trade": 0, "move": 2, "defense": 1.5, "irrigate": null, "mine": null, "transform": "grassland", "water": false, "frame": 10, "color": "#1f7a3d" }, + { "id": "ocean", "name": "Ocean", "food": 1, "shield": 0, "trade": 2, "move": 1, "defense": 1.0, "irrigate": null, "mine": null, "transform": null, "water": true, "frame": 11, "color": "#2b5f9e" } + ], + "grasslandShieldFrame": 1, + + "specials": [ + { "id": "buffalo", "name": "Buffalo", "terrain": "plains", "food": 1, "shield": 3, "trade": 0, "frame": 0 }, + { "id": "wheat", "name": "Wheat", "terrain": "plains", "food": 3, "shield": 1, "trade": 0, "frame": 1 }, + { "id": "pheasant", "name": "Pheasant", "terrain": "forest", "food": 3, "shield": 2, "trade": 0, "frame": 2 }, + { "id": "silk", "name": "Silk", "terrain": "forest", "food": 1, "shield": 2, "trade": 3, "frame": 3 }, + { "id": "coal", "name": "Coal", "terrain": "hills", "food": 1, "shield": 2, "trade": 0, "frame": 4 }, + { "id": "wine", "name": "Wine", "terrain": "hills", "food": 1, "shield": 0, "trade": 4, "frame": 5 }, + { "id": "gold", "name": "Gold", "terrain": "mountains", "food": 0, "shield": 1, "trade": 6, "frame": 6 }, + { "id": "iron", "name": "Iron", "terrain": "mountains", "food": 0, "shield": 4, "trade": 0, "frame": 7 }, + { "id": "oasis", "name": "Oasis", "terrain": "desert", "food": 3, "shield": 1, "trade": 0, "frame": 8 }, + { "id": "oil", "name": "Oil", "terrain": "desert", "food": 0, "shield": 4, "trade": 0, "frame": 9 }, + { "id": "game", "name": "Game", "terrain": "tundra", "food": 3, "shield": 0, "trade": 0, "frame": 10 }, + { "id": "furs", "name": "Furs", "terrain": "tundra", "food": 2, "shield": 0, "trade": 3, "frame": 11 }, + { "id": "ivory", "name": "Ivory", "terrain": "glacier", "food": 1, "shield": 1, "trade": 4, "frame": 12 }, + { "id": "glacieroil", "name": "Oil", "terrain": "glacier", "food": 0, "shield": 4, "trade": 0, "frame": 13 }, + { "id": "peat", "name": "Peat", "terrain": "swamp", "food": 1, "shield": 4, "trade": 0, "frame": 14 }, + { "id": "spice", "name": "Spice", "terrain": "swamp", "food": 3, "shield": 0, "trade": 4, "frame": 15 }, + { "id": "gems", "name": "Gems", "terrain": "jungle", "food": 1, "shield": 0, "trade": 4, "frame": 16 }, + { "id": "fruit", "name": "Fruit", "terrain": "jungle", "food": 4, "shield": 0, "trade": 1, "frame": 17 }, + { "id": "fish", "name": "Fish", "terrain": "ocean", "food": 3, "shield": 0, "trade": 2, "frame": 18 }, + { "id": "whales", "name": "Whales", "terrain": "ocean", "food": 2, "shield": 2, "trade": 3, "frame": 19 } + ], + + "improvements": [ + { "id": "road", "name": "Road", "work": 2, "prereq": null, "engineerOnly": false }, + { "id": "railroad", "name": "Railroad", "work": 4, "prereq": "railroad", "engineerOnly": false, "requires": "road" }, + { "id": "irrigation", "name": "Irrigation", "work": 3, "prereq": null, "engineerOnly": false }, + { "id": "farmland", "name": "Farmland", "work": 4, "prereq": "refrigeration", "engineerOnly": false, "requires": "irrigation" }, + { "id": "mine", "name": "Mine", "work": 4, "prereq": null, "engineerOnly": false }, + { "id": "fortress", "name": "Fortress", "work": 5, "prereq": "construction", "engineerOnly": false }, + { "id": "transform", "name": "Transform", "work": 8, "prereq": "explosives", "engineerOnly": true } + ], + + "buildings": [ + { "id": "palace", "name": "Palace", "cost": 100, "upkeep": 0, "prereq": "masonry", "requires": null, "effect": "palace" }, + { "id": "barracks", "name": "Barracks", "cost": 40, "upkeep": 1, "prereq": null, "requires": null, "effect": "veterans" }, + { "id": "granary", "name": "Granary", "cost": 60, "upkeep": 1, "prereq": "pottery", "requires": null, "effect": "granary" }, + { "id": "citywalls", "name": "City Walls", "cost": 80, "upkeep": 0, "prereq": "masonry", "requires": null, "effect": "walls", "value": 3 }, + { "id": "courthouse", "name": "Courthouse", "cost": 80, "upkeep": 1, "prereq": "codeoflaws", "requires": null, "effect": "corruption", "value": 0.5 }, + { "id": "library", "name": "Library", "cost": 80, "upkeep": 1, "prereq": "writing", "requires": null, "effect": "science", "value": 0.5 }, + { "id": "marketplace", "name": "Marketplace", "cost": 80, "upkeep": 1, "prereq": "currency", "requires": null, "effect": "gold", "value": 0.5 }, + { "id": "aqueduct", "name": "Aqueduct", "cost": 80, "upkeep": 2, "prereq": "construction", "requires": null, "effect": "sizecap", "value": 8 }, + { "id": "harbor", "name": "Harbor", "cost": 60, "upkeep": 1, "prereq": "seafaring", "requires": null, "effect": "oceanfood", "value": 1 }, + { "id": "university", "name": "University", "cost": 160, "upkeep": 3, "prereq": "university", "requires": "library", "effect": "science", "value": 0.5 }, + { "id": "bank", "name": "Bank", "cost": 120, "upkeep": 2, "prereq": "banking", "requires": "marketplace", "effect": "gold", "value": 0.5 }, + { "id": "sewersystem", "name": "Sewer System", "cost": 120, "upkeep": 2, "prereq": "sanitation", "requires": "aqueduct", "effect": "sizecap", "value": 12 }, + { "id": "stockexchange", "name": "Stock Exchange", "cost": 160, "upkeep": 3, "prereq": "thecorporation", "requires": "bank", "effect": "gold", "value": 0.5 }, + { "id": "coastalfortress", "name": "Coastal Fortress", "cost": 80, "upkeep": 1, "prereq": "metallurgy", "requires": null, "effect": "defensesea", "value": 2 }, + { "id": "factory", "name": "Factory", "cost": 200, "upkeep": 4, "prereq": "industrialization", "requires": null, "effect": "shields", "value": 0.5 }, + { "id": "powerplant", "name": "Power Plant", "cost": 160, "upkeep": 4, "prereq": "refining", "requires": "factory", "effect": "power", "value": 0.25 }, + { "id": "hydroplant", "name": "Hydro Plant", "cost": 240, "upkeep": 4, "prereq": "electronics", "requires": "factory", "effect": "power", "value": 0.25 }, + { "id": "nuclearplant", "name": "Nuclear Plant", "cost": 160, "upkeep": 2, "prereq": "nuclearpower", "requires": "factory", "effect": "power", "value": 0.25 }, + { "id": "mfgplant", "name": "Mfg. Plant", "cost": 320, "upkeep": 6, "prereq": "robotics", "requires": "factory", "effect": "shields", "value": 0.25 }, + { "id": "offshoreplatform", "name": "Offshore Platform", "cost": 160, "upkeep": 3, "prereq": "miniaturization", "requires": null, "effect": "oceanshield", "value": 1 }, + { "id": "airport", "name": "Airport", "cost": 160, "upkeep": 3, "prereq": "radio", "requires": null, "effect": "airport" }, + { "id": "superhighways", "name": "Superhighways", "cost": 120, "upkeep": 5, "prereq": "automobile", "requires": null, "effect": "roadtrade", "value": 0.5 }, + { "id": "researchlab", "name": "Research Lab", "cost": 160, "upkeep": 3, "prereq": "computers", "requires": "university", "effect": "science", "value": 0.5 }, + { "id": "sambattery", "name": "SAM Battery", "cost": 100, "upkeep": 2, "prereq": "rocketry", "requires": null, "effect": "defenseair", "value": 2 }, + { "id": "sdidefense", "name": "SDI Defense", "cost": 200, "upkeep": 4, "prereq": "superconductor", "requires": null, "effect": "sdi" }, + { "id": "supermarket", "name": "Supermarket", "cost": 80, "upkeep": 3, "prereq": "refrigeration", "requires": null, "effect": "farmfood", "value": 0.5 } + ], + + "governments": [ + { "id": "despotism", "name": "Despotism", "prereq": null, "corruptionFactor": 1.0, "despotPenalty": true, "tradeBonus": 0, "flatCorruption": false, "freeUnits": 3, "unitUpkeep": "shield", "settlerFood": 1, "warPenalty": 1, "frame": 10 }, + { "id": "anarchy", "name": "Anarchy", "prereq": null, "corruptionFactor": 1.5, "despotPenalty": true, "tradeBonus": 0, "flatCorruption": false, "freeUnits": 3, "unitUpkeep": "shield", "settlerFood": 1, "warPenalty": 1, "noScience": true, "frame": 11 }, + { "id": "monarchy", "name": "Monarchy", "prereq": "monarchy", "corruptionFactor": 0.7, "despotPenalty": false, "tradeBonus": 0, "flatCorruption": false, "freeUnits": 3, "unitUpkeep": "shield", "settlerFood": 1, "warPenalty": 1, "frame": 12 }, + { "id": "communism", "name": "Communism", "prereq": "communism", "corruptionFactor": 0.4, "despotPenalty": false, "tradeBonus": 0, "flatCorruption": true, "freeUnits": 6, "unitUpkeep": "shield", "settlerFood": 1, "warPenalty": 1, "frame": 13 }, + { "id": "republic", "name": "The Republic", "prereq": "therepublic", "corruptionFactor": 0.3, "despotPenalty": false, "tradeBonus": 1, "flatCorruption": false, "freeUnits": 0, "unitUpkeep": "shield", "settlerFood": 2, "warPenalty": 1, "frame": 14 }, + { "id": "democracy", "name": "Democracy", "prereq": "democracy", "corruptionFactor": 0.0, "despotPenalty": false, "tradeBonus": 1, "flatCorruption": false, "freeUnits": 0, "unitUpkeep": "gold", "settlerFood": 2, "warPenalty": 2, "frame": 15 } + ], + + "difficulties": [ + { "id": "chieftain", "name": "Chieftain", "humanResearchFactor": 0.8, "aiProdBonus": 0.8, "aiScienceBonus": 0.8, "aiStartUnits": 0, "aiAggression": 0.25 }, + { "id": "warlord", "name": "Warlord", "humanResearchFactor": 0.9, "aiProdBonus": 0.9, "aiScienceBonus": 0.9, "aiStartUnits": 0, "aiAggression": 0.4 }, + { "id": "prince", "name": "Prince", "humanResearchFactor": 1.0, "aiProdBonus": 1.0, "aiScienceBonus": 1.0, "aiStartUnits": 0, "aiAggression": 0.5 }, + { "id": "king", "name": "King", "humanResearchFactor": 1.1, "aiProdBonus": 1.25, "aiScienceBonus": 1.25, "aiStartUnits": 1, "aiAggression": 0.65 }, + { "id": "emperor", "name": "Emperor", "humanResearchFactor": 1.2, "aiProdBonus": 1.5, "aiScienceBonus": 1.5, "aiStartUnits": 1, "aiAggression": 0.8 } + ], + + "worldSizes": [ + { "id": "small", "name": "Small", "cols": 40, "rows": 32, "startSettlers": 1 }, + { "id": "medium", "name": "Medium", "cols": 56, "rows": 44, "startSettlers": 2 }, + { "id": "large", "name": "Large", "cols": 72, "rows": 56, "startSettlers": 2 } + ], + + "playerColors": ["#3b82f6", "#ef4444", "#22c55e", "#eab308", "#a855f7", "#06b6d4", "#f97316", "#ec4899"], + + "cityNames": [ + "Rivermouth", "Stonehaven", "Fairview", "Oakdale", "Silverport", "Highgate", "Westmarch", "Eastwick", + "Northolt", "Southmere", "Goldcrest", "Ironforge", "Millbrook", "Ashford", "Briarwood", "Cedar Falls", + "Duskvale", "Elmshade", "Foxglove", "Greenfield", "Harborview", "Ivorydale", "Junction", "Kingsbridge", + "Lakeshore", "Maplewood", "Newhaven", "Oxbow", "Pinecrest", "Quarrytown", "Redcliff", "Sandpoint", + "Thornbury", "Umberfell", "Vineyard", "Willowdale", "Yellowbrook", "Zephyr Bay", "Amberton", "Blackwater", + "Coppermine", "Driftwood", "Emberly", "Frostholm", "Glasswater", "Hearthstone", "Islegard", "Jadeport", + "Kestrel Point", "Larkspur", "Moonwell", "Nightfall", "Opal Coast", "Palisade", "Quietwater", "Ravenrock", + "Starfall", "Tidewater", "Underhill", "Violetgate", "Wheatfield", "Wolfden", "Yarrow", "Zenith" + ], + + "yearCurve": [ + { "until": -1000, "step": 50 }, + { "until": 1, "step": 25 }, + { "until": 1500, "step": 20 }, + { "until": 1750, "step": 10 }, + { "until": 1850, "step": 5 }, + { "until": 1900, "step": 2 }, + { "until": 99999, "step": 1 } + ] +} diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index c7ee57e..74e4daf 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -151,6 +151,15 @@ export const MANIFEST = { return ids.map((id) => image(`peggle-bg-${id}`, `assets/images/peggle/${id}.png`)); }, ], + // Rules data always loads; sprite sheets are optional drop-ins declared in + // data/civilization-artwork.json (spec: src/games/civilization/sprites.md). + // Sheets with path:null stay procedural. citySheets is a map keyed by theme + // (classic today; asian etc. drop in later with no code change). + civilization: [ + { type: 'json', key: 'civilization-rules', path: 'data/civilization-rules.json' }, + (scene) => sheetsFrom(scene, 'civilization-artwork', + ['terrainSheet', 'resourceSheet', 'improvementSheet', 'unitSheet', 'iconSheet', 'citySheets']), + ], }; // Returns the full (normalized) descriptor list for a slug — including assets diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 599e25b..315fb12 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -110,3 +110,4 @@ registerGame({ slug: 'balatro', name: 'Balatro', category: 'cards', cardGame: tr registerGame({ slug: 'peggle', name: 'Peggle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 80 }); registerGame({ slug: 'coloradodefense', name: 'Colorado Defense', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 81 }); registerGame({ slug: 'starcontrol', name: 'Star Control', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 0, iconFrame: 82 }); +registerGame({ slug: 'civilization', name: 'Civilization', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 83 }); diff --git a/src/games/civilization/CivilizationAI.js b/src/games/civilization/CivilizationAI.js new file mode 100644 index 0000000..5d838b7 --- /dev/null +++ b/src/games/civilization/CivilizationAI.js @@ -0,0 +1,643 @@ +// Civilization — AI civ controller. Headless (no Phaser). +// +// runAITurn(rules, state, civIdx) plays one civ's whole turn: strategy pick, +// diplomacy, research, city builds, then unit orders. respondToProposal +// answers human (or other-AI) diplomacy. AI-initiated proposals toward the +// human are queued on state.events as { type: 'aiProposal', keep: true } for +// the scene to present at the start of the human turn. + +import { + rand, randInt, cheb, tileIndex, inBounds, terrainAt, cityAt, unitsAt, + civUnits, civCities, cityById, knownCount, availableTechs, availableUnits, + availableBuildings, canFoundCity, foundCity, setBuild, buyCost, buyBuild, + tryMove, disembark, findPath, startWork, canWork, canEstablishRoute, + establishTradeRoute, resolveAttack, attackerStrength, defenderStrength, + pickDefender, launchSpaceship, canPropose, applyTreaty, declareWar, + exchangeTechs, civPower, cityYields, buildCost, + setResearch, startRevolution, +} from './CivilizationLogic.js'; +import { siteQuality } from './CivilizationWorldGen.js'; + +const MAX_UNIT_STEPS = 40; // per unit per turn, guards against loops + +export function runAITurn(rules, state, civIdx) { + const civ = state.civs[civIdx]; + if (!civ.alive || state.over) return; + + const strategy = computeStrategy(rules, state, civIdx); + doDiplomacy(rules, state, civIdx, strategy); + doResearch(rules, state, civIdx, strategy); + doGovernment(rules, state, civIdx); + for (const city of civCities(state, civIdx)) { + manageCityBuild(rules, state, civIdx, city, strategy); + } + doUnits(rules, state, civIdx, strategy); + + // Launch when ready unless conquest is already in hand. + const ship = civ.spaceship; + if (!ship.launched + && ship.structural >= rules.spaceship.structuralNeeded + && ship.component >= rules.spaceship.componentsNeeded + && ship.module >= rules.spaceship.modulesNeeded) { + launchSpaceship(rules, state, civ); + } +} + +// --------------------------------------------------------------------------- +// Strategy + +export function computeStrategy(rules, state, civIdx) { + const civ = state.civs[civIdx]; + const diff = rules.difficulties[state.difficultyId]; + const myCities = civCities(state, civIdx); + const myPower = civPower(rules, state, civIdx); + + const atWarWith = state.civs.filter((c) => c.alive && c.id !== civIdx + && civ.relations[c.id] === 'war').map((c) => c.id); + + const techTotal = rules.techList.filter((t) => !t.repeatable).length; + const techFrac = knownCount(civ) / techTotal; + const wantsSpace = civ.known.spaceflight || techFrac >= 0.8; + + const settlerCount = civUnits(state, civIdx) + .filter((u) => rules.units[u.type].flags.includes('settler')).length; + const landTiles = state.world.cols * state.world.rows * (state.world.landFraction ?? 0.3); + const targetCities = Math.max(5, Math.round(landTiles / 40)); + const canExpand = myCities.length < targetCities && settlerCount < 3; + + let phase = 'develop'; + if (atWarWith.length) phase = 'war'; + else if (wantsSpace) phase = 'space'; + else if (canExpand || myCities.length === 0) phase = 'expand'; + + return { phase, atWarWith, myPower, aggression: diff.aiAggression, techFrac }; +} + +// --------------------------------------------------------------------------- +// Diplomacy + +function doDiplomacy(rules, state, civIdx, strategy) { + const civ = state.civs[civIdx]; + for (const other of state.civs) { + if (other.id === civIdx || !other.alive) continue; + const rel = civ.relations[other.id]; + if (rel === 'nocontact') continue; + const attitude = civ.attitude[other.id] ?? 0; + const powerRatio = strategy.myPower / Math.max(1, civPower(rules, state, other.id)); + + if (rel === 'war') { + // Sue for peace when clearly losing. + if (powerRatio < 0.6 && canPropose(state, civIdx, other.id, 'ceasefire')) { + proposeOrQueue(rules, state, civIdx, other.id, 'ceasefire'); + } + continue; + } + // Escalate to war: hostile attitude + power advantage + aggression roll, + // opportunism against a much weaker neighbour, or (rarely) a true sneak + // attack on a treaty partner who has fallen far behind. + const sneakBar = rel === 'peace' || rel === 'alliance' ? -70 : -10; + const hostile = attitude <= sneakBar && powerRatio > 1.15; + const opportunist = rel === 'contact' && powerRatio > 1.6 && attitude < 40; + const sneak = (rel === 'peace') && powerRatio > 2.5 && attitude < 0; + if ((hostile || opportunist) && rand(state) < strategy.aggression * 0.3) { + declareWar(rules, state, civIdx, other.id); + continue; + } + if (sneak && rand(state) < strategy.aggression * 0.1) { + declareWar(rules, state, civIdx, other.id); + continue; + } + // Peace-seeking & alliances with actual friends only — a world of + // reflexive peace treaties stalls the game forever. + if (rel === 'ceasefire' && attitude > 0) proposeOrQueue(rules, state, civIdx, other.id, 'peace'); + else if (rel === 'contact' && attitude > 30) proposeOrQueue(rules, state, civIdx, other.id, 'peace'); + else if (rel === 'peace' && attitude > 50 && strategy.atWarWith.length === 0 + && sharedEnemy(state, civIdx, other.id)) { + proposeOrQueue(rules, state, civIdx, other.id, 'alliance'); + } + // Fair tech trades with non-hostile AIs (human trades happen in their UI). + if (!other.human && attitude > 0 && rand(state) < 0.25) { + tryFairExchange(rules, state, civIdx, other.id); + } + } +} + +function sharedEnemy(state, a, b) { + return state.civs.some((c) => c.alive && c.id !== a && c.id !== b + && state.civs[a].relations[c.id] === 'war' && state.civs[b].relations[c.id] === 'war'); +} + +function proposeOrQueue(rules, state, fromIdx, toIdx, kind) { + const target = state.civs[toIdx]; + if (target.human) { + if (!state.events.some((e) => e.type === 'aiProposal' && e.from === fromIdx && e.kind === kind)) { + state.events.push({ type: 'aiProposal', from: fromIdx, to: toIdx, kind, keep: true }); + } + return; + } + if (respondToProposal(rules, state, toIdx, fromIdx, kind)) { + applyTreaty(state, fromIdx, toIdx, kind); + } +} + +export function respondToProposal(rules, state, aiIdx, fromIdx, kind, payload = {}) { + const civ = state.civs[aiIdx]; + const attitude = civ.attitude[fromIdx] ?? 0; + const powerRatio = civPower(rules, state, aiIdx) + / Math.max(1, civPower(rules, state, fromIdx)); + switch (kind) { + case 'ceasefire': + // Winners press on; only accept when not dominant or genuinely friendly. + return powerRatio < 1.1 || attitude > 0; + case 'peace': + return powerRatio < 0.8 || attitude > 20; + case 'alliance': + return attitude > 40 && (sharedEnemy(state, aiIdx, fromIdx) || attitude > 65); + case 'exchange': { + const { giveId, getId } = payload; // from `fromIdx`'s perspective + if (!giveId || !getId) return false; + const rankGive = rules.techRank[giveId] ?? 0; + const rankGet = rules.techRank[getId] ?? 0; + return attitude > -10 && rankGive >= rankGet - 1; + } + case 'tribute': + return powerRatio < 0.6 && (payload.amount ?? 0) <= civ.gold * 0.25; + default: + return false; + } +} + +function tryFairExchange(rules, state, a, b) { + const civA = state.civs[a]; + const civB = state.civs[b]; + const aOffers = Object.keys(civA.known).filter((t) => !civB.known[t]); + const bOffers = Object.keys(civB.known).filter((t) => !civA.known[t]); + if (!aOffers.length || !bOffers.length) return; + aOffers.sort((x, y) => (rules.techRank[x] ?? 0) - (rules.techRank[y] ?? 0)); + bOffers.sort((x, y) => (rules.techRank[x] ?? 0) - (rules.techRank[y] ?? 0)); + const give = aOffers[0]; + const get = bOffers[0]; + if (Math.abs((rules.techRank[give] ?? 0) - (rules.techRank[get] ?? 0)) <= 1) { + exchangeTechs(rules, state, a, b, give, get); + } +} + +// --------------------------------------------------------------------------- +// Research & government + +const SPACE_CHAIN = ['spaceflight', 'plastics', 'superconductor', 'fusionpower']; + +function doResearch(rules, state, civIdx, strategy) { + const civ = state.civs[civIdx]; + if (civ.researching) return; + const options = availableTechs(rules, civ); + if (!options.length) return; + let best = null; + let bestScore = -Infinity; + for (const t of options) { + const g = rules.techGates[t.id]; + let score = rand(state) * 4; // jitter + score += g.prereqOf.length * 2; + const gatesUnits = g.units.length > 0; + const gatesEcon = g.buildings.some((b) => ['science', 'gold', 'shields', 'granary', 'sizecap'] + .includes(rules.buildings[b].effect)); + if (strategy.phase === 'war' && gatesUnits) score += 8; + if (strategy.phase !== 'war' && gatesEcon) score += 6; + if (g.governments.length && strategy.phase !== 'war') score += 5; + if (strategy.phase === 'space' && (SPACE_CHAIN.includes(t.id) + || g.prereqOf.some((p) => SPACE_CHAIN.includes(p)))) score += 12; + if (t.repeatable) score -= 20; + if (score > bestScore) { bestScore = score; best = t; } + } + if (best) setResearch(rules, state, civ, best.id); +} + +function doGovernment(rules, state, civIdx) { + const civ = state.civs[civIdx]; + if (civ.government === 'anarchy') return; + // Simple ladder: despotism -> monarchy -> republic (peace) / communism (war-heavy). + const wants = civ.known.democracy && atPeace(state, civIdx) ? 'democracy' + : civ.known.therepublic && atPeace(state, civIdx) ? 'republic' + : civ.known.communism && !atPeace(state, civIdx) ? 'communism' + : civ.known.monarchy ? 'monarchy' : null; + if (wants && wants !== civ.government + && govRank(wants) > govRank(civ.government)) { + startRevolution(rules, state, civ, wants); + } +} +function atPeace(state, civIdx) { + return !Object.values(state.civs[civIdx].relations).includes('war'); +} +function govRank(id) { + return { despotism: 0, anarchy: -1, monarchy: 1, communism: 2, republic: 2, democracy: 3 }[id] ?? 0; +} + +// --------------------------------------------------------------------------- +// City builds + +const DEVELOP_CHAIN = ['granary', 'library', 'marketplace', 'barracks', 'aqueduct', 'harbor', + 'university', 'bank', 'courthouse', 'sewersystem', 'factory', 'powerplant', 'stockexchange', + 'superhighways', 'researchlab', 'supermarket', 'mfgplant']; + +function manageCityBuild(rules, state, civIdx, city, strategy) { + const civ = state.civs[civIdx]; + const def = rules.units[city.build?.id]; + const midBuild = city.shieldBox > 0 && city.shieldBox < buildCost(rules, city) * 0.9; + const defenders = unitsAt(state, city.x, city.y) + .filter((u) => u.civ === civIdx && rules.units[u.type].domain === 'land' + && !rules.units[u.type].flags.includes('noncombat')); + + // Emergency: garrison first, buy it when the enemy is at the gate. + const wantGarrison = strategy.phase === 'war' ? 2 : 1; + if (defenders.length < wantGarrison) { + const best = bestDefender(rules, state, civ, city); + if (best && city.build?.id !== best.id) setBuild(rules, state, city, 'unit', best.id); + const enemyNear = state.units.some((u) => u.civ !== civIdx + && state.civs[civIdx].relations[u.civ] === 'war' + && !rules.units[u.type].flags.includes('noncombat') + && cheb(u.x, u.y, city.x, city.y) <= 3); + if (defenders.length === 0 && enemyNear && civ.gold > buyCost(rules, city) + 50) { + buyBuild(rules, state, city); + } + return; + } + if (midBuild && city.build.type === 'unit' && def?.flags.includes('spaceship')) return; + if (midBuild && rand(state) < 0.7) return; // usually let builds finish + + // Spaceship race: pour high-shield cities into parts. + if (strategy.phase === 'space') { + const parts = availableUnits(rules, state, civ, city) + .filter((u) => u.flags.includes('spaceship')) + .sort((a, b) => a.cost - b.cost); + if (parts.length) { setBuild(rules, state, city, 'unit', parts[0].id); return; } + } + + // Expansion: settlers from cities that can spare the head. + if (strategy.phase === 'expand' && city.size >= 2) { + const settlerType = civ.known.explosives ? 'engineers' : 'settlers'; + const settlersOut = civUnits(state, civIdx) + .filter((u) => rules.units[u.type].flags.includes('settler')).length; + if (settlersOut < 3 && rand(state) < 0.7) { + setBuild(rules, state, city, 'unit', 'settlers'); + return; + } + if (settlerType === 'engineers' && !civUnits(state, civIdx).some((u) => u.type === 'engineers')) { + setBuild(rules, state, city, 'unit', 'engineers'); + return; + } + } + + // War: attackers with a side of siege. + if (strategy.phase === 'war') { + const units = availableUnits(rules, state, civ, city) + .filter((u) => u.domain === 'land' && !u.flags.includes('noncombat') && !u.flags.includes('spaceship')); + if (units.length) { + const attackers = units.sort((a, b) => b.attack - a.attack); + const pick = rand(state) < 0.3 + ? attackers.find((u) => u.attack >= 6 && u.defense <= 2) ?? attackers[0] + : attackers[0]; + setBuild(rules, state, city, 'unit', pick.id); + return; + } + } + + // Keep a worker corps alive after expansion: roads/irrigation are the whole + // economy, and settlers all get consumed founding cities. + const workers = civUnits(state, civIdx) + .filter((u) => rules.units[u.type].flags.includes('settler')).length; + const wantWorkers = Math.min(3, Math.max(1, Math.ceil(civCities(state, civIdx).length / 2))); + if (workers < wantWorkers && city.size >= 2 && rand(state) < 0.5) { + setBuild(rules, state, city, 'unit', civ.known.explosives ? 'engineers' : 'settlers'); + return; + } + + // Develop: walk the improvement chain; caravans for big trade cities. + const avail = availableBuildings(rules, state, civ, city); + const yields = cityYields(rules, state, city); + if (yields.netTrade >= 8 && city.routes.length < 2 && civ.known.trade && rand(state) < 0.3) { + const cvType = civ.known.thecorporation ? 'freight' : 'caravan'; + setBuild(rules, state, city, 'unit', cvType); + return; + } + for (const id of DEVELOP_CHAIN) { + if (avail.some((b) => b.id === id)) { + // Skip aqueduct/sewer until the city is close to its cap. + if (id === 'aqueduct' && city.size < 6) continue; + if (id === 'sewersystem' && city.size < 10) continue; + setBuild(rules, state, city, 'building', id); + return; + } + } + const fallback = bestDefender(rules, state, civ, city); + if (fallback) setBuild(rules, state, city, 'unit', fallback.id); +} + +function bestDefender(rules, state, civ, city) { + return availableUnits(rules, state, civ, city) + .filter((u) => u.domain === 'land' && !u.flags.includes('noncombat') && !u.flags.includes('spaceship')) + .sort((a, b) => b.defense - a.defense || a.cost - b.cost)[0] ?? null; +} + +// --------------------------------------------------------------------------- +// Units + +function doUnits(rules, state, civIdx, strategy) { + const civ = state.civs[civIdx]; + for (const unit of [...civUnits(state, civIdx)]) { + if (!state.units.includes(unit) || state.over) continue; + if (unit.carriedBy) { handleCargo(rules, state, unit, strategy); continue; } + if (unit.order?.kind === 'work') continue; + const def = rules.units[unit.type]; + if (def.domain === 'project') continue; + let steps = 0; + while (unit.mp > 0 && steps < MAX_UNIT_STEPS && state.units.includes(unit) && !state.over) { + const acted = stepUnit(rules, state, civIdx, unit, strategy); + steps += 1; + if (!acted) break; + } + if (state.units.includes(unit) && !unit.moved && !unit.fortified && def.defense > 0 + && cityAt(state, unit.x, unit.y)) { + unit.fortified = true; + } + } +} + +function stepUnit(rules, state, civIdx, unit, strategy) { + const def = rules.units[unit.type]; + if (def.flags.includes('settler')) return stepSettler(rules, state, civIdx, unit, strategy); + if (def.flags.includes('caravan')) return stepCaravan(rules, state, civIdx, unit); + if (def.flags.includes('ignoreterrain') && def.attack === 0) return stepExplorer(rules, state, civIdx, unit); + if (def.domain === 'sea') return stepShip(rules, state, civIdx, unit, strategy); + if (def.domain === 'air') { unit.mp = 0; return false; } // AI keeps air home (v1) + return stepMilitary(rules, state, civIdx, unit, strategy); +} + +function moveToward(rules, state, unit, tx, ty) { + // Pathfind once per decision, then walk the whole path while movement + // lasts — re-planning every tile step dominates AI turn time otherwise. + const path = findPath(rules, state, unit, tx, ty); + if (!path || !path.length) return false; + let progressed = false; + for (const [nx, ny] of path) { + if (unit.mp <= 0 || !state.units.includes(unit) || state.over) break; + const out = tryMove(rules, state, unit, Math.sign(nx - unit.x), Math.sign(ny - unit.y)); + if (out.result !== 'moved' && out.result !== 'boarded' && out.result !== 'captured') break; + progressed = true; + if (out.result !== 'moved') break; // boarded/captured ends the walk + if (out.hut) break; // hut outcomes can change the situation + } + return progressed; +} + +function stepSettler(rules, state, civIdx, unit, strategy) { + const civ = state.civs[civIdx]; + // Improve home turf when expansion is done (or this is an engineer). + const wantsFound = strategy.phase === 'expand' || civCities(state, civIdx).length === 0; + if (!wantsFound || rules.units[unit.type].flags.includes('engineer')) { + return stepWorker(rules, state, civIdx, unit); + } + const best = bestCitySite(rules, state, civIdx, unit); + if (!best) return stepWorker(rules, state, civIdx, unit); + if (best.x === unit.x && best.y === unit.y && canFoundCity(rules, state, unit.x, unit.y)) { + foundCity(rules, state, unit); + return false; + } + return moveToward(rules, state, unit, best.x, best.y); +} + +export function bestCitySite(rules, state, civIdx, unit) { + const { world } = state; + let best = null; + let bestScore = -Infinity; + const R = 7; + for (let dy = -R; dy <= R; dy += 1) { + for (let dx = -R; dx <= R; dx += 1) { + const x = unit.x + dx; + const y = unit.y + dy; + if (!inBounds(world, x, y)) continue; + if (!canFoundCity(rules, state, x, y)) continue; + const terr = terrainAt(rules, world, x, y); + if (terr.water || terr.id === 'mountains') continue; + const enemyClose = state.civs.some((c) => c.alive && c.id !== civIdx + && civCities(state, c.id).some((ct) => cheb(ct.x, ct.y, x, y) <= 3)); + if (enemyClose) continue; + const score = siteQuality(rules, world, x, y) - cheb(unit.x, unit.y, x, y) * 2; + if (score > bestScore) { bestScore = score; best = { x, y }; } + } + } + return best; +} + +function stepWorker(rules, state, civIdx, unit) { + // Improve tiles inside own city radii: irrigation > mine > road. + if (!cityNeedsMe(rules, state, civIdx, unit.x, unit.y)) { + const target = nearestWorkTile(rules, state, civIdx, unit); + if (target) return moveToward(rules, state, unit, target[0], target[1]); + unit.mp = 0; + return false; + } + for (const imp of ['irrigation', 'mine', 'road', 'railroad', 'farmland']) { + if (canWork(rules, state, unit, imp)) { + // Don't irrigate what a mine serves better and vice versa: terrain fields + // already gate this; simple priority order is enough for the AI. + startWork(rules, state, unit, imp); + return false; + } + } + const target = nearestWorkTile(rules, state, civIdx, unit); + if (target) return moveToward(rules, state, unit, target[0], target[1]); + unit.mp = 0; + return false; +} + +function cityNeedsMe(rules, state, civIdx, x, y) { + return civCities(state, civIdx).some((c) => cheb(c.x, c.y, x, y) <= 2); +} + +function nearestWorkTile(rules, state, civIdx, unit) { + const { world } = state; + let best = null; + let bestDist = Infinity; + for (const city of civCities(state, civIdx)) { + for (let dy = -2; dy <= 2; dy += 1) { + for (let dx = -2; dx <= 2; dx += 1) { + const x = city.x + dx; + const y = city.y + dy; + if (!inBounds(world, x, y)) continue; + const terr = terrainAt(rules, world, x, y); + if (terr.water) continue; + const bits = world.improvements[tileIndex(world, x, y)]; + const needsSomething = (!(bits & 4) && terr.irrigate !== null) + || (!(bits & 16) && terr.mine !== null) || !(bits & 1); + if (!needsSomething) continue; + const d = cheb(unit.x, unit.y, x, y); + if (d < bestDist) { bestDist = d; best = [x, y]; } + } + } + } + return best; +} + +function stepExplorer(rules, state, civIdx, unit) { + const target = nearestFrontier(state, civIdx, unit); + if (!target) { unit.mp = 0; return false; } + return moveToward(rules, state, unit, target[0], target[1]); +} + +function nearestFrontier(state, civIdx, unit) { + const { world } = state; + const grid = state.explored[civIdx]; + let best = null; + let bestDist = Infinity; + // Sample the map (stride 2) for unexplored tiles adjacent to explored ones. + for (let y = 0; y < world.rows; y += 2) { + for (let x = 0; x < world.cols; x += 2) { + if (grid[tileIndex(world, x, y)]) continue; + const d = cheb(unit.x, unit.y, x, y); + if (d < bestDist) { bestDist = d; best = [x, y]; } + } + } + return best; +} + +function stepCaravan(rules, state, civIdx, unit) { + if (canEstablishRoute(rules, state, unit)) { + establishTradeRoute(rules, state, unit); + return false; + } + const home = cityById(state, unit.homeCity) ?? civCities(state, civIdx)[0]; + if (!home) { unit.mp = 0; return false; } + if (!unit.homeCity) unit.homeCity = home.id; + let best = null; + let bestScore = -Infinity; + for (const city of state.cities) { + if (city.id === home.id) continue; + if (city.civ !== civIdx && state.civs[civIdx].relations[city.civ] !== 'peace' + && state.civs[civIdx].relations[city.civ] !== 'alliance') continue; + const d = cheb(home.x, home.y, city.x, city.y); + if (d < 8) continue; + const score = (city.civ === civIdx ? 0 : 20) + d - cheb(unit.x, unit.y, city.x, city.y) * 0.5; + if (score > bestScore) { bestScore = score; best = city; } + } + if (!best) { unit.mp = 0; return false; } + return moveToward(rules, state, unit, best.x, best.y); +} + +function stepShip(rules, state, civIdx, unit, strategy) { + const def = rules.units[unit.type]; + // Warships hunt enemy ships/coastal targets during war; else patrol home. + if (def.attack > 0 && strategy.atWarWith.length) { + const target = state.units.find((u) => strategy.atWarWith.includes(u.civ) + && rules.units[u.type].domain === 'sea' && cheb(u.x, u.y, unit.x, unit.y) <= 6); + if (target) { + if (cheb(target.x, target.y, unit.x, unit.y) <= 1) { + resolveAttack(rules, state, unit, target.x, target.y); + return false; + } + return moveToward(rules, state, unit, target.x, target.y); + } + } + unit.mp = 0; + return false; +} + +function handleCargo(rules, state, unit, strategy) { + // Disembark next to a hostile city or onto open land when the boat parks. + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]; + for (const [dx, dy] of dirs) { + const x = unit.x + dx; + const y = unit.y + dy; + if (!inBounds(state.world, x, y)) continue; + if (terrainAt(rules, state.world, x, y).water) continue; + const city = cityAt(state, x, y); + if (city && strategy.atWarWith.includes(city.civ)) { + disembark(rules, state, unit, dx, dy); + return; + } + } +} + +function stepMilitary(rules, state, civIdx, unit, strategy) { + const civ = state.civs[civIdx]; + const def = rules.units[unit.type]; + const homeCity = cityAt(state, unit.x, unit.y); + + // Keep the garrison staffed before adventuring. + if (homeCity && homeCity.civ === civIdx) { + const garrison = unitsAt(state, unit.x, unit.y).filter((u) => u.civ === civIdx + && rules.units[u.type].domain === 'land' && !rules.units[u.type].flags.includes('noncombat')); + const wanted = strategy.phase === 'war' ? 2 : 1; + const myRank = garrison.sort((a, b) => rules.units[b.type].defense - rules.units[a.type].defense) + .indexOf(unit); + if (myRank >= 0 && myRank < wanted) { + unit.fortified = true; + unit.mp = 0; + return false; + } + } + + // Attack adjacent enemies when odds look good. + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]; + for (const [dx, dy] of dirs) { + const x = unit.x + dx; + const y = unit.y + dy; + if (!inBounds(state.world, x, y)) continue; + const enemies = unitsAt(state, x, y).filter((u) => civ.relations[u.civ] === 'war'); + const enemyCity = cityAt(state, x, y); + const cityHostile = enemyCity && civ.relations[enemyCity.civ] === 'war'; + if (!enemies.length && !cityHostile) continue; + if (def.domain === 'land' && terrainAt(rules, state.world, x, y).water) continue; + if (def.attack <= 0) continue; + if (enemies.length) { + const defender = pickDefender(rules, state, x, y, unit); + const A = attackerStrength(rules, state, unit); + const D = defenderStrength(rules, state, defender, unit); + // Massed assault: with friends beside us, grind the walls down even at + // poor odds — defenders keep their damage between waves. + const allies = unitsAt(state, unit.x, unit.y).filter((u) => u.civ === civIdx + && rules.units[u.type].attack >= 2).length; + const gate = cityHostile ? (allies >= 2 ? 0.35 : 0.6) : 0.8; + if (A >= D * gate) { + const out = tryMove(rules, state, unit, dx, dy); + return out.result === 'combat'; + } + } else if (cityHostile) { + const out = tryMove(rules, state, unit, dx, dy); + return out.result === 'captured'; + } + } + + // March on the weakest reachable enemy city. + if (strategy.atWarWith.length && def.attack > 0) { + let target = null; + let bestScore = -Infinity; + for (const city of state.cities) { + if (!strategy.atWarWith.includes(city.civ)) continue; + const defenders = unitsAt(state, city.x, city.y).length; + const d = cheb(unit.x, unit.y, city.x, city.y); + const score = -defenders * 4 - d; + if (score > bestScore) { bestScore = score; target = city; } + } + if (target) return moveToward(rules, state, unit, target.x, target.y); + } + + // Peacetime: a slice of the army scouts outward (finds neighbours, pops + // huts); the rest drifts home and fortifies. + if (unit.id % 3 === 0 && strategy.atWarWith.length === 0) { + const frontier = nearestFrontier(state, civIdx, unit); + if (frontier && moveToward(rules, state, unit, frontier[0], frontier[1])) return true; + } + const own = civCities(state, civIdx); + if (own.length) { + let nearest = own[0]; + for (const c of own) { + if (cheb(unit.x, unit.y, c.x, c.y) < cheb(unit.x, unit.y, nearest.x, nearest.y)) nearest = c; + } + if (cheb(unit.x, unit.y, nearest.x, nearest.y) > 1) { + return moveToward(rules, state, unit, nearest.x, nearest.y); + } + } + unit.fortified = true; + unit.mp = 0; + return false; +} diff --git a/src/games/civilization/CivilizationCityScreen.js b/src/games/civilization/CivilizationCityScreen.js new file mode 100644 index 0000000..437362a --- /dev/null +++ b/src/games/civilization/CivilizationCityScreen.js @@ -0,0 +1,162 @@ +// Civilization — city detail modal: growth, yields, worked-tile emphasis, +// build queue with buy, buildings, supported units and trade routes. + +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import * as Logic from './CivilizationLogic.js'; + +const FONT = '"Julius Sans One"'; + +export function openCityScreen(scene, rules, state, city, onClose) { + const root = scene.add.container(0, 0).setDepth(65); + const dim = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6) + .setInteractive(); + const W = 1500; + const H = 860; + const px = GAME_WIDTH / 2; + const py = GAME_HEIGHT / 2; + const panel = scene.add.rectangle(px, py, W, H, COLORS.panel) + .setStrokeStyle(3, COLORS.accent); + root.add([dim, panel]); + + let dynamic = scene.add.container(0, 0); + root.add(dynamic); + + const close = () => { root.destroy(true); onClose(); }; + const closeBtn = new Button(scene, px + W / 2 - 80, py - H / 2 + 40, '✕', close, + { width: 60, height: 44, fontSize: 22, variant: 'ghost' }); + root.add(closeBtn); + + function redraw() { + dynamic.destroy(true); + dynamic = scene.add.container(0, 0); + root.add(dynamic); + const civ = state.civs[city.civ]; + Logic.autoAssignTiles(rules, state, city); + const y = Logic.cityYields(rules, state, city); + const left = px - W / 2 + 40; + const top = py - H / 2 + 30; + + dynamic.add(scene.add.text(px, top + 10, `${city.name} — Population ${city.size}`, { + fontFamily: 'Righteous', fontSize: '34px', color: COLORS.accentHex, + }).setOrigin(0.5, 0)); + + // Growth bar. + const boxSize = (city.size + 1) * Logic.FOODBOX_PER_SIZE; + const gw = 400; + dynamic.add(scene.add.text(left, top + 70, 'Growth', { fontFamily: FONT, fontSize: '19px', color: COLORS.mutedHex })); + dynamic.add(scene.add.rectangle(left + 90, top + 80, gw, 20, 0x0a0d12).setOrigin(0, 0.5) + .setStrokeStyle(1, COLORS.muted)); + dynamic.add(scene.add.rectangle(left + 90, top + 80, + gw * Math.max(0, Math.min(1, city.foodBox / boxSize)), 16, 0x4a9e44).setOrigin(0, 0.5)); + dynamic.add(scene.add.text(left + 100 + gw, top + 70, `${city.foodBox}/${boxSize}`, { + fontFamily: FONT, fontSize: '18px', color: COLORS.textHex, + })); + + // Yields breakdown. + const lines = [ + `Food ${y.food} (need ${y.foodNeed}) surplus ${y.foodSurplus >= 0 ? '+' : ''}${y.foodSurplus}`, + `Shields ${y.shield}${y.supportShields ? ` (support −${y.supportShields})` : ''}`, + `Trade ${y.trade} corruption −${y.corruption}${y.routeTrade ? ` routes +${y.routeTrade}` : ''}`, + `Gold +${y.gold} · Science +${y.science} · Upkeep −${y.upkeep}`, + ]; + dynamic.add(scene.add.text(left, top + 112, lines.join('\n'), { + fontFamily: FONT, fontSize: '20px', color: COLORS.textHex, lineSpacing: 8, + })); + + // Emphasis toggle. + dynamic.add(scene.add.text(left, top + 260, 'Worker emphasis:', { + fontFamily: FONT, fontSize: '19px', color: COLORS.mutedHex, + })); + ['balanced', 'food', 'production', 'trade'].forEach((emp, i) => { + const bx = left + 200 + i * 150; + const rect = scene.add.rectangle(bx, top + 270, 138, 40, COLORS.panel) + .setStrokeStyle(2, city.emphasis === emp ? COLORS.gold : COLORS.muted); + const txt = scene.add.text(bx, top + 270, emp.toUpperCase(), { + fontFamily: FONT, fontSize: '16px', + color: city.emphasis === emp ? COLORS.goldHex : COLORS.textHex, + }).setOrigin(0.5); + rect.setInteractive({ useHandCursor: true }); + rect.on('pointerdown', () => { city.emphasis = emp; redraw(); }); + dynamic.add(rect); + dynamic.add(txt); + }); + + // Buildings list. + dynamic.add(scene.add.text(left, top + 320, 'City Improvements', { + fontFamily: FONT, fontSize: '21px', color: COLORS.accentHex, + })); + const built = Object.keys(city.buildings).map((id) => rules.buildings[id]?.name ?? id); + dynamic.add(scene.add.text(left, top + 355, built.length ? built.join(', ') : '(none yet)', { + fontFamily: FONT, fontSize: '18px', color: COLORS.textHex, + wordWrap: { width: 640 }, lineSpacing: 6, + })); + + // Supported units + routes. + const supported = state.units.filter((u) => u.homeCity === city.id) + .map((u) => rules.units[u.type].name); + dynamic.add(scene.add.text(left, top + 500, `Supported units (${supported.length})`, { + fontFamily: FONT, fontSize: '21px', color: COLORS.accentHex, + })); + dynamic.add(scene.add.text(left, top + 535, supported.length ? supported.join(', ') : '(none)', { + fontFamily: FONT, fontSize: '17px', color: COLORS.textHex, + wordWrap: { width: 640 }, lineSpacing: 5, + })); + const routes = city.routes.map((r) => { + const other = Logic.cityById(state, r.cityId); + return other ? `${other.name} (+${r.amount})` : `(lost city +${r.amount})`; + }); + dynamic.add(scene.add.text(left, top + 660, `Trade routes: ${routes.length ? routes.join(', ') : '(none)'}`, { + fontFamily: FONT, fontSize: '18px', color: COLORS.textHex, wordWrap: { width: 640 }, + })); + + // --- Build picker (right column) + const rx = px + 60; + const cost = Logic.buildCost(rules, city); + const cur = city.build.type === 'unit' ? rules.units[city.build.id] : rules.buildings[city.build.id]; + const turns = y.shield > 0 ? Math.ceil((cost - city.shieldBox) / y.shield) : '∞'; + dynamic.add(scene.add.text(rx, top + 64, + `Building: ${cur.name} ${city.shieldBox}/${cost} shields (${turns} turns)`, { + fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, + })); + const buyPrice = Logic.buyCost(rules, city); + const buyBtn = new Button(scene, rx + 560, top + 120, `BUY (${buyPrice}g)`, () => { + if (Logic.buyBuild(rules, state, city)) redraw(); + }, { width: 190, height: 46, fontSize: 18, variant: civ.gold >= buyPrice && !city.boughtThisTurn ? 'solid' : 'ghost' }); + dynamic.add(buyBtn); + dynamic.add(scene.add.text(rx, top + 108, `Treasury: ${civ.gold} gold`, { + fontFamily: FONT, fontSize: '19px', color: COLORS.goldHex, + })); + + // Choices: units then buildings, two columns of rows. + const unitChoices = Logic.availableUnits(rules, state, civ, city) + .map((u) => ({ type: 'unit', id: u.id, label: `${u.name} (${u.cost})`, tip: `A${u.attack} D${u.defense} M${u.move}` })); + const bldChoices = Logic.availableBuildings(rules, state, civ, city) + .map((b) => ({ type: 'building', id: b.id, label: `${b.name} (${b.cost})`, tip: `upkeep ${b.upkeep}` })); + const choices = [...unitChoices, ...bldChoices]; + const colW = 330; + const rowH = 36; + const perCol = 17; + choices.slice(0, perCol * 2).forEach((ch, i) => { + const colX = rx + Math.floor(i / perCol) * (colW + 20); + const rowY = top + 170 + (i % perCol) * rowH; + const active = city.build.type === ch.type && city.build.id === ch.id; + const rect = scene.add.rectangle(colX + colW / 2, rowY, colW, rowH - 4, + active ? 0x3a3222 : 0x181510) + .setStrokeStyle(1, active ? COLORS.gold : COLORS.muted, active ? 1 : 0.5); + const txt = scene.add.text(colX + 10, rowY, ch.label, { + fontFamily: FONT, fontSize: '16px', + color: ch.type === 'unit' ? '#cfe3ff' : '#ffe9bd', + }).setOrigin(0, 0.5); + rect.setInteractive({ useHandCursor: true }); + rect.on('pointerdown', () => { + Logic.setBuild(rules, state, city, ch.type, ch.id); + redraw(); + }); + dynamic.add(rect); + dynamic.add(txt); + }); + } + + redraw(); +} diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js new file mode 100644 index 0000000..4e08897 --- /dev/null +++ b/src/games/civilization/CivilizationGame.js @@ -0,0 +1,825 @@ +// Civilization (Civ II-lite) — main Phaser scene. +// +// Phases: setup -> playing -> over. Setup picks a leader character (the +// standard opponents double as civilizations), world size, rival count and +// difficulty. The engine (CivilizationLogic) is headless; this scene drives it +// and renders through CivilizationMapView. AI rivals play via CivilizationAI. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { compileRules, turnToYear, formatYear } from './CivilizationRules.js'; +import * as Logic from './CivilizationLogic.js'; +import { runAITurn, respondToProposal } from './CivilizationAI.js'; +import { CivilizationMapView } from './CivilizationMapView.js'; +import { openCityScreen } from './CivilizationCityScreen.js'; +import { + openTechScreen, openDiplomacyScreen, openSpaceshipScreen, showVictoryOverlay, +} from './CivilizationScreens.js'; + +const FONT = '"Julius Sans One"'; +const SAVE_KEY = 'civilization-save'; +const D = { hud: 30, modal: 60, toast: 80 }; + +export default class CivilizationGame extends Phaser.Scene { + constructor() { super('CivilizationGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'civilization', name: 'Civilization' }; + this.state = null; + this.view = null; + this.phase = 'setup'; + this.modalOpen = false; + this.busy = false; + } + + create() { + this.rules = compileRules(this.cache.json.get('civilization-rules')); + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0a0d12) + .setDepth(-10); + + this.hudRoot = this.add.container(0, 0).setDepth(D.hud); + this.modalRoot = this.add.container(0, 0).setDepth(D.modal); + this.toastRoot = this.add.container(0, 0).setDepth(D.toast); + this.setupRoot = null; + this.toasts = []; + + this.keys = this.input.keyboard.addKeys('UP,DOWN,LEFT,RIGHT,Q,E,Z,C,W,A,S,D,B,F,G,I,M,N,O,R,T,SPACE,ENTER'); + this.escHandler = () => this.onEscape(); + this.input.keyboard.on('keydown-ESC', this.escHandler); + this.events.once('shutdown', () => { + this.input.keyboard.off('keydown-ESC', this.escHandler); + this.view?.destroy(); + }); + + try { + const music = this.cache.json.get('music'); + if (music?.tracks) this.music = new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + + this.opponentsData = []; + fetch('data/opponents.json') + .then((r) => r.json()) + .then((json) => { this.opponentsData = json.opponents ?? []; this.showSetup(); }) + .catch(() => { this.opponentsData = []; this.showSetup(); }); + } + + onEscape() { + if (this.modalOpen) return; // modals close themselves + if (this.phase === 'playing') this.openMenu(); + else this.scene.start('GameMenu'); + } + + // =========================================================================== + // Setup phase + + showSetup() { + this.phase = 'setup'; + this.setupRoot?.destroy(true); + this.setupRoot = this.add.container(0, 0).setDepth(10); + const root = this.setupRoot; + const cx = GAME_WIDTH / 2; + + root.add(this.add.text(cx, 52, 'CIVILIZATION', { + fontFamily: 'Righteous', fontSize: '54px', color: COLORS.accentHex, + }).setOrigin(0.5)); + root.add(this.add.text(cx, 100, 'Build an empire to stand the test of time', { + fontFamily: FONT, fontSize: '20px', color: COLORS.mutedHex, + }).setOrigin(0.5)); + + // --- leader pick grid + root.add(this.add.text(cx, 150, 'CHOOSE YOUR LEADER', { + fontFamily: FONT, fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5)); + + const leaders = this.opponentsData.length ? this.opponentsData + : Array.from({ length: 8 }, (_, i) => ({ id: `leader${i}`, name: `Leader ${i + 1}`, spriteIndex: 0 })); + this.setupLeaders = leaders; + this.pickedLeader = this.pickedLeader ?? 0; + + const perRow = 15; + const cell = 96; + const gridW = Math.min(leaders.length, perRow) * cell; + const gx = cx - gridW / 2 + cell / 2; + const gy = 214; + this.leaderMarks = []; + leaders.forEach((op, i) => { + const x = gx + (i % perRow) * cell; + const y = gy + Math.floor(i / perRow) * (cell + 18); + const ring = this.add.circle(x, y, 40, COLORS.panel) + .setStrokeStyle(3, i === this.pickedLeader ? COLORS.gold : COLORS.muted); + let face; + if (this.textures.exists('opponents')) { + face = this.add.image(x, y, 'opponents', op.spriteIndex ?? 0).setDisplaySize(72, 72); + const maskShape = this.make.graphics({ add: false }); + maskShape.fillStyle(0xffffff); + maskShape.fillCircle(x, y, 36); + face.setMask(maskShape.createGeometryMask()); + } else { + face = this.add.text(x, y, op.name[0], { + fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex, + }).setOrigin(0.5); + } + const label = this.add.text(x, y + 52, op.name, { + fontFamily: FONT, fontSize: '14px', + color: i === this.pickedLeader ? COLORS.goldHex : COLORS.mutedHex, + }).setOrigin(0.5); + ring.setInteractive({ useHandCursor: true }); + ring.on('pointerdown', () => { + this.pickedLeader = i; + this.leaderMarks.forEach((m, j) => { + m.ring.setStrokeStyle(3, j === i ? COLORS.gold : COLORS.muted); + m.label.setColor(j === i ? COLORS.goldHex : COLORS.mutedHex); + }); + }); + this.leaderMarks.push({ ring, label }); + root.add([ring, face, label]); + }); + + const rowsUsed = Math.ceil(leaders.length / perRow); + let oy = gy + rowsUsed * (cell + 18) + 40; + + // --- option rows + this.setupOpts = this.setupOpts ?? { sizeId: 'medium', opponents: 3, difficultyId: 'prince' }; + const mkRow = (label, options, current, onPick) => { + root.add(this.add.text(cx - 560, oy, label, { + fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, + }).setOrigin(0, 0.5)); + const marks = []; + const startX = cx - 260; + options.forEach((opt, i) => { + const w = Math.max(90, opt.label.length * 13 + 30); + const x = startX + options.slice(0, i).reduce((a, o) => a + Math.max(90, o.label.length * 13 + 30) + 14, 0); + const rect = this.add.rectangle(x + w / 2, oy, w, 44, COLORS.panel) + .setStrokeStyle(2, opt.value === current ? COLORS.gold : COLORS.muted); + const txt = this.add.text(x + w / 2, oy, opt.label, { + fontFamily: FONT, fontSize: '19px', + color: opt.value === current ? COLORS.goldHex : COLORS.textHex, + }).setOrigin(0.5); + rect.setInteractive({ useHandCursor: true }); + rect.on('pointerdown', () => { + onPick(opt.value); + marks.forEach((m, j) => { + m.rect.setStrokeStyle(2, options[j].value === opt.value ? COLORS.gold : COLORS.muted); + m.txt.setColor(options[j].value === opt.value ? COLORS.goldHex : COLORS.textHex); + }); + }); + marks.push({ rect, txt }); + root.add([rect, txt]); + }); + oy += 62; + }; + + mkRow('World Size', + this.rules.worldSizeList.map((w) => ({ label: `${w.name} (${w.cols}×${w.rows})`, value: w.id })), + this.setupOpts.sizeId, (v) => { this.setupOpts.sizeId = v; }); + mkRow('Rival Civilizations', + [2, 3, 4, 5, 6, 7].map((n) => ({ label: `${n}`, value: n })), + this.setupOpts.opponents, (v) => { this.setupOpts.opponents = v; }); + mkRow('Difficulty', + this.rules.difficultyList.map((d) => ({ label: d.name, value: d.id })), + this.setupOpts.difficultyId, (v) => { this.setupOpts.difficultyId = v; }); + + oy += 8; + const startBtn = new Button(this, cx - (this.hasSave() ? 160 : 0), oy + 20, 'BEGIN', () => { + this.startNewGame(); + }, { width: 280, height: 64, fontSize: 30 }); + root.add(startBtn); + if (this.hasSave()) { + const resumeBtn = new Button(this, cx + 160, oy + 20, 'RESUME GAME', () => { + this.resumeGame(); + }, { width: 280, height: 64, fontSize: 26, variant: 'ghost' }); + root.add(resumeBtn); + } + } + + hasSave() { + try { return !!localStorage.getItem(SAVE_KEY); } catch (_) { return false; } + } + + startNewGame() { + const { sizeId, opponents, difficultyId } = this.setupOpts; + const pool = this.setupLeaders; + const me = pool[this.pickedLeader]; + const rivals = pool.filter((_, i) => i !== this.pickedLeader); + // Shuffle rivals with Math.random (game determinism starts at engine seed). + for (let i = rivals.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [rivals[i], rivals[j]] = [rivals[j], rivals[i]]; + } + const leaders = [me, ...rivals.slice(0, opponents)] + .map((op) => ({ id: op.id, name: op.name })); + const seed = (Date.now() % 1000000) + 1; + try { + this.state = Logic.createGame(this.rules, { + sizeId, seed, difficultyId, leaders, humanIndex: 0, + }); + } catch (err) { + this.toast(`World generation failed — try again (${err.message})`); + return; + } + this.leaderPool = leaders; + this.beginPlaying(); + } + + resumeGame() { + try { + const state = Logic.deserialize(localStorage.getItem(SAVE_KEY)); + if (!state) { this.toast('Saved game is from an old version'); localStorage.removeItem(SAVE_KEY); return; } + this.state = state; + this.beginPlaying(true); + } catch (_) { + this.toast('Could not load the save'); + } + } + + saveGame() { + try { localStorage.setItem(SAVE_KEY, Logic.serialize(this.state)); } catch (_) { /* full */ } + } + clearSave() { + try { localStorage.removeItem(SAVE_KEY); } catch (_) { /* noop */ } + } + + // =========================================================================== + // Playing phase + + beginPlaying(resumed = false) { + this.phase = 'playing'; + this.setupRoot?.destroy(true); + this.setupRoot = null; + this.view = new CivilizationMapView(this, this.rules, this.state, { + onCityClick: (city) => this.onCityClick(city), + }); + this.view.buildMinimap(16, GAME_HEIGHT - 260, 300); + this.buildHud(); + this.bindPointer(); + + const human = this.state.civs[this.state.humanIndex]; + const capital = Logic.civCities(this.state, human.id)[0]; + const firstUnit = Logic.civUnits(this.state, human.id)[0]; + const focus = capital ?? firstUnit; + if (focus) this.view.centerOn(focus.x, focus.y); + + if (!resumed || this.state.current !== this.state.humanIndex) { + // Fresh games (and saves mid-AI-round) run up to the human turn. + this.runToHumanTurn(); + } else { + this.startHumanTurn(false); + } + } + + buildHud() { + this.hudRoot.removeAll(true); + const bar = this.add.rectangle(GAME_WIDTH / 2, 28, GAME_WIDTH, 56, COLORS.panel, 0.95) + .setStrokeStyle(1, COLORS.accent, 0.6); + this.hudRoot.add(bar); + this.hudText = this.add.text(20, 28, '', { + fontFamily: FONT, fontSize: '21px', color: COLORS.textHex, + }).setOrigin(0, 0.5); + this.hudRoot.add(this.hudText); + + const mkBtn = (x, label, fn, w = 150) => { + const b = new Button(this, x, 28, label, fn, { width: w, height: 42, fontSize: 18 }); + this.hudRoot.add(b); + return b; + }; + mkBtn(GAME_WIDTH - 760, 'TECH', () => this.openTech()); + mkBtn(GAME_WIDTH - 600, 'DIPLOMACY', () => this.openDiplomacy(), 170); + mkBtn(GAME_WIDTH - 430, 'SPACESHIP', () => this.openSpaceship(), 170); + mkBtn(GAME_WIDTH - 270, 'MENU', () => this.openMenu(), 120); + this.endTurnBtn = new Button(this, GAME_WIDTH - 110, 28, 'END TURN', () => this.onEndTurn(), + { width: 180, height: 42, fontSize: 18, bg: COLORS.gold, textColor: COLORS.textDarkHex }); + this.hudRoot.add(this.endTurnBtn); + + // Right-side unit/terrain panel. + this.unitPanel = this.add.container(GAME_WIDTH - 300, 80); + const panelBg = this.add.rectangle(0, 0, 284, 240, COLORS.panel, 0.92) + .setOrigin(0, 0).setStrokeStyle(2, COLORS.accent, 0.7); + this.unitPanelText = this.add.text(14, 14, '', { + fontFamily: FONT, fontSize: '17px', color: COLORS.textHex, + wordWrap: { width: 256 }, lineSpacing: 5, + }); + this.unitPanel.add([panelBg, this.unitPanelText]); + this.hudRoot.add(this.unitPanel); + + this.hintText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 24, + 'Arrows/QEZC move · B found city · R road · I irrigate · M mine · O fortress · F fortify · Space skip · N next · Enter end turn', + { fontFamily: FONT, fontSize: '15px', color: COLORS.mutedHex }).setOrigin(0.5); + this.hudRoot.add(this.hintText); + this.refreshHud(); + } + + refreshHud() { + if (!this.state || this.phase !== 'playing') return; + const civ = this.state.civs[this.state.humanIndex]; + const year = formatYear(turnToYear(this.state.turn, this.rules.yearCurve)); + const gov = this.rules.governments[civ.government].name; + const research = civ.researching + ? `${this.rules.techs[civ.researching].name} ${civ.beakers}/${Logic.currentResearchCost(this.rules, this.state, civ)}` + : '— pick research —'; + this.hudText.setText(`${year} Gold: ${civ.gold} Science: ${research} ${gov}`); + this.refreshUnitPanel(); + } + + refreshUnitPanel() { + const unit = this.selectedUnit(); + if (!unit) { + this.unitPanelText.setText(this.busy ? 'Rivals are moving…' : 'No unit selected.\nClick a unit or press N.'); + return; + } + const def = this.rules.units[unit.type]; + const terr = Logic.terrainAt(this.rules, this.state.world, unit.x, unit.y); + const idx = Logic.tileIndex(this.state.world, unit.x, unit.y); + const spec = this.state.world.special[idx] >= 0 + ? this.rules.specialList[this.state.world.special[idx]].name : null; + const lines = [ + `${def.name}${unit.vet ? ' (V)' : ''}`, + `A${def.attack} D${def.defense} HP ${unit.hp}/${def.hp}`, + `Moves: ${(unit.mp / 3).toFixed(unit.mp % 3 ? 1 : 0)}`, + `Terrain: ${terr.name}${spec ? ` (${spec})` : ''}`, + ]; + if (unit.order?.kind === 'work') { + lines.push(`Working: ${this.rules.improvements[unit.order.imp].name}`); + } + if (unit.fortified) lines.push('Fortified'); + this.unitPanelText.setText(lines.join('\n')); + } + + // --------------------------------------------------------------------------- + // Pointer + keyboard input + + bindPointer() { + let dragStart = null; + let dragged = false; + this.input.on('pointerdown', (pointer) => { + if (this.modalOpen || this.phase !== 'playing') return; + dragStart = { x: pointer.x, y: pointer.y, rx: this.view.root.x, ry: this.view.root.y }; + dragged = false; + }); + this.input.on('pointermove', (pointer) => { + if (!pointer.isDown || !dragStart || this.modalOpen) return; + const dx = pointer.x - dragStart.x; + const dy = pointer.y - dragStart.y; + if (Math.abs(dx) + Math.abs(dy) > 8) dragged = true; + if (dragged) { + this.view.root.x = dragStart.rx + dx; + this.view.root.y = dragStart.ry + dy; + this.view.clampPan(); + } + }); + this.input.on('pointerup', (pointer) => { + const wasDrag = dragged; + dragStart = null; + dragged = false; + if (wasDrag || this.modalOpen || this.phase !== 'playing' || this.busy) return; + if (pointer.y < 56 || pointer.y > GAME_HEIGHT - 44) return; // HUD bands + // Clicks that landed on any interactive object (buttons, minimap, city + // banners) are theirs, not the map's. + if (this.input.hitTestPointer(pointer).length > 0) return; + const tile = this.view.screenToTile(pointer.x, pointer.y); + if (tile) this.onTileClick(tile[0], tile[1]); + }); + this.input.on('wheel', (pointer, objs, dx, dy) => { + if (this.modalOpen || this.phase !== 'playing') return; + this.view.zoomBy(dy > 0 ? -1 : 1); + }); + } + + onTileClick(c, r) { + const state = this.state; + const human = state.humanIndex; + const myUnits = Logic.unitsAt(state, c, r).filter((u) => u.civ === human); + const sel = this.selectedUnit(); + + if (myUnits.length && (!sel || sel.x !== c || sel.y !== r)) { + this.selectUnit(myUnits[0]); + return; + } + if (myUnits.length && sel && sel.x === c && sel.y === r) { + // Cycle the stack. + const i = myUnits.indexOf(sel); + this.selectUnit(myUnits[(i + 1) % myUnits.length]); + return; + } + const city = Logic.cityAt(state, c, r); + if (city && city.civ === human && !myUnits.length) { + this.onCityClick(city); + return; + } + // Move the selected unit toward the clicked tile. + if (sel && state.current === human) { + if (Logic.cheb(sel.x, sel.y, c, r) === 1) { + this.tryStep(sel, c - sel.x, r - sel.y); + return; + } + const path = Logic.findPath(this.rules, state, sel, c, r); + if (!path) { this.toast('No route there'); return; } + this.view.showPath(path); + this.walkPath(sel, path); + } + } + + onCityClick(city) { + if (city.civ !== this.state.humanIndex || this.modalOpen) return; + this.modalOpen = true; + openCityScreen(this, this.rules, this.state, city, () => { + this.modalOpen = false; + this.view.refresh(); + this.refreshHud(); + }); + } + + update() { + if (this.phase !== 'playing' || this.modalOpen || this.busy) return; + // Edge-of-keyboard panning. + const k = this.keys; + const pan = 14; + if (k.W.isDown) this.view.panBy(0, pan); + if (k.S.isDown && !k.S.shiftKey) this.view.panBy(0, -pan); + if (k.A.isDown) this.view.panBy(pan, 0); + if (k.D.isDown) this.view.panBy(-pan, 0); + + if (this.state?.current !== this.state?.humanIndex) return; + const sel = this.selectedUnit(); + const just = (key) => Phaser.Input.Keyboard.JustDown(key); + + if (just(k.N)) this.selectNextUnit(); + if (just(k.ENTER)) this.onEndTurn(); + if (!sel) return; + + if (just(k.UP)) this.tryStep(sel, 0, -1); + else if (just(k.DOWN)) this.tryStep(sel, 0, 1); + else if (just(k.LEFT)) this.tryStep(sel, -1, 0); + else if (just(k.RIGHT)) this.tryStep(sel, 1, 0); + else if (just(k.Q)) this.tryStep(sel, -1, -1); + else if (just(k.E)) this.tryStep(sel, 1, -1); + else if (just(k.Z)) this.tryStep(sel, -1, 1); + else if (just(k.C)) this.tryStep(sel, 1, 1); + else if (just(k.SPACE)) { sel.mp = 0; this.selectNextUnit(); } + else if (just(k.F)) { sel.fortified = true; sel.mp = 0; this.afterAction(); this.selectNextUnit(); } + else if (just(k.B)) this.tryFound(sel); + else if (just(k.R)) this.tryWork(sel, this.canRail(sel) ? 'railroad' : 'road'); + else if (just(k.I)) this.tryWork(sel, 'irrigation'); + else if (just(k.M)) this.tryWork(sel, 'mine'); + else if (just(k.O)) this.tryWork(sel, 'fortress'); + else if (just(k.T)) this.tryWork(sel, 'transform'); + else if (just(k.G)) this.tryCaravan(sel); + } + + canRail(unit) { + const bits = this.state.world.improvements[Logic.tileIndex(this.state.world, unit.x, unit.y)]; + return (bits & Logic.IMP.ROAD) && this.state.civs[unit.civ].known.railroad; + } + + selectedUnit() { + if (!this.state || this.selectedUnitId == null) return null; + const u = Logic.unitById(this.state, this.selectedUnitId); + if (!u || u.civ !== this.state.humanIndex) return null; + return u; + } + + selectUnit(unit) { + this.selectedUnitId = unit?.id ?? null; + this.view.selectedUnitId = this.selectedUnitId; + this.view.showPath(null); + if (unit) this.view.centerOnIfOffscreen?.(unit.x, unit.y); + this.view.refresh(); + this.refreshUnitPanel(); + } + + selectNextUnit() { + const units = Logic.civUnits(this.state, this.state.humanIndex) + .filter((u) => u.mp > 0 && !u.fortified && !u.sentry && !u.order && !u.carriedBy + && this.rules.units[u.type].domain !== 'project'); + if (!units.length) { this.selectUnit(null); return; } + const curIdx = units.findIndex((u) => u.id === this.selectedUnitId); + const next = units[(curIdx + 1) % units.length]; + this.selectUnit(next); + this.view.centerOn(next.x, next.y); + } + + tryStep(unit, dx, dy) { + if (this.state.current !== this.state.humanIndex) return; + const out = unit.carriedBy + ? Logic.disembark(this.rules, this.state, unit, dx, dy) + : Logic.tryMove(this.rules, this.state, unit, dx, dy); + if (out.result === 'blocked' && out.needsWar !== undefined) { + this.confirmWar(out.needsWar, () => { + Logic.declareWar(this.rules, this.state, this.state.humanIndex, out.needsWar); + this.tryStep(unit, dx, dy); + }); + return; + } + if (out.result === 'invalid') return; + if (out.hut) this.toastHut(out.hut); + this.afterAction(); + if (unit.mp <= 0 && this.state.units.includes(unit)) this.selectNextUnit(); + if (!this.state.units.includes(unit)) this.selectNextUnit(); + } + + walkPath(unit, path) { + // Step along the found path until movement runs out or something happens. + let i = 0; + const step = () => { + if (i >= path.length || unit.mp <= 0 || !this.state.units.includes(unit) || this.state.over) { + this.view.showPath(null); + this.afterAction(); + if (!this.state.units.includes(unit) || unit.mp <= 0) this.selectNextUnit(); + return; + } + const [nx, ny] = path[i]; + i += 1; + const out = Logic.tryMove(this.rules, this.state, unit, + Math.sign(nx - unit.x), Math.sign(ny - unit.y)); + if (out.result === 'invalid' || out.result === 'blocked') { + this.view.showPath(null); + this.afterAction(); + return; + } + if (out.hut) this.toastHut(out.hut); + this.view.refresh(); + this.time.delayedCall(60, step); + }; + step(); + } + + tryFound(unit) { + const def = this.rules.units[unit.type]; + if (!def.flags.includes('settler')) return; + if (!Logic.canFoundCity(this.rules, this.state, unit.x, unit.y)) { + this.toast('Cannot found a city here'); + return; + } + const city = Logic.foundCity(this.rules, this.state, unit); + if (city) { + this.toast(`${city.name} founded!`); + this.view.repaintTileAndNeighbors(city.x, city.y); + this.afterAction(); + this.selectNextUnit(); + } + } + + tryWork(unit, impId) { + if (Logic.startWork(this.rules, this.state, unit, impId)) { + this.toast(`${this.rules.units[unit.type].name}: building ${this.rules.improvements[impId].name}`); + this.afterAction(); + this.selectNextUnit(); + } else { + this.toast(`Cannot build ${this.rules.improvements[impId]?.name ?? impId} here`); + } + } + + tryCaravan(unit) { + const pair = Logic.canEstablishRoute(this.rules, this.state, unit); + if (!pair) { this.toast('Caravans need a city 8+ tiles from home'); return; } + const out = Logic.establishTradeRoute(this.rules, this.state, unit); + if (out) { + this.toast(`Trade route: ${out.from.name} ↔ ${out.to.name} (+${out.bonus} gold & beakers)`); + this.afterAction(); + this.selectNextUnit(); + } + } + + afterAction() { + // Repaint tiles that may have changed (work orders complete on turn start, + // but roads from engineers etc. show up next refresh — cheap full check + // is unnecessary; unit/city layer + HUD is enough here). + this.view.refresh(); + this.refreshHud(); + if (this.state.over) this.onGameOver(); + } + + // --------------------------------------------------------------------------- + // Turn cycle + + startHumanTurn(process = true) { + const human = this.state.humanIndex; + if (process) Logic.beginCivTurn(this.rules, this.state, human); + this.repaintFromEvents(); + this.busy = false; + this.endTurnBtn?.setAlpha(1); + + const civ = this.state.civs[human]; + if (!civ.alive) { this.onGameOver(); return; } + + // Research prompt. + if (!civ.researching && Logic.availableTechs(this.rules, civ).length) { + this.openTech(); + } + this.presentAIProposals(); + this.announceEvents(); + this.view.refresh(); + this.refreshHud(); + this.selectNextUnit(); + } + + onEndTurn() { + if (this.busy || this.modalOpen || this.phase !== 'playing') return; + if (this.state.current !== this.state.humanIndex) return; + Logic.endCivTurn(this.rules, this.state, this.state.humanIndex); + this.saveGame(); + this.runToHumanTurn(); + } + + runToHumanTurn() { + this.busy = true; + this.endTurnBtn?.setAlpha(0.4); + this.refreshUnitPanel(); + const stepCiv = () => { + if (this.phase !== 'playing') return; + if (this.state.over) { this.onGameOver(); return; } + if (!this.state.civs[this.state.humanIndex].alive) { this.onGameOver(); return; } + const cur = this.state.current; + if (cur === this.state.humanIndex) { + this.startHumanTurn(true); + return; + } + Logic.beginCivTurn(this.rules, this.state, cur); + runAITurn(this.rules, this.state, cur); + Logic.endCivTurn(this.rules, this.state, cur); + this.view.refresh(); + this.refreshHud(); + this.time.delayedCall(90, stepCiv); + }; + stepCiv(); + } + + // --------------------------------------------------------------------------- + // Events, proposals, toasts + + // Terrain changes (work orders, founded cities' roads) arrive as engine + // events; repaint just those tiles instead of rebaking the world. + repaintFromEvents() { + for (const e of this.state.events) { + if (e.painted) continue; + e.painted = true; + if (e.type === 'workDone') { + this.view.repaintTileAndNeighbors(e.x, e.y); + } else if (e.type === 'cityFounded' || e.type === 'cityDestroyed') { + const city = Logic.cityById(this.state, e.cityId); + if (city) this.view.repaintTileAndNeighbors(city.x, city.y); + } + } + } + + announceEvents() { + const human = this.state.humanIndex; + for (const e of this.state.events) { + if (e.announced) continue; + e.announced = true; + if (e.type === 'war' && e.b === human) { + this.toast(`${this.state.civs[e.a].name} declares WAR on you!`); + } else if (e.type === 'techDone' && e.civ === human) { + this.toast(`Research complete: ${this.rules.techs[e.tech].name}`); + } else if (e.type === 'cityCaptured' && (e.from === human || e.to === human)) { + this.toast(e.to === human ? `You captured ${e.name}!` : `${e.name} has fallen!`); + } else if (e.type === 'civEliminated') { + this.toast(`${this.state.civs[e.civ].name} has been destroyed`); + } else if (e.type === 'spaceshipLaunched') { + this.toast(`${this.state.civs[e.civ].name} launched a spaceship!`); + } else if (e.type === 'contact' && (e.a === human || e.b === human)) { + const other = e.a === human ? e.b : e.a; + this.toast(`You have made contact with ${this.state.civs[other].name}`); + } + } + } + + presentAIProposals() { + const human = this.state.humanIndex; + const proposals = this.state.events.filter((e) => e.type === 'aiProposal' && e.to === human); + this.state.events = this.state.events.filter((e) => !(e.type === 'aiProposal' && e.to === human)); + const next = () => { + const p = proposals.shift(); + if (!p) return; + if (!Logic.canPropose(this.state, p.from, human, p.kind)) { next(); return; } + const from = this.state.civs[p.from]; + this.confirmDialog( + `${from.name} proposes a ${p.kind === 'ceasefire' ? 'cease-fire' : p.kind}. Accept?`, + () => { Logic.applyTreaty(this.state, p.from, human, p.kind); this.refreshHud(); next(); }, + () => next(), + ); + }; + next(); + } + + toastHut(hut) { + const msgs = { + gold: `You found ${hut.gold} gold in the hut!`, + tech: hut.tech ? `The tribe teaches you ${this.rules.techs[hut.tech].name}!` : 'A gift!', + unit: `A band of ${hut.unit} joins you!`, + ambushWon: 'Hostile tribe! Your unit fought them off.', + ambushLost: 'Hostile tribe! Your unit was lost!', + }; + this.toast(msgs[hut.outcome] ?? 'An empty hut.'); + } + + toast(msg) { + const y = 90 + this.toasts.length * 40; + const t = this.add.text(GAME_WIDTH / 2, y, msg, { + fontFamily: FONT, fontSize: '20px', color: COLORS.textHex, + backgroundColor: '#1e1a12ee', padding: { x: 16, y: 7 }, + }).setOrigin(0.5).setDepth(D.toast); + this.toastRoot.add(t); + this.toasts.push(t); + this.tweens.add({ + targets: t, alpha: 0, delay: 2600, duration: 500, + onComplete: () => { + this.toasts = this.toasts.filter((x) => x !== t); + t.destroy(); + }, + }); + } + + confirmWar(civIdx, onYes) { + const name = this.state.civs[civIdx].name; + this.confirmDialog(`Attack ${name}? This means WAR!`, onYes, () => {}); + } + + confirmDialog(message, onYes, onNo) { + this.modalOpen = true; + const root = this.add.container(0, 0).setDepth(D.modal); + const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55) + .setInteractive(); + const panel = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, 640, 220, COLORS.panel) + .setStrokeStyle(2, COLORS.accent); + const txt = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 40, message, { + fontFamily: FONT, fontSize: '24px', color: COLORS.textHex, + wordWrap: { width: 580 }, align: 'center', + }).setOrigin(0.5); + const close = (fn) => () => { root.destroy(true); this.modalOpen = false; fn?.(); }; + const yes = new Button(this, GAME_WIDTH / 2 - 110, GAME_HEIGHT / 2 + 50, 'YES', close(onYes), + { width: 180, height: 52 }); + const no = new Button(this, GAME_WIDTH / 2 + 110, GAME_HEIGHT / 2 + 50, 'NO', close(onNo), + { width: 180, height: 52, variant: 'ghost' }); + root.add([dim, panel, txt, yes, no]); + } + + // --------------------------------------------------------------------------- + // Screens + + openTech() { + if (this.modalOpen) return; + this.modalOpen = true; + openTechScreen(this, this.rules, this.state, () => { + this.modalOpen = false; + this.refreshHud(); + }); + } + + openDiplomacy() { + if (this.modalOpen) return; + this.modalOpen = true; + openDiplomacyScreen(this, this.rules, this.state, this.opponentsData, respondToProposal, () => { + this.modalOpen = false; + this.view.refresh(); + this.refreshHud(); + }); + } + + openSpaceship() { + if (this.modalOpen) return; + this.modalOpen = true; + openSpaceshipScreen(this, this.rules, this.state, () => { + this.modalOpen = false; + this.refreshHud(); + if (this.state.over) this.onGameOver(); + }); + } + + openMenu() { + if (this.modalOpen) return; + this.modalOpen = true; + const root = this.add.container(0, 0).setDepth(D.modal); + const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55) + .setInteractive(); + const panel = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, 460, 340, COLORS.panel) + .setStrokeStyle(2, COLORS.accent); + const title = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 120, 'MENU', { + fontFamily: 'Righteous', fontSize: '32px', color: COLORS.accentHex, + }).setOrigin(0.5); + const close = () => { root.destroy(true); this.modalOpen = false; }; + const resume = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 - 50, 'RESUME', close, + { width: 320, height: 56 }); + const save = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 20, 'SAVE GAME', () => { + this.saveGame(); + this.toast('Game saved'); + close(); + }, { width: 320, height: 56, variant: 'ghost' }); + const quit = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 90, 'SAVE & QUIT', () => { + this.saveGame(); + this.scene.start('GameMenu'); + }, { width: 320, height: 56, variant: 'ghost' }); + root.add([dim, panel, title, resume, save, quit]); + } + + onGameOver() { + if (this.phase === 'over') return; + this.phase = 'over'; + this.busy = false; + this.clearSave(); + showVictoryOverlay(this, this.rules, this.state, this.opponentsData, () => { + this.scene.restart({ game: this.gameDef }); + }); + } +} diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js new file mode 100644 index 0000000..b6593f6 --- /dev/null +++ b/src/games/civilization/CivilizationLogic.js @@ -0,0 +1,1580 @@ +// Civilization — headless game engine. No Phaser imports; runs in Node for +// tools/verifyCivilization.js and in the browser scene. +// +// Simplifications vs Civ II (by design, see plan/sprites.md): no happiness or +// tax sliders (fixed 50/50 gold/science trade split), no senate, no wonders, +// no pollution, no zones of control, no rivers. Movement points are stored in +// thirds (road = 1/3, railroad = free). Combat is the Civ II round model: +// p(hit) = A/(A+D), loser of a round loses the winner's firepower in hp. + +import { generateWorld, mulberry32, shieldGrassAt, IMP } from './CivilizationWorldGen.js'; +import { techCost } from './CivilizationRules.js'; + +export { mulberry32, shieldGrassAt, IMP }; + +export const FOOD_PER_CITIZEN = 2; +export const FOODBOX_PER_SIZE = 10; +export const VET_BONUS = 1.5; +export const FORTIFY_BONUS = 1.5; +export const CITY_BASE_DEF = 1.5; // unwalled city acts like fortified ground +export const FORTRESS_BONUS = 2; +export const HEAL_FIELD = 0.1; +export const HEAL_CITY = 1 / 3; +export const PATH_EXPANSION_CAP = 400; +export const HUT_RESULTS = ['gold', 'tech', 'unit', 'ambush']; + +// Fat cross: 5x5 Chebyshev block minus the four corners = 21 tiles. +export const CITY_RADIUS = []; +for (let dy = -2; dy <= 2; dy += 1) { + for (let dx = -2; dx <= 2; dx += 1) { + if (Math.abs(dx) === 2 && Math.abs(dy) === 2) continue; + CITY_RADIUS.push([dx, dy]); + } +} + +export function rand(state) { + // Explicit-state mulberry32 step so the RNG serializes with the game. + let a = state.rngState | 0; + a = (a + 0x6d2b79f5) | 0; + state.rngState = a; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} +export function randInt(state, n) { return Math.floor(rand(state) * n); } + +export function cheb(x1, y1, x2, y2) { + return Math.max(Math.abs(x1 - x2), Math.abs(y1 - y2)); +} + +// --------------------------------------------------------------------------- +// Game creation + +export function createGame(rules, opts) { + const { + sizeId = 'medium', seed = 1, difficultyId = 'prince', + leaders, humanIndex = 0, + } = opts; + const numCivs = leaders.length; + const world = generateWorld(rules, { sizeId, seed, numCivs }); + const difficulty = rules.difficulties[difficultyId]; + const n = world.cols * world.rows; + + const state = { + version: 1, + rules: null, // attached transiently via attachRules; never serialized + seed, + rngState: (seed * 2654435761) | 0, + sizeId, + difficultyId, + turn: 0, + current: 0, + humanIndex, + world, + civs: [], + cities: [], + units: [], + nextUnitId: 1, + nextCityId: 1, + explored: [], + over: null, + events: [], + }; + + const nameOrder = shuffledIndexes(state, rules.cityNames.length); + for (let i = 0; i < numCivs; i += 1) { + const relations = {}; + const attitude = {}; + for (let j = 0; j < numCivs; j += 1) { + if (j !== i) { relations[j] = 'nocontact'; attitude[j] = 0; } + } + state.civs.push({ + id: i, + leaderId: leaders[i].id, + name: leaders[i].name, + color: rules.playerColors[i % rules.playerColors.length], + human: i === humanIndex, + alive: true, + government: 'despotism', + revolutionTurns: 0, + pendingGovernment: null, + gold: 50, + beakers: 0, + researching: null, + known: {}, + futureCount: 0, + relations, + attitude, + reputation: 0, + spaceship: { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 }, + nameCursor: i, // stride through the shared shuffled name pool + nameOrder, + score: 0, + }); + state.explored.push(new Array(n).fill(0)); + } + + // Starting settlers (+1 for AI at higher difficulties). + const startSettlers = rules.worldSizes[sizeId].startSettlers; + for (let i = 0; i < numCivs; i += 1) { + const [sx, sy] = world.starts[i]; + const count = startSettlers + (state.civs[i].human ? 0 : difficulty.aiStartUnits); + for (let k = 0; k < count; k += 1) { + const spot = k === 0 ? [sx, sy] : nearbyLandSpot(rules, state, sx, sy); + spawnUnit(rules, state, i, 'settlers', spot[0], spot[1], null); + } + exploreAround(state, i, sx, sy, 2); + } + return state; +} + +function shuffledIndexes(state, len) { + const arr = Array.from({ length: len }, (_, i) => i); + for (let i = arr.length - 1; i > 0; i -= 1) { + const j = randInt(state, i + 1); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +function nearbyLandSpot(rules, state, x, y) { + const { world } = state; + for (let r = 1; r <= 3; r += 1) { + for (let dy = -r; dy <= r; dy += 1) { + for (let dx = -r; dx <= r; dx += 1) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= world.cols || ny >= world.rows) continue; + const terr = rules.terrainList[world.terrain[ny * world.cols + nx]]; + if (!terr.water && terr.id !== 'glacier' && terr.id !== 'mountains') return [nx, ny]; + } + } + } + return [x, y]; +} + +// --------------------------------------------------------------------------- +// Lookups + +export function tileIndex(world, x, y) { return y * world.cols + x; } +export function inBounds(world, x, y) { + return x >= 0 && y >= 0 && x < world.cols && y < world.rows; +} +export function terrainAt(rules, world, x, y) { + return rules.terrainList[world.terrain[tileIndex(world, x, y)]]; +} +export function cityAt(state, x, y) { + return state.cities.find((c) => c.x === x && c.y === y) ?? null; +} +export function unitsAt(state, x, y) { + return state.units.filter((u) => u.x === x && u.y === y && !u.carriedBy); +} +export function unitById(state, id) { return state.units.find((u) => u.id === id) ?? null; } +export function cityById(state, id) { return state.cities.find((c) => c.id === id) ?? null; } +export function civUnits(state, civ) { return state.units.filter((u) => u.civ === civ); } +export function civCities(state, civ) { return state.cities.filter((c) => c.civ === civ); } + +export function knowsTech(civ, techId) { return !!civ.known[techId]; } +export function knownCount(civ) { return Object.keys(civ.known).length + civ.futureCount; } + +export function availableTechs(rules, civ) { + return rules.techList.filter((t) => (t.repeatable || !civ.known[t.id]) + && t.prereqs.every((p) => civ.known[p])); +} + +export function availableUnits(rules, state, civ, city) { + return rules.unitList.filter((u) => { + if (u.prereq && !knowsTech(civ, u.prereq)) return false; + if (u.obsoletedBy && knowsTech(civ, rules.units[u.obsoletedBy].prereq)) return false; + if (u.domain === 'sea' && !isCoastal(rules, state, city)) return false; + if (u.flags.includes('spaceship')) { + const ship = state.civs[civ.id].spaceship; + const cap = { ssstructural: rules.spaceship.structuralNeeded, + sscomponent: rules.spaceship.componentsNeeded, + ssmodule: rules.spaceship.modulesNeeded }[u.id]; + const have = { ssstructural: ship.structural, sscomponent: ship.component, + ssmodule: ship.module }[u.id]; + if (ship.launched || have >= cap) return false; + } + return true; + }); +} + +export function availableBuildings(rules, state, civ, city) { + return rules.buildingList.filter((b) => { + if (city.buildings[b.id]) return false; + if (b.prereq && !knowsTech(civ, b.prereq)) return false; + if (b.requires && !city.buildings[b.requires]) return false; + if (b.effect === 'power' && hasPowerPlant(city)) return false; + if ((b.effect === 'oceanfood' || b.effect === 'oceanshield' || b.effect === 'defensesea') + && !isCoastal(rules, state, city)) return false; + return true; + }); +} + +function hasPowerPlant(city) { + return !!(city.buildings.powerplant || city.buildings.hydroplant || city.buildings.nuclearplant); +} + +export function isCoastal(rules, state, city) { + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]) { + const x = city.x + dx; + const y = city.y + dy; + if (inBounds(state.world, x, y) && terrainAt(rules, state.world, x, y).water) return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Tile yields + +export function tileYield(rules, state, civIdx, city, x, y) { + const { world } = state; + const i = tileIndex(world, x, y); + const terr = rules.terrainList[world.terrain[i]]; + const special = world.special[i] >= 0 ? rules.specialList[world.special[i]] : null; + let food = special ? special.food : terr.food; + let shield = special ? special.shield : terr.shield; + let trade = special ? special.trade : terr.trade; + if (!special && terr.id === 'grassland' && shieldGrassAt(x, y)) shield += 1; + + const imp = world.improvements[i]; + if ((imp & IMP.IRRIGATION) && terr.irrigate) food += terr.irrigate; + if ((imp & IMP.MINE) && terr.mine) shield += terr.mine; + if ((imp & IMP.ROAD) && !terr.water && terr.move === 1) trade += 1; + if ((imp & IMP.RAILROAD) && shield >= 1) shield += 1; + if ((imp & IMP.FARMLAND) && city && city.buildings.supermarket) { + food = Math.floor(food * 1.5); + } + if (city) { + if (terr.water && city.buildings.harbor) food += 1; + if (terr.water && city.buildings.offshoreplatform) shield += 1; + } + + const civ = state.civs[civIdx]; + const gov = rules.governments[civ.government]; + if (gov.tradeBonus && trade >= 1) trade += gov.tradeBonus; + if (gov.despotPenalty) { + if (food >= 3) food -= 1; + if (shield >= 3) shield -= 1; + if (trade >= 3) trade -= 1; + } + return { food, shield, trade }; +} + +const EMPHASIS_WEIGHTS = { + balanced: { food: 3, shield: 2, trade: 1 }, + food: { food: 6, shield: 1, trade: 1 }, + production: { food: 1, shield: 5, trade: 1 }, + trade: { food: 1, shield: 1, trade: 5 }, +}; + +export function autoAssignTiles(rules, state, city) { + const { world } = state; + const weights = EMPHASIS_WEIGHTS[city.emphasis] ?? EMPHASIS_WEIGHTS.balanced; + const takenElsewhere = new Set(); + for (const other of state.cities) { + if (other.id === city.id) continue; + for (const t of other.worked) takenElsewhere.add(t); + } + const options = []; + for (const [dx, dy] of CITY_RADIUS) { + if (dx === 0 && dy === 0) continue; + const x = city.x + dx; + const y = city.y + dy; + if (!inBounds(world, x, y)) continue; + const idx = tileIndex(world, x, y); + if (takenElsewhere.has(idx)) continue; + const other = cityAt(state, x, y); + if (other) continue; + const yld = tileYield(rules, state, city.civ, city, x, y); + options.push({ idx, w: yld.food * weights.food + yld.shield * weights.shield + yld.trade * weights.trade }); + } + options.sort((a, b) => b.w - a.w || a.idx - b.idx); + city.worked = options.slice(0, city.size).map((o) => o.idx); +} + +export function cityYields(rules, state, city) { + const civ = state.civs[city.civ]; + const gov = rules.governments[civ.government]; + const centre = tileYield(rules, state, city.civ, city, city.x, city.y); + let food = centre.food; + let shield = Math.max(1, centre.shield); // city tile always makes 1 shield... + let trade = Math.max(1, centre.trade); // ...and 1 trade (the market economy floor) + for (const idx of city.worked) { + const x = idx % state.world.cols; + const y = (idx / state.world.cols) | 0; + const yld = tileYield(rules, state, city.civ, city, x, y); + food += yld.food; + shield += yld.shield; + trade += yld.trade; + } + + // Trade routes. + let routeTrade = 0; + for (const r of city.routes) routeTrade += r.amount; + trade += routeTrade; + + // Corruption. + const capital = civCities(state, city.civ).find((c) => c.buildings.palace); + const dist = gov.flatCorruption ? 10 + : (capital ? cheb(city.x, city.y, capital.x, capital.y) : 16); + let corruption = Math.floor(trade * gov.corruptionFactor * Math.min(1, dist / 20)); + if (city.buildings.courthouse) corruption = Math.floor(corruption * 0.5); + corruption = Math.min(corruption, trade); + const netTrade = trade - corruption; + + // Fixed 50/50 tax split (no sliders in this build), then building multipliers. + // Science gets the odd arrow: with rivers cut, early cities often make just + // 1 trade, and research must never round down to a permanent zero. + 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; + for (const bId of Object.keys(city.buildings)) { + const b = rules.buildings[bId]; + if (!b) continue; + if (b.effect === 'gold') goldMult += b.value; + if (b.effect === 'science') sciMult += b.value; + if (b.effect === 'shields') shieldMult += b.value; + if (b.effect === 'power' && city.buildings.factory) shieldMult += b.value; + } + + // Unit support: shields per supported unit beyond the free allowance + // (Democracy pays gold instead), settlers also eat food. + const supported = state.units.filter((u) => u.homeCity === city.id); + let supportShields = 0; + let supportGold = 0; + let settlerFood = 0; + let combatants = 0; + for (const u of supported) { + const def = rules.units[u.type]; + if (def.flags.includes('settler')) settlerFood += gov.settlerFood; + if (def.domain === 'project' || def.flags.includes('noncombat')) continue; + combatants += 1; + if (combatants > gov.freeUnits) { + if (gov.unitUpkeep === 'gold') supportGold += 1; + else supportShields += 1; + } + } + + const grossShield = Math.floor(shield * shieldMult); + const netShield = Math.max(0, grossShield - supportShields); + const foodNeed = city.size * FOOD_PER_CITIZEN + settlerFood; + const upkeep = Object.keys(city.buildings) + .reduce((sum, id) => sum + (rules.buildings[id]?.upkeep ?? 0), 0); + + return { + food, foodNeed, foodSurplus: food - foodNeed, + shield: netShield, grossShield, supportShields, + trade, corruption, netTrade, routeTrade, + gold: Math.floor(baseGold * goldMult), science: Math.floor(baseScience * sciMult), + upkeep, supportGold, + }; +} + +// --------------------------------------------------------------------------- +// Cities + +export function nextCityName(rules, state, civ) { + const c = state.civs[civ]; + if (civCities(state, civ).length === 0) return `${c.name} City`; + const pool = rules.cityNames; + const idx = c.nameOrder[c.nameCursor % pool.length]; + const round = Math.floor(c.nameCursor / pool.length); + c.nameCursor += state.civs.length; + return round > 0 ? `${pool[idx]} ${'I'.repeat(round + 1)}` : pool[idx]; +} + +export function canFoundCity(rules, state, x, y) { + const terr = terrainAt(rules, state.world, x, y); + if (terr.water || terr.id === 'glacier') return false; + for (const c of state.cities) { + if (cheb(c.x, c.y, x, y) < 2) return false; + } + return true; +} + +export function foundCity(rules, state, unit) { + if (!canFoundCity(rules, state, unit.x, unit.y)) return null; + const civ = state.civs[unit.civ]; + const city = { + id: state.nextCityId, + civ: unit.civ, + x: unit.x, + y: unit.y, + name: nextCityName(rules, state, unit.civ), + size: 1, + foodBox: 0, + shieldBox: 0, + build: { type: 'unit', id: 'warriors' }, + buildings: {}, + worked: [], + emphasis: 'balanced', + routes: [], + boughtThisTurn: false, + }; + state.nextCityId += 1; + if (civCities(state, unit.civ).length === 0) city.buildings.palace = true; + // Civ II treats the city square as having a road (its main early trade). + state.world.improvements[tileIndex(state.world, city.x, city.y)] |= IMP.ROAD; + state.cities.push(city); + removeUnit(state, unit); + autoAssignTiles(rules, state, city); + exploreAround(state, unit.civ, city.x, city.y, 2); + state.events.push({ type: 'cityFounded', civ: unit.civ, cityId: city.id, name: city.name }); + return city; +} + +export function setBuild(rules, state, city, type, id) { + if (city.build && city.build.type !== type && city.shieldBox > 0) { + city.shieldBox = Math.floor(city.shieldBox / 2); // Civ II class-switch penalty + } + city.build = { type, id }; +} + +export function buildCost(rules, city) { + return city.build.type === 'unit' + ? rules.units[city.build.id].cost + : rules.buildings[city.build.id].cost; +} + +export function buyCost(rules, city) { + const remaining = Math.max(0, buildCost(rules, city) - city.shieldBox); + return Math.ceil(remaining * (city.build.type === 'unit' ? 2.5 : 2)); +} + +export function buyBuild(rules, state, city) { + const civ = state.civs[city.civ]; + const cost = buyCost(rules, city); + if (city.boughtThisTurn || civ.gold < cost) return false; + civ.gold -= cost; + city.shieldBox = buildCost(rules, city); + city.boughtThisTurn = true; + return true; +} + +export function sellBuilding(rules, state, city, buildingId) { + if (!city.buildings[buildingId] || buildingId === 'palace') return false; + delete city.buildings[buildingId]; + state.civs[city.civ].gold += rules.buildings[buildingId].cost; + return true; +} + +function completeBuild(rules, state, city) { + const civ = state.civs[city.civ]; + const { type, id } = city.build; + if (type === 'building') { + city.buildings[id] = true; + city.shieldBox = 0; + state.events.push({ type: 'buildingDone', civ: city.civ, cityId: city.id, building: id }); + pickNextBuild(rules, state, city); + return; + } + const def = rules.units[id]; + if (def.flags.includes('spaceship')) { + const ship = civ.spaceship; + if (id === 'ssstructural') ship.structural += 1; + if (id === 'sscomponent') ship.component += 1; + if (id === 'ssmodule') ship.module += 1; + city.shieldBox = 0; + state.events.push({ type: 'spaceshipPart', civ: city.civ, part: id }); + pickNextBuild(rules, state, city); + return; + } + if (def.flags.includes('settler')) { + if (city.size < 2) return; // hold until the city can spare the population + city.size -= 1; + autoAssignTiles(rules, state, city); + } + const unit = spawnUnit(rules, state, city.civ, id, city.x, city.y, city.id); + if (city.buildings.barracks && def.domain === 'land') unit.vet = true; + city.shieldBox = 0; + state.events.push({ type: 'unitDone', civ: city.civ, cityId: city.id, unit: id }); +} + +function pickNextBuild(rules, state, city) { + // Fall back to something always buildable after finishing a build. + const civ = state.civs[city.civ]; + const units = availableUnits(rules, state, civ, city).filter((u) => !u.flags.includes('spaceship')); + const best = units.filter((u) => u.domain === 'land' && !u.flags.includes('noncombat')) + .sort((a, b) => b.defense - a.defense)[0] ?? units[0]; + if (best) city.build = { type: 'unit', id: best.id }; +} + +function processCity(rules, state, city) { + const civ = state.civs[city.civ]; + autoAssignTiles(rules, state, city); + const y = cityYields(rules, state, city); + + // Food. + city.foodBox += y.foodSurplus; + const boxSize = (city.size + 1) * FOODBOX_PER_SIZE; + if (city.foodBox >= boxSize) { + const cap = sizeCap(rules, city); + if (city.size < cap) { + city.size += 1; + city.foodBox = city.buildings.granary ? Math.floor(boxSize / 2) : 0; + state.events.push({ type: 'cityGrew', civ: city.civ, cityId: city.id, size: city.size }); + } else { + city.foodBox = boxSize; // capped until an aqueduct/sewer arrives + } + } else if (city.foodBox < 0) { + // Famine: starve a citizen, or a supported settler first. + const settler = state.units.find((u) => u.homeCity === city.id + && rules.units[u.type].flags.includes('settler')); + if (settler) removeUnit(state, settler); + else { + city.size -= 1; + state.events.push({ type: 'cityShrank', civ: city.civ, cityId: city.id, size: city.size }); + } + city.foodBox = 0; + if (city.size <= 0) { destroyCity(rules, state, city); return; } + } + + // Shields. + city.shieldBox += y.shield; + if (city.shieldBox >= buildCost(rules, city)) completeBuild(rules, state, city); + city.boughtThisTurn = false; + + // Economy. + civ.gold += y.gold - y.upkeep - y.supportGold; + if (!rules.governments[civ.government].noScience) civ.beakers += y.science; + + // Bankruptcy: auto-sell the cheapest sellable building. + if (civ.gold < 0) { + const sellable = Object.keys(city.buildings).filter((b) => b !== 'palace'); + if (sellable.length) { + sellable.sort((a, b) => rules.buildings[a].cost - rules.buildings[b].cost); + sellBuilding(rules, state, city, sellable[0]); + state.events.push({ type: 'buildingSold', civ: city.civ, cityId: city.id, building: sellable[0] }); + } + if (civ.gold < 0) civ.gold = 0; + } +} + +export function sizeCap(rules, city) { + if (city.buildings.sewersystem) return 99; + if (city.buildings.aqueduct) return 12; + return 8; +} + +function destroyCity(rules, state, city) { + state.cities = state.cities.filter((c) => c.id !== city.id); + for (const u of state.units.filter((un) => un.homeCity === city.id)) u.homeCity = null; + for (const other of state.cities) { + other.routes = other.routes.filter((r) => r.cityId !== city.id); + } + state.events.push({ type: 'cityDestroyed', cityId: city.id, name: city.name }); +} + +// --------------------------------------------------------------------------- +// Research & government + +export function currentResearchCost(rules, state, civ) { + const diff = rules.difficulties[state.difficultyId]; + const factor = civ.human ? diff.humanResearchFactor : 1 / diff.aiScienceBonus; + return techCost(knownCount(civ), factor); +} + +export function setResearch(rules, state, civ, techId) { + const tech = rules.techs[techId]; + if (!tech) return false; + if (!tech.repeatable && civ.known[techId]) return false; + if (!tech.prereqs.every((p) => civ.known[p])) return false; + civ.researching = techId; + return true; +} + +function progressResearch(rules, state, civ) { + if (!civ.researching) return; + const cost = currentResearchCost(rules, state, civ); + if (civ.beakers < cost) return; + civ.beakers -= cost; + const techId = civ.researching; + if (rules.techs[techId].repeatable) civ.futureCount += 1; + else civ.known[techId] = true; + civ.researching = null; + state.events.push({ type: 'techDone', civ: civ.id, tech: techId }); +} + +export function grantTech(rules, state, civ, techId) { + if (rules.techs[techId].repeatable) civ.futureCount += 1; + else civ.known[techId] = true; + if (civ.researching === techId) civ.researching = null; +} + +export function startRevolution(rules, state, civ, targetGovId) { + const gov = rules.governments[targetGovId]; + if (!gov || (gov.prereq && !civ.known[gov.prereq])) return false; + if (targetGovId === civ.government) return false; + civ.government = 'anarchy'; + civ.pendingGovernment = targetGovId; + civ.revolutionTurns = 2 + randInt(state, 3); + state.events.push({ type: 'revolution', civ: civ.id, target: targetGovId }); + return true; +} + +function progressRevolution(rules, state, civ) { + if (civ.government !== 'anarchy' || !civ.pendingGovernment) return; + civ.revolutionTurns -= 1; + if (civ.revolutionTurns <= 0) { + civ.government = civ.pendingGovernment; + civ.pendingGovernment = null; + state.events.push({ type: 'newGovernment', civ: civ.id, government: civ.government }); + } +} + +// --------------------------------------------------------------------------- +// Units & movement + +export function spawnUnit(rules, state, civIdx, type, x, y, homeCity) { + const def = rules.units[type]; + const unit = { + id: state.nextUnitId, + civ: civIdx, + type, + x, y, + hp: def.hp, + mp: def.move * 3, + vet: false, + fortified: false, + sentry: false, + moved: false, + homeCity, + carriedBy: null, + order: null, + }; + state.nextUnitId += 1; + state.units.push(unit); + return unit; +} + +export function removeUnit(state, unit) { + for (const u of state.units) if (u.carriedBy === unit.id) removeUnit(state, u); + state.units = state.units.filter((x) => x.id !== unit.id); +} + +export function moveCost(rules, state, unit, fx, fy, tx, ty) { + const def = rules.units[unit.type]; + const { world } = state; + const toTerr = terrainAt(rules, world, tx, ty); + if (def.domain === 'air') return 3; + if (def.domain === 'sea') { + if (!toTerr.water && !cityAt(state, tx, ty)) return Infinity; + return 3; + } + // Land. + if (toTerr.water) return Infinity; // boarding handled in tryMove + const fi = tileIndex(world, fx, fy); + const ti = tileIndex(world, tx, ty); + const bothRail = (world.improvements[fi] & IMP.RAILROAD) && (world.improvements[ti] & IMP.RAILROAD); + if (bothRail) return 0; + const bothRoad = (world.improvements[fi] & (IMP.ROAD | IMP.RAILROAD)) + && (world.improvements[ti] & (IMP.ROAD | IMP.RAILROAD)); + if (bothRoad) return 1; + if (def.flags.includes('ignoreterrain')) return 1; + return toTerr.move * 3; +} + +// Whether `unit` may occupy (x,y) ignoring enemies (domain/terrain check). +export function canOccupy(rules, state, unit, x, y) { + if (!inBounds(state.world, x, y)) return false; + const def = rules.units[unit.type]; + const terr = terrainAt(rules, state.world, x, y); + const city = cityAt(state, x, y); + if (def.domain === 'sea') { + if (city) return city.civ === unit.civ; + if (!terr.water) return false; + if (def.flags.includes('coastal')) { + // Triremes hug the coast: some adjacent land required. + let coast = false; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + const nx = x + dx; + const ny = y + dy; + if (inBounds(state.world, nx, ny) && !terrainAt(rules, state.world, nx, ny).water) coast = true; + } + } + return coast; + } + return true; + } + if (def.domain === 'air') return true; + return !terr.water; +} + +// One-step move/attack/board. Returns an outcome object. +export function tryMove(rules, state, unit, dx, dy) { + if (state.over) return { result: 'invalid' }; + if (Math.abs(dx) > 1 || Math.abs(dy) > 1 || (dx === 0 && dy === 0)) return { result: 'invalid' }; + if (unit.mp <= 0 || unit.carriedBy) return { result: 'invalid' }; + const tx = unit.x + dx; + const ty = unit.y + dy; + if (!inBounds(state.world, tx, ty)) return { result: 'invalid' }; + const def = rules.units[unit.type]; + const targetCity = cityAt(state, tx, ty); + const targets = unitsAt(state, tx, ty).filter((u) => u.civ !== unit.civ); + + // Attack? + if (targets.length || (targetCity && targetCity.civ !== unit.civ)) { + const enemyCiv = targets.length ? targets[0].civ : targetCity.civ; + if (state.civs[unit.civ].relations[enemyCiv] !== 'war') { + return { result: 'blocked', needsWar: enemyCiv }; + } + if (def.attack <= 0) return { result: 'invalid' }; + if (def.domain === 'land' && terrainAt(rules, state.world, tx, ty).water) return { result: 'invalid' }; + if (def.domain === 'sea' && !terrainAt(rules, state.world, tx, ty).water && !targetCity) return { result: 'invalid' }; + if (targets.length === 0 && targetCity) { + // Undefended city: land units capture, sea/air just raid the walls. + if (def.domain !== 'land') return { result: 'invalid' }; + spendMove(unit, 3); + return captureCity(rules, state, unit, targetCity); + } + return resolveAttack(rules, state, unit, tx, ty); + } + + // Board a transport? + if (def.domain === 'land' && terrainAt(rules, state.world, tx, ty).water) { + const boat = unitsAt(state, tx, ty).find((u) => u.civ === unit.civ + && (rules.units[u.type].cargo ?? 0) > cargoCount(state, u)); + if (boat) { + unit.x = tx; unit.y = ty; unit.carriedBy = boat.id; unit.mp = 0; unit.moved = true; + return { result: 'boarded', boat: boat.id }; + } + return { result: 'invalid' }; + } + + if (!canOccupy(rules, state, unit, tx, ty)) return { result: 'invalid' }; + if (targetCity && targetCity.civ !== unit.civ) return { result: 'invalid' }; + + const cost = moveCost(rules, state, unit, unit.x, unit.y, tx, ty); + if (!Number.isFinite(cost)) return { result: 'invalid' }; + spendMove(unit, cost); + unit.x = tx; + unit.y = ty; + unit.fortified = false; + unit.moved = true; + dropCarried(rules, state, unit, tx, ty); + exploreAround(state, unit.civ, tx, ty, 2); + makeContacts(rules, state, unit.civ, tx, ty); + + const hutIdx = tileIndex(state.world, tx, ty); + if (state.world.huts[hutIdx]) { + state.world.huts[hutIdx] = 0; + return { result: 'moved', hut: resolveHut(rules, state, unit) }; + } + return { result: 'moved' }; +} + +function spendMove(unit, cost) { + unit.mp = Math.max(0, unit.mp - Math.max(0, cost)); + unit.moved = true; +} + +function cargoCount(state, boat) { + return state.units.filter((u) => u.carriedBy === boat.id).length; +} + +function dropCarried(rules, state, unit, x, y) { + // Units riding a transport move with it; disembark handled by their own move. + for (const u of state.units) { + if (u.carriedBy === unit.id) { u.x = x; u.y = y; } + } + if (unit.carriedBy) unit.carriedBy = null; +} + +export function disembark(rules, state, unit, dx, dy) { + if (!unit.carriedBy || unit.mp <= 0) return { result: 'invalid' }; + const boat = unitById(state, unit.carriedBy); + if (!boat) { unit.carriedBy = null; return { result: 'invalid' }; } + unit.carriedBy = null; + unit.mp = 3; // stepping ashore takes the turn's movement + const out = tryMove(rules, state, unit, dx, dy); + if (out.result === 'invalid' || out.result === 'blocked') { + unit.carriedBy = boat.id; + unit.mp = 0; + } + return out; +} + +function resolveHut(rules, state, unit) { + const civ = state.civs[unit.civ]; + const roll = rand(state); + if (roll < 0.4) { + const gold = 25 * (1 + randInt(state, 4)); + civ.gold += gold; + state.events.push({ type: 'hut', civ: civ.id, outcome: 'gold', gold }); + return { outcome: 'gold', gold }; + } + if (roll < 0.65) { + const options = availableTechs(rules, civ).filter((t) => t.era === 'ancient' && !t.repeatable); + if (options.length) { + const tech = options[randInt(state, options.length)]; + grantTech(rules, state, civ, tech.id); + state.events.push({ type: 'hut', civ: civ.id, outcome: 'tech', tech: tech.id }); + return { outcome: 'tech', tech: tech.id }; + } + civ.gold += 50; + state.events.push({ type: 'hut', civ: civ.id, outcome: 'gold', gold: 50 }); + return { outcome: 'gold', gold: 50 }; + } + if (roll < 0.85) { + const type = civ.known.chivalry ? 'knights' : (civ.known.ironworking ? 'legion' : 'horsemen'); + spawnUnit(rules, state, unit.civ, type, unit.x, unit.y, null); + state.events.push({ type: 'hut', civ: civ.id, outcome: 'unit', unit: type }); + return { outcome: 'unit', unit: type }; + } + // Ambush: fight a phantom era-scaled hostile on the spot. + const phantomType = civ.known.conscription ? 'riflemen' : (civ.known.gunpowder ? 'musketeers' : 'legion'); + const phantom = rules.units[phantomType]; + const def = rules.units[unit.type]; + const survived = simulateDuel(state, Math.max(1, def.attack), def.hp, def.fp, + phantom.defense, phantom.hp, phantom.fp, unit.hp); + if (!survived.attackerWon) { + removeUnit(state, unit); + state.events.push({ type: 'hut', civ: civ.id, outcome: 'ambushLost' }); + return { outcome: 'ambushLost' }; + } + unit.hp = survived.attackerHp; + state.events.push({ type: 'hut', civ: civ.id, outcome: 'ambushWon' }); + return { outcome: 'ambushWon' }; +} + +// --------------------------------------------------------------------------- +// Combat + +export function defenderStrength(rules, state, defUnit, attacker) { + const def = rules.units[defUnit.type]; + const attDef = rules.units[attacker.type]; + const terr = terrainAt(rules, state.world, defUnit.x, defUnit.y); + const city = cityAt(state, defUnit.x, defUnit.y); + const idx = tileIndex(state.world, defUnit.x, defUnit.y); + let d = def.defense * (defUnit.vet ? VET_BONUS : 1) * (defUnit.hp / def.hp); + d *= terr.defense; + if (defUnit.fortified) d *= FORTIFY_BONUS; + if (state.world.improvements[idx] & IMP.FORTRESS) d *= FORTRESS_BONUS; + if (city) { + if (city.buildings.citywalls && attDef.domain === 'land' && !attDef.flags.includes('ignorewalls')) { + d *= rules.buildings.citywalls.value; + } else if (!defUnit.fortified) { + d *= CITY_BASE_DEF; + } + if (attDef.domain === 'sea' && city.buildings.coastalfortress) d *= rules.buildings.coastalfortress.value; + if (attDef.domain === 'air' && city.buildings.sambattery) d *= rules.buildings.sambattery.value; + } + if (def.flags.includes('antimounted') && attDef.flags.includes('mounted')) d *= 2; + return d; +} + +export function attackerStrength(rules, state, unit) { + const def = rules.units[unit.type]; + return def.attack * (unit.vet ? VET_BONUS : 1) * (unit.hp / def.hp); +} + +export function pickDefender(rules, state, x, y, attacker) { + const targets = unitsAt(state, x, y).filter((u) => u.civ !== attacker.civ); + if (!targets.length) return null; + let best = targets[0]; + let bestD = -1; + for (const t of targets) { + const d = defenderStrength(rules, state, t, attacker); + if (d > bestD) { bestD = d; best = t; } + } + return best; +} + +export function simulateDuel(state, A, aHpMax, aFp, D, dHpMax, dFp, aHpStart) { + let aHp = aHpStart; + let dHp = dHpMax; + const p = (A + D) > 0 ? A / (A + D) : 1; + while (aHp > 0 && dHp > 0) { + if (rand(state) < p) dHp -= aFp; + else aHp -= dFp; + } + return { attackerWon: dHp <= 0, attackerHp: Math.max(0, aHp), defenderHp: Math.max(0, dHp) }; +} + +export function resolveAttack(rules, state, attacker, tx, ty) { + const attDef = rules.units[attacker.type]; + const defender = pickDefender(rules, state, tx, ty, attacker); + if (!defender) return { result: 'invalid' }; + const defDef = rules.units[defender.type]; + + // Nukes: no combat — obliterate the tile (SDI blocks). + if (attDef.flags.includes('nuke')) { + const city = cityAt(state, tx, ty); + if (city && city.buildings.sdidefense) { + removeUnit(state, attacker); + state.events.push({ type: 'nukeBlocked', cityId: city.id }); + return { result: 'nukeBlocked' }; + } + for (const u of unitsAt(state, tx, ty)) removeUnit(state, u); + if (city) city.size = Math.max(1, Math.ceil(city.size / 2)); + removeUnit(state, attacker); + state.events.push({ type: 'nuke', x: tx, y: ty, cityId: city?.id ?? null }); + checkVictory(rules, state); + return { result: 'nuked' }; + } + + const A = attackerStrength(rules, state, attacker); + const D = defenderStrength(rules, state, defender, attacker); + const duel = simulateDuel(state, A, attDef.hp, attDef.fp, D, defDef.hp, defDef.fp, attacker.hp); + + const city = cityAt(state, tx, ty); + const idx = tileIndex(state.world, tx, ty); + const protectedStack = !!city || !!(state.world.improvements[idx] & IMP.FORTRESS); + + if (duel.attackerWon) { + removeUnit(state, defender); + if (!protectedStack) { + for (const u of unitsAt(state, tx, ty).filter((un) => un.civ === defender.civ)) removeUnit(state, u); + } + attacker.hp = Math.max(1, duel.attackerHp); + if (!attacker.vet && rand(state) < 0.5) attacker.vet = true; + } else { + defender.hp = Math.max(1, duel.defenderHp); + if (!defender.vet && rand(state) < 0.5) defender.vet = true; + removeUnit(state, attacker); + } + spendMove(attacker, 3); + if (attDef.flags.includes('missile') && duel.attackerWon) removeUnit(state, attacker); + + state.events.push({ + type: 'combat', x: tx, y: ty, + attacker: { civ: attacker.civ, type: attacker.type }, + defender: { civ: defender.civ, type: defender.type }, + attackerWon: duel.attackerWon, + }); + checkVictory(rules, state); + return { result: 'combat', won: duel.attackerWon }; +} + +export function captureCity(rules, state, unit, city) { + const oldCiv = state.civs[city.civ]; + const newCivIdx = unit.civ; + const loot = 50 + 10 * city.size; + const wasCapital = !!city.buildings.palace; + state.civs[newCivIdx].gold += Math.min(oldCiv.gold, loot); + oldCiv.gold = Math.max(0, oldCiv.gold - loot); + delete city.buildings.palace; + city.civ = newCivIdx; + city.size = Math.max(1, city.size - 1); + city.routes = []; + city.shieldBox = 0; + city.build = { type: 'unit', id: 'warriors' }; + for (const u of state.units.filter((un) => un.homeCity === city.id)) u.homeCity = null; + unit.x = city.x; + unit.y = city.y; + unit.moved = true; + exploreAround(state, newCivIdx, city.x, city.y, 2); + + // A capital lost mid-flight destroys the spaceship. + if (wasCapital && oldCiv.spaceship.launched) { + oldCiv.spaceship = { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 }; + state.events.push({ type: 'spaceshipLost', civ: oldCiv.id }); + } + // Relocate palace to another city (free) if any remain. + const remaining = civCities(state, oldCiv.id); + if (wasCapital && remaining.length) remaining[0].buildings.palace = true; + + state.events.push({ type: 'cityCaptured', cityId: city.id, name: city.name, from: oldCiv.id, to: newCivIdx }); + if (!remaining.length) eliminateCiv(rules, state, oldCiv.id); + checkVictory(rules, state); + return { result: 'captured', cityId: city.id }; +} + +export function eliminateCiv(rules, state, civIdx) { + const civ = state.civs[civIdx]; + if (!civ.alive) return; + civ.alive = false; + for (const u of civUnits(state, civIdx)) removeUnit(state, u); + state.events.push({ type: 'civEliminated', civ: civIdx }); +} + +// --------------------------------------------------------------------------- +// Worked orders (settlers/engineers) + +export function canWork(rules, state, unit, impId) { + const def = rules.units[unit.type]; + if (!def.flags.includes('settler')) return false; + const imp = rules.improvements[impId]; + if (!imp) return false; + if (imp.engineerOnly && !def.flags.includes('engineer')) return false; + const civ = state.civs[unit.civ]; + if (imp.prereq && !civ.known[imp.prereq]) return false; + const { world } = state; + const idx = tileIndex(world, unit.x, unit.y); + const terr = terrainAt(rules, world, unit.x, unit.y); + if (terr.water) return false; + const bits = world.improvements[idx]; + switch (impId) { + case 'road': return !(bits & IMP.ROAD); + case 'railroad': return !!(bits & IMP.ROAD) && !(bits & IMP.RAILROAD); + case 'irrigation': + return terr.irrigate !== null && !(bits & IMP.IRRIGATION) && hasWaterAccess(rules, state, unit.x, unit.y); + case 'farmland': return !!(bits & IMP.IRRIGATION) && !(bits & IMP.FARMLAND); + case 'mine': return terr.mine !== null && !(bits & IMP.MINE); + case 'fortress': return !(bits & IMP.FORTRESS) && !cityAt(state, unit.x, unit.y); + case 'transform': return terr.transform !== null; + default: return false; + } +} + +function hasWaterAccess(rules, state, x, y) { + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + const nx = x + dx; + const ny = y + dy; + if (!inBounds(state.world, nx, ny)) continue; + if (terrainAt(rules, state.world, nx, ny).water) return true; + if (state.world.improvements[tileIndex(state.world, nx, ny)] & IMP.IRRIGATION) return true; + } + } + return false; +} + +export function startWork(rules, state, unit, impId) { + if (!canWork(rules, state, unit, impId)) return false; + unit.order = { kind: 'work', imp: impId, progress: 0 }; + unit.mp = 0; + return true; +} + +function progressWork(rules, state, unit) { + if (!unit.order || unit.order.kind !== 'work') return; + const def = rules.units[unit.type]; + unit.order.progress += def.flags.includes('engineer') ? 2 : 1; + unit.mp = 0; + const imp = rules.improvements[unit.order.imp]; + if (unit.order.progress < imp.work) return; + const { world } = state; + const idx = tileIndex(world, unit.x, unit.y); + switch (unit.order.imp) { + case 'road': world.improvements[idx] |= IMP.ROAD; break; + case 'railroad': world.improvements[idx] |= IMP.RAILROAD; break; + case 'irrigation': + world.improvements[idx] = (world.improvements[idx] | IMP.IRRIGATION) & ~IMP.MINE; + break; + case 'farmland': world.improvements[idx] |= IMP.FARMLAND; break; + case 'mine': + world.improvements[idx] = (world.improvements[idx] | IMP.MINE) + & ~(IMP.IRRIGATION | IMP.FARMLAND); + break; + case 'fortress': world.improvements[idx] |= IMP.FORTRESS; break; + case 'transform': { + const terr = terrainAt(rules, world, unit.x, unit.y); + if (terr.transform) { + const target = rules.terrainList.findIndex((t) => t.id === terr.transform); + world.terrain[idx] = target; + world.improvements[idx] = 0; + } + break; + } + default: break; + } + unit.order = null; + state.events.push({ type: 'workDone', civ: unit.civ, x: unit.x, y: unit.y }); +} + +// --------------------------------------------------------------------------- +// Pathfinding (Dijkstra in move-thirds, capped) + +export function findPath(rules, state, unit, tx, ty) { + const { world } = state; + if (!inBounds(world, tx, ty)) return null; + const start = tileIndex(world, unit.x, unit.y); + const goal = tileIndex(world, tx, ty); + if (start === goal) return []; + const dist = new Map([[start, 0]]); + const prev = new Map(); + const frontier = [{ idx: start, d: 0 }]; + let expansions = 0; + while (frontier.length && expansions < PATH_EXPANSION_CAP) { + // Linear min-extract: frontier stays small under the expansion cap. + let minI = 0; + for (let i = 1; i < frontier.length; i += 1) { + if (frontier[i].d < frontier[minI].d) minI = i; + } + const { idx, d } = frontier[minI]; + frontier[minI] = frontier[frontier.length - 1]; + frontier.pop(); + if (idx === goal) break; + if (d > (dist.get(idx) ?? Infinity)) continue; + expansions += 1; + const x = idx % world.cols; + const y = (idx / world.cols) | 0; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (dx === 0 && dy === 0) continue; + const nx = x + dx; + const ny = y + dy; + if (!inBounds(world, nx, ny)) continue; + const nIdx = tileIndex(world, nx, ny); + if (nIdx !== goal) { + if (!canOccupy(rules, state, unit, nx, ny)) continue; + const blockers = unitsAt(state, nx, ny).filter((u) => u.civ !== unit.civ); + const enemyCity = cityAt(state, nx, ny); + if (blockers.length || (enemyCity && enemyCity.civ !== unit.civ)) continue; + } + const cost = moveCost(rules, state, unit, x, y, nx, ny); + if (!Number.isFinite(cost)) continue; + const nd = d + cost + 0.01; // slight step bias keeps rail paths short + if (nd < (dist.get(nIdx) ?? Infinity)) { + dist.set(nIdx, nd); + prev.set(nIdx, idx); + frontier.push({ idx: nIdx, d: nd }); + } + } + } + } + if (!prev.has(goal)) return null; + const path = []; + let cur = goal; + while (cur !== start) { + path.unshift([cur % world.cols, (cur / world.cols) | 0]); + cur = prev.get(cur); + } + return path; +} + +// --------------------------------------------------------------------------- +// Visibility / exploration / contact + +export function exploreAround(state, civIdx, x, y, radius) { + const grid = state.explored[civIdx]; + const { world } = state; + for (let dy = -radius; dy <= radius; dy += 1) { + for (let dx = -radius; dx <= radius; dx += 1) { + const nx = x + dx; + const ny = y + dy; + if (inBounds(world, nx, ny)) grid[tileIndex(world, nx, ny)] = 1; + } + } +} + +export function computeVisible(state, civIdx) { + const { world } = state; + const vis = new Set(); + const mark = (x, y, r) => { + for (let dy = -r; dy <= r; dy += 1) { + for (let dx = -r; dx <= r; dx += 1) { + const nx = x + dx; + const ny = y + dy; + if (inBounds(world, nx, ny)) vis.add(tileIndex(world, nx, ny)); + } + } + }; + for (const u of civUnits(state, civIdx)) mark(u.x, u.y, 2); + for (const c of civCities(state, civIdx)) mark(c.x, c.y, 2); + return vis; +} + +export function isUnitVisibleTo(rules, state, unit, civIdx) { + if (unit.civ === civIdx) return true; + const def = rules.units[unit.type]; + if (def.flags.includes('submarine')) { + // Subs only show when something of ours is adjacent. + return civUnits(state, civIdx).some((u) => cheb(u.x, u.y, unit.x, unit.y) <= 1) + || civCities(state, civIdx).some((c) => cheb(c.x, c.y, unit.x, unit.y) <= 1); + } + return true; +} + +// Full proximity sweep: any of my units/cities within 3 of theirs = contact. +export function contactSweep(rules, state, civIdx) { + const civ = state.civs[civIdx]; + const minePoints = [ + ...civUnits(state, civIdx).map((u) => [u.x, u.y]), + ...civCities(state, civIdx).map((c) => [c.x, c.y]), + ]; + for (const other of state.civs) { + if (other.id === civIdx || !other.alive) continue; + if (civ.relations[other.id] !== 'nocontact') continue; + const theirs = [ + ...civUnits(state, other.id).map((u) => [u.x, u.y]), + ...civCities(state, other.id).map((c) => [c.x, c.y]), + ]; + let met = false; + for (const [mx, my] of minePoints) { + for (const [tx, ty] of theirs) { + if (cheb(mx, my, tx, ty) <= 3) { met = true; break; } + } + if (met) break; + } + if (met) { + civ.relations[other.id] = 'contact'; + other.relations[civIdx] = 'contact'; + state.events.push({ type: 'contact', a: civIdx, b: other.id }); + } + } +} + +export function makeContacts(rules, state, civIdx, x, y) { + for (const other of state.civs) { + if (other.id === civIdx || !other.alive) continue; + if (state.civs[civIdx].relations[other.id] !== 'nocontact') continue; + const near = civUnits(state, other.id).some((u) => cheb(u.x, u.y, x, y) <= 1) + || civCities(state, other.id).some((c) => cheb(c.x, c.y, x, y) <= 1); + if (near) { + state.civs[civIdx].relations[other.id] = 'contact'; + other.relations[civIdx] = 'contact'; + state.events.push({ type: 'contact', a: civIdx, b: other.id }); + } + } +} + +// --------------------------------------------------------------------------- +// Trade routes (caravans) + +export const TRADE_ROUTE_MIN_DIST = 8; +export const MAX_ROUTES = 3; + +export function canEstablishRoute(rules, state, unit) { + const def = rules.units[unit.type]; + if (!def.flags.includes('caravan')) return null; + const here = cityAt(state, unit.x, unit.y); + const home = cityById(state, unit.homeCity); + if (!here || !home || here.id === home.id) return null; + if (here.civ !== unit.civ && state.civs[unit.civ].relations[here.civ] === 'war') return null; + if (cheb(here.x, here.y, home.x, home.y) < TRADE_ROUTE_MIN_DIST) return null; + return { here, home }; +} + +export function establishTradeRoute(rules, state, unit) { + const pair = canEstablishRoute(rules, state, unit); + if (!pair) return null; + const { here, home } = pair; + const civ = state.civs[unit.civ]; + const dist = cheb(here.x, here.y, home.x, home.y); + const yHome = cityYields(rules, state, home); + const yHere = cityYields(rules, state, here); + const foreign = here.civ !== unit.civ; + const bonus = Math.floor((dist + yHome.netTrade + yHere.netTrade) / 2) * (foreign ? 2 : 1); + civ.gold += bonus; + civ.beakers += bonus; + const amount = Math.max(1, Math.floor(dist / 4)) + (foreign ? 1 : 0); + addRoute(home, { cityId: here.id, amount }); + addRoute(here, { cityId: home.id, amount }); + removeUnit(state, unit); + state.events.push({ + type: 'tradeRoute', civ: unit.civ, from: home.id, to: here.id, bonus, amount, + }); + return { bonus, amount, from: home, to: here }; +} + +function addRoute(city, route) { + const existing = city.routes.find((r) => r.cityId === route.cityId); + if (existing) { existing.amount = Math.max(existing.amount, route.amount); return; } + city.routes.push(route); + if (city.routes.length > MAX_ROUTES) { + city.routes.sort((a, b) => b.amount - a.amount); + city.routes.length = MAX_ROUTES; + } +} + +// --------------------------------------------------------------------------- +// Diplomacy +// +// Pairwise relation states and the legal proposal steps between them. War can +// be declared from any contacted state; breaking peace/alliance that way is a +// "sneak attack" and permanently scars the aggressor's reputation. + +export const DIPLO_PROPOSALS = { + war: ['ceasefire'], + ceasefire: ['peace'], + contact: ['peace'], + peace: ['alliance'], + alliance: [], + nocontact: [], +}; + +export function canPropose(state, a, b, kind) { + const rel = state.civs[a].relations[b]; + return (DIPLO_PROPOSALS[rel] ?? []).includes(kind); +} + +export function applyTreaty(state, a, b, kind) { + if (!canPropose(state, a, b, kind)) return false; + state.civs[a].relations[b] = kind; + state.civs[b].relations[a] = kind; + bumpAttitude(state, a, b, 15); + bumpAttitude(state, b, a, 15); + state.events.push({ type: 'treaty', a, b, kind }); + return true; +} + +export function declareWar(rules, state, a, b) { + const civA = state.civs[a]; + const rel = civA.relations[b]; + if (rel === 'nocontact' || rel === 'war') return false; + const sneak = rel === 'peace' || rel === 'alliance'; + civA.relations[b] = 'war'; + state.civs[b].relations[a] = 'war'; + bumpAttitude(state, b, a, -50); + if (sneak) { + const gov = rules.governments[civA.government]; + civA.reputation -= 25 * (gov.warPenalty ?? 1); + for (const other of state.civs) { + if (other.id !== a && other.alive) bumpAttitude(state, other.id, a, -20); + } + } + state.events.push({ type: 'war', a, b, sneak }); + return true; +} + +export function cancelTreaty(state, a, b) { + const rel = state.civs[a].relations[b]; + if (rel !== 'peace' && rel !== 'alliance' && rel !== 'ceasefire') return false; + state.civs[a].relations[b] = 'contact'; + state.civs[b].relations[a] = 'contact'; + bumpAttitude(state, b, a, rel === 'alliance' ? -20 : -10); + state.events.push({ type: 'treatyCancelled', a, b, was: rel }); + return true; +} + +export function giftGold(state, a, b, amount) { + const civA = state.civs[a]; + if (amount <= 0 || civA.gold < amount) return false; + civA.gold -= amount; + state.civs[b].gold += amount; + bumpAttitude(state, b, a, Math.min(20, Math.ceil(amount / 25))); + state.events.push({ type: 'gift', a, b, gold: amount }); + return true; +} + +export function giftTech(rules, state, a, b, techId) { + const civA = state.civs[a]; + const civB = state.civs[b]; + if (!civA.known[techId] || civB.known[techId]) return false; + grantTech(rules, state, civB, techId); + bumpAttitude(state, b, a, 15); + state.events.push({ type: 'gift', a, b, tech: techId }); + return true; +} + +export function exchangeTechs(rules, state, a, b, giveId, getId) { + const civA = state.civs[a]; + const civB = state.civs[b]; + if (!civA.known[giveId] || civB.known[giveId]) return false; + if (!civB.known[getId] || civA.known[getId]) return false; + grantTech(rules, state, civB, giveId); + grantTech(rules, state, civA, getId); + bumpAttitude(state, a, b, 5); + bumpAttitude(state, b, a, 5); + state.events.push({ type: 'techExchange', a, b, giveId, getId }); + return true; +} + +export function payTribute(state, a, b, amount) { + const civA = state.civs[a]; + const paid = Math.min(civA.gold, amount); + if (paid <= 0) return 0; + civA.gold -= paid; + state.civs[b].gold += paid; + bumpAttitude(state, a, b, -15); // being shaken down breeds resentment + state.events.push({ type: 'tribute', a, b, gold: paid }); + return paid; +} + +export function bumpAttitude(state, ofCiv, towardCiv, delta) { + const civ = state.civs[ofCiv]; + civ.attitude[towardCiv] = clampAttitude((civ.attitude[towardCiv] ?? 0) + delta); +} +function clampAttitude(v) { return Math.max(-100, Math.min(100, v)); } + +export function civPower(rules, state, civIdx) { + let power = 0; + for (const u of civUnits(state, civIdx)) { + const def = rules.units[u.type]; + power += def.attack + def.defense; + } + for (const c of civCities(state, civIdx)) power += c.size * 2; + return power; +} + +// Per-turn attitude drift toward a situational baseline. Reputation scars from +// sneak attacks hold the ceiling down permanently. +export function updateAttitudes(rules, state, civIdx) { + const civ = state.civs[civIdx]; + const myPower = civPower(rules, state, civIdx); + for (const other of state.civs) { + if (other.id === civIdx || !other.alive) continue; + if (civ.relations[other.id] === 'nocontact') continue; + let baseline = 0; + const theirPower = civPower(rules, state, other.id); + if (theirPower > myPower * 2) baseline -= 20; // fear the runaway + // Shared enemies build friendship. + for (const third of state.civs) { + if (third.id === civIdx || third.id === other.id || !third.alive) continue; + if (civ.relations[third.id] === 'war' && other.relations[third.id] === 'war') baseline += 20; + } + // Border friction: their combat units close to my cities. + let friction = 0; + for (const u of civUnits(state, other.id)) { + if (rules.units[u.type].flags.includes('noncombat')) continue; + if (civCities(state, civIdx).some((c) => cheb(c.x, c.y, u.x, u.y) <= 3)) friction += 1; + } + baseline -= Math.min(30, friction * 5); + if (civ.relations[other.id] === 'war') baseline -= 40; + if (civ.relations[other.id] === 'alliance') baseline += 30; + baseline += Math.max(-50, other.reputation / 2); + baseline = clampAttitude(baseline); + const cur = civ.attitude[other.id] ?? 0; + civ.attitude[other.id] = clampAttitude(cur + Math.sign(baseline - cur) * Math.min(3, Math.abs(baseline - cur))); + } +} + +export function attitudeMood(value) { + if (value <= -25) return 'upset'; + if (value >= 25) return 'happy'; + return 'idle'; +} + +// --------------------------------------------------------------------------- +// Turn structure + +export function beginCivTurn(rules, state, civIdx) { + const civ = state.civs[civIdx]; + if (!civ.alive) return; + // Trim the event log instead of clearing it: the scene reads events from + // AI turns at the start of the human turn (toasts, terrain repaints), and + // headless soaks must not grow it unboundedly. + if (state.events.length > 400) { + state.events = [ + ...state.events.filter((e) => e.keep), + ...state.events.slice(-200).filter((e) => !e.keep), + ]; + } + progressRevolution(rules, state, civ); + updateAttitudes(rules, state, civIdx); + + // Cities produce/grow/research. + for (const city of civCities(state, civIdx)) processCity(rules, state, city); + progressResearch(rules, state, civ); + if (!civ.researching && !civ.human) { + // AIs always keep researching something (module CivilizationAI refines this). + const options = availableTechs(rules, civ); + if (options.length) civ.researching = options[randInt(state, options.length)].id; + } + + // Units: reset movement, heal stationary ones, progress work orders. + for (const unit of civUnits(state, civIdx)) { + const def = rules.units[unit.type]; + if (!unit.moved && !unit.order) { + const city = cityAt(state, unit.x, unit.y); + const healFrac = city ? (city.buildings.barracks ? 1 : HEAL_CITY) : HEAL_FIELD; + unit.hp = Math.min(def.hp, unit.hp + Math.ceil(def.hp * healFrac)); + } + unit.mp = def.move * 3; + unit.moved = false; + progressWork(rules, state, unit); + } +} + +export function endCivTurn(rules, state, civIdx) { + const civ = state.civs[civIdx]; + if (civ.alive) { + contactSweep(rules, state, civIdx); + // Air units must end the turn on a city or carrier. + for (const unit of civUnits(state, civIdx)) { + const def = rules.units[unit.type]; + if (def.domain !== 'air') continue; + const city = cityAt(state, unit.x, unit.y); + const carrier = unitsAt(state, unit.x, unit.y) + .find((u) => u.civ === civIdx && (rules.units[u.type].cargoAir ?? 0) > 0); + if (!city && !carrier) { + removeUnit(state, unit); + state.events.push({ type: 'airCrash', civ: civIdx, unitType: unit.type }); + } + } + } + // Advance to the next living civ; wrap advances the game turn. + let next = civIdx; + for (let i = 0; i < state.civs.length; i += 1) { + next = (next + 1) % state.civs.length; + if (next === 0) { + state.turn += 1; + checkSpaceshipArrivals(rules, state); + } + if (state.civs[next].alive) break; + } + state.current = next; + return next; +} + +export function launchSpaceship(rules, state, civ) { + const ship = civ.spaceship; + if (ship.launched) return false; + if (ship.structural < rules.spaceship.structuralNeeded + || ship.component < rules.spaceship.componentsNeeded + || ship.module < rules.spaceship.modulesNeeded) return false; + ship.launched = true; + ship.arrivalTurn = state.turn + rules.spaceship.travelTurns; + state.events.push({ type: 'spaceshipLaunched', civ: civ.id, arrivalTurn: ship.arrivalTurn }); + return true; +} + +function checkSpaceshipArrivals(rules, state) { + if (state.over) return; + for (const civ of state.civs) { + if (civ.alive && civ.spaceship.launched && state.turn >= civ.spaceship.arrivalTurn) { + state.over = { type: 'spaceship', winner: civ.id }; + state.events.push({ type: 'victory', mode: 'spaceship', civ: civ.id, keep: true }); + return; + } + } +} + +export function checkVictory(rules, state) { + if (state.over) return state.over; + for (const civ of state.civs) { + if (civ.alive && civCities(state, civ.id).length === 0 + && !civUnits(state, civ.id).some((u) => rules.units[u.type].flags.includes('settler')) + && state.turn > 0) { + eliminateCiv(rules, state, civ.id); + } + } + const living = state.civs.filter((c) => c.alive); + if (living.length === 1) { + state.over = { type: 'conquest', winner: living[0].id }; + state.events.push({ type: 'victory', mode: 'conquest', civ: living[0].id, keep: true }); + } else if (living.length === 0) { + state.over = { type: 'conquest', winner: -1 }; + } + return state.over; +} + +export function civScore(rules, state, civIdx) { + const civ = state.civs[civIdx]; + let pop = 0; + for (const c of civCities(state, civIdx)) pop += c.size; + return pop * 2 + knownCount(civ) * 2 + civ.futureCount * 5 + + civCities(state, civIdx).length * 3; +} + +// --------------------------------------------------------------------------- +// Serialization + +export function serialize(state) { + const { rules, ...rest } = state; + return JSON.stringify(rest); +} + +export function deserialize(json) { + const state = JSON.parse(json); + if (state.version !== 1) return null; + return state; +} + +export function hashState(state) { + const str = serialize(state); + let h = 2166136261; + for (let i = 0; i < str.length; i += 1) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return (h >>> 0).toString(16); +} diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js new file mode 100644 index 0000000..26616e3 --- /dev/null +++ b/src/games/civilization/CivilizationMapView.js @@ -0,0 +1,667 @@ +// Civilization — isometric map renderer. +// +// The engine works on a square grid; this view draws it as Civ II-style +// diamonds. Terrain (plus improvements/specials/huts) is baked into one +// RenderTexture and repainted per-tile when the world changes; fog is a second +// RenderTexture of black diamonds erased as the human explores. Units and +// cities are lightweight display objects rebuilt on refresh() — the game is +// turn-based, so refreshes happen on actions, not per frame. +// +// Art: uses the optional civilization-* sheets when loaded (see sprites.md), +// otherwise draws flat-shaded diamonds, glyphs and roundels procedurally. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { + IMP, tileIndex, inBounds, cityAt, unitsAt, civUnits, civCities, + computeVisible, isUnitVisibleTo, shieldGrassAt, +} from './CivilizationLogic.js'; + +export const TILE_W = 128; +export const TILE_H = 64; +export const FRAME_H = 96; // sprite frames carry 32px of headroom above the diamond + +const ZOOMS = [0.5, 0.75, 1.0]; + +export class CivilizationMapView { + constructor(scene, rules, state, callbacks = {}) { + this.scene = scene; + this.rules = rules; + this.state = state; + this.cb = callbacks; + this.humanIdx = state.humanIndex; + this.zoomIdx = 1; + this.selectedUnitId = null; + this.exploredDrawn = null; + + const { world } = state; + this.originX = world.rows * (TILE_W / 2); // keeps iso x positive + this.worldW = (world.cols + world.rows) * (TILE_W / 2); + this.worldH = (world.cols + world.rows) * (TILE_H / 2) + FRAME_H; + + this.root = scene.add.container(0, 0).setDepth(1); + // Chunked RenderTextures: a Large map is ~8200px wide — beyond one safe + // GPU texture — so terrain and fog are tiled into <=2048px chunks. + const CHUNK = 2048; + this.chunkSize = CHUNK; + this.terrainChunks = []; + this.fogChunks = []; + for (let oy = 0; oy < this.worldH; oy += CHUNK) { + for (let ox = 0; ox < this.worldW; ox += CHUNK) { + const w = Math.min(CHUNK, this.worldW - ox); + const h = Math.min(CHUNK, this.worldH - oy); + const trt = scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0); + const frt = scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0); + this.terrainChunks.push({ rt: trt, ox, oy, w, h }); + this.fogChunks.push({ rt: frt, ox, oy, w, h }); + } + } + this.pathGfx = scene.add.graphics(); + this.dynamic = scene.add.container(0, 0); + this.root.add([ + ...this.terrainChunks.map((c) => c.rt), + this.pathGfx, this.dynamic, + ...this.fogChunks.map((c) => c.rt), + ]); + + this.stamp = scene.add.graphics().setVisible(false); + this.miniGfx = null; + + this.bakeTerrain(); + this.initFog(); + this.setZoom(1); + } + + // Run fn(rt, ox, oy) on every chunk overlapping the given world-space box. + forEachChunk(chunks, x0, y0, x1, y1, fn) { + for (const c of chunks) { + if (x1 < c.ox || x0 > c.ox + c.w || y1 < c.oy || y0 > c.oy + c.h) continue; + fn(c.rt, c.ox, c.oy); + } + } + + // Tile bounding box in world space (frame headroom included). + tileBounds(x, y) { + return [x - TILE_W / 2 - 4, y - (FRAME_H - TILE_H) - 4, x + TILE_W / 2 + 4, y + FRAME_H + 4]; + } + + drawStampAt(gfx, x, y) { + const [x0, y0, x1, y1] = this.tileBounds(x, y); + this.forEachChunk(this.terrainChunks, x0, y0, x1, y1, + (rt, ox, oy) => rt.draw(gfx, x - ox, y - oy)); + } + + drawFrameAt(key, frame, x, y) { + const [x0, y0, x1, y1] = this.tileBounds(x + 64, y); + this.forEachChunk(this.terrainChunks, x0, y0, x1, y1, + (rt, ox, oy) => rt.drawFrame(key, frame, x - ox, y - oy)); + } + + destroy() { + this.root.destroy(true); + this.stamp.destroy(); + this.fogStamp?.destroy(); + this.miniRoot?.destroy(true); + } + + // ------------------------------------------------------------------------- + // Coordinates + + isoX(c, r) { return this.originX + (c - r) * (TILE_W / 2); } + isoY(c, r) { return (c + r) * (TILE_H / 2); } + + // Screen -> tile (or null). Checks the two diamond candidates around the + // inverse transform. + screenToTile(px, py) { + const wx = (px - this.root.x) / this.root.scaleX; + const wy = (py - this.root.y) / this.root.scaleY; + const a = (wx - this.originX) / (TILE_W / 2); + const b = (wy - TILE_H / 2) / (TILE_H / 2); // diamond centre offset + const cf = (a + b) / 2; + const rf = (b - a) / 2; + let best = null; + let bestDist = Infinity; + for (const [c, r] of [ + [Math.floor(cf), Math.floor(rf)], [Math.ceil(cf), Math.floor(rf)], + [Math.floor(cf), Math.ceil(rf)], [Math.ceil(cf), Math.ceil(rf)], + [Math.round(cf), Math.round(rf)], + ]) { + if (!inBounds(this.state.world, c, r)) continue; + const cx = this.isoX(c, r); + const cy = this.isoY(c, r) + TILE_H / 2; + // Diamond containment via L1 distance in tile units. + const dx = Math.abs(wx - cx) / (TILE_W / 2); + const dy = Math.abs(wy - cy) / (TILE_H / 2); + if (dx + dy <= 1.02) { + const d = dx + dy; + if (d < bestDist) { bestDist = d; best = [c, r]; } + } + } + return best; + } + + setZoom(idx) { + this.zoomIdx = Phaser.Math.Clamp(idx, 0, ZOOMS.length - 1); + this.root.setScale(ZOOMS[this.zoomIdx]); + this.clampPan(); + } + zoomBy(delta) { this.setZoom(this.zoomIdx + delta); } + + panBy(dx, dy) { + this.root.x += dx; + this.root.y += dy; + this.clampPan(); + } + + clampPan() { + const s = this.root.scaleX; + const minX = GAME_WIDTH - this.worldW * s - 100; + const minY = GAME_HEIGHT - this.worldH * s - 100; + this.root.x = Phaser.Math.Clamp(this.root.x, Math.min(100, minX), 100); + this.root.y = Phaser.Math.Clamp(this.root.y, Math.min(100, minY), 100); + } + + centerOn(c, r) { + const s = this.root.scaleX; + this.root.x = GAME_WIDTH / 2 - this.isoX(c, r) * s; + this.root.y = GAME_HEIGHT / 2 - (this.isoY(c, r) + TILE_H / 2) * s; + this.clampPan(); + } + + // ------------------------------------------------------------------------- + // Terrain baking + + bakeTerrain() { + for (const c of this.terrainChunks) c.rt.clear(); + const { world } = this.state; + // Paint back-to-front (row-major works: greater c+r paints later). + for (let sum = 0; sum <= world.cols + world.rows - 2; sum += 1) { + for (let c = Math.max(0, sum - world.rows + 1); c <= Math.min(sum, world.cols - 1); c += 1) { + const r = sum - c; + this.paintTile(c, r); + } + } + } + + repaintTileAndNeighbors(c, r) { + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (inBounds(this.state.world, c + dx, r + dy)) this.paintTile(c + dx, r + dy); + } + } + } + + paintTile(c, r) { + const { world } = this.state; + const idx = tileIndex(world, c, r); + const terr = this.rules.terrainList[world.terrain[idx]]; + const x = this.isoX(c, r); + const y = this.isoY(c, r); + const useSheet = this.scene.textures.exists('civilization-terrain'); + if (useSheet) { + let frame = terr.frame; + if (terr.id === 'grassland' && shieldGrassAt(c, r) && world.special[idx] < 0) { + frame = this.rules.grasslandShieldFrame; + } + this.drawFrameAt('civilization-terrain', frame, x - TILE_W / 2, y + TILE_H - FRAME_H); + } else { + this.paintProceduralTile(c, r, terr, x, y); + } + this.paintImprovements(c, r, x, y); + this.paintSpecial(c, r, x, y); + } + + paintProceduralTile(c, r, terr, x, y) { + const g = this.stamp; + g.clear(); + const base = Phaser.Display.Color.HexStringToColor(terr.color).color; + const cy = TILE_H / 2; // stamp-local diamond centre + g.fillStyle(base, 1); + g.beginPath(); + g.moveTo(0, cy - TILE_H / 2); + g.lineTo(TILE_W / 2, cy); + g.lineTo(0, cy + TILE_H / 2); + g.lineTo(-TILE_W / 2, cy); + g.closePath(); + g.fillPath(); + g.lineStyle(1, 0x000000, 0.18); + g.strokePath(); + + // Simple per-terrain glyphs. + const darker = Phaser.Display.Color.ValueToColor(base).darken(25).color; + const lighter = Phaser.Display.Color.ValueToColor(base).lighten(20).color; + if (terr.id === 'forest' || terr.id === 'jungle') { + g.fillStyle(darker, 1); + for (const [tx, ty] of [[-24, 0], [0, -8], [22, 2]]) { + g.fillTriangle(tx - 9, cy + ty + 8, tx + 9, cy + ty + 8, tx, cy + ty - 12); + } + } else if (terr.id === 'mountains') { + g.fillStyle(darker, 1); + g.fillTriangle(-28, cy + 12, 4, cy + 12, -12, cy - 22); + g.fillTriangle(-4, cy + 14, 30, cy + 14, 13, cy - 16); + g.fillStyle(0xffffff, 0.9); + g.fillTriangle(-16, cy - 14, -8, cy - 14, -12, cy - 22); + } else if (terr.id === 'hills') { + g.fillStyle(darker, 1); + g.fillEllipse(-16, cy + 4, 34, 16); + g.fillEllipse(14, cy + 8, 38, 18); + } else if (terr.id === 'ocean') { + g.lineStyle(2, lighter, 0.7); + for (const [tx, ty] of [[-24, -6], [8, 2], [-8, 10]]) { + g.beginPath(); + g.moveTo(tx, cy + ty); + g.lineTo(tx + 14, cy + ty); + g.strokePath(); + } + } else if (terr.id === 'swamp') { + g.lineStyle(2, darker, 0.8); + for (const [tx, ty] of [[-20, 4], [4, -4], [18, 8]]) { + g.beginPath(); + g.moveTo(tx, cy + ty); + g.lineTo(tx, cy + ty - 8); + g.strokePath(); + } + } else if (terr.id === 'desert') { + g.fillStyle(darker, 0.6); + g.fillEllipse(-14, cy + 4, 10, 4); + g.fillEllipse(12, cy - 4, 12, 4); + } else if (terr.id === 'grassland') { + const idx = tileIndex(this.state.world, c, r); + if (shieldGrassAt(c, r) && this.state.world.special[idx] < 0) { + g.fillStyle(lighter, 1); + g.fillCircle(18, cy - 6, 5); + } + } + this.drawStampAt(g, x, y); + } + + paintImprovements(c, r, x, y) { + const { world } = this.state; + const idx = tileIndex(world, c, r); + const bits = world.improvements[idx]; + const g = this.stamp; + const cy = TILE_H / 2; + const hasCity = !!cityAt(this.state, c, r); + + // Roads/rails connect toward neighbours that also have them (or cities). + if (bits & (IMP.ROAD | IMP.RAILROAD)) { + g.clear(); + const rail = !!(bits & IMP.RAILROAD); + let drewAny = false; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (dx === 0 && dy === 0) continue; + const nc = c + dx; + const nr = r + dy; + if (!inBounds(world, nc, nr)) continue; + const nBits = world.improvements[tileIndex(world, nc, nr)]; + if (!(nBits & (IMP.ROAD | IMP.RAILROAD))) continue; + const ex = (this.isoX(nc, nr) - this.isoX(c, r)) / 2; + const ey = (this.isoY(nc, nr) - this.isoY(c, r)) / 2; + g.lineStyle(rail ? 4 : 3, rail ? 0x4a4038 : 0x8a6f4d, 0.9); + g.beginPath(); + g.moveTo(0, cy); + g.lineTo(ex, cy + ey); + g.strokePath(); + drewAny = true; + } + } + if (!drewAny && !hasCity) { + g.lineStyle(3, 0x8a6f4d, 0.9); + g.beginPath(); + g.moveTo(-14, cy); + g.lineTo(14, cy); + g.strokePath(); + } + this.drawStampAt(g, x, y); + } + + const useSheet = this.scene.textures.exists('civilization-improvements'); + const drawBadge = (frame, fallback) => { + if (useSheet) { + this.drawFrameAt('civilization-improvements', frame, x - 32, y); + } else { + fallback(); + } + }; + if ((bits & IMP.IRRIGATION) && !hasCity) { + drawBadge(bits & IMP.FARMLAND ? 1 : 0, () => { + g.clear(); + g.lineStyle(2, 0x2f8fbf, 0.8); + for (let i = -1; i <= 1; i += 1) { + g.beginPath(); + g.moveTo(-18, cy + i * 7); + g.lineTo(18, cy + i * 7); + g.strokePath(); + } + this.drawStampAt(g, x, y); + }); + } + if (bits & IMP.MINE) { + drawBadge(2, () => { + g.clear(); + g.fillStyle(0x3a3a3a, 1); + g.fillTriangle(-8, cy + 6, 8, cy + 6, 0, cy - 8); + g.fillStyle(0x111111, 1); + g.fillRect(-2, cy - 2, 4, 8); + this.drawStampAt(g, x, y); + }); + } + if (bits & IMP.FORTRESS) { + drawBadge(3, () => { + g.clear(); + g.lineStyle(3, 0x9a8866, 1); + g.strokeRect(-20, cy - 12, 40, 24); + this.drawStampAt(g, x, y); + }); + } + if (world.huts[idx]) { + drawBadge(4, () => { + g.clear(); + g.fillStyle(0x8a5a2a, 1); + g.fillRect(-8, cy - 4, 16, 10); + g.fillStyle(0xb08040, 1); + g.fillTriangle(-11, cy - 4, 11, cy - 4, 0, cy - 14); + this.drawStampAt(g, x, y); + }); + } + } + + paintSpecial(c, r, x, y) { + const { world } = this.state; + const idx = tileIndex(world, c, r); + if (world.special[idx] < 0) return; + const spec = this.rules.specialList[world.special[idx]]; + if (this.scene.textures.exists('civilization-resources')) { + this.drawFrameAt('civilization-resources', spec.frame, x - 32, y); + return; + } + const g = this.stamp; + const cy = TILE_H / 2; + g.clear(); + g.fillStyle(0xffffff, 0.85); + g.fillCircle(0, cy, 9); + g.fillStyle(specialColor(spec.id), 1); + g.fillCircle(0, cy, 7); + this.drawStampAt(g, x, y); + } + + // ------------------------------------------------------------------------- + // Fog + + initFog() { + const { world } = this.state; + for (const c of this.fogChunks) { + c.rt.clear(); + c.rt.fill(0x05060a, 1); + } + // Erase diamonds that are already explored; remember what we've erased. + // The stamp is oversized by a pixel so adjacent erases leave no seams. + this.exploredDrawn = new Array(world.cols * world.rows).fill(0); + const g = this.scene.add.graphics().setVisible(false); + const cy = TILE_H / 2; + g.fillStyle(0xffffff, 1); + g.beginPath(); + g.moveTo(TILE_W / 2, cy - TILE_H / 2 - 2); + g.lineTo(TILE_W + 3, cy); + g.lineTo(TILE_W / 2, cy + TILE_H / 2 + 2); + g.lineTo(-3, cy); + g.closePath(); + g.fillPath(); + this.fogStamp = g; + this.updateFog(); + } + + updateFog() { + if (this.humanIdx < 0) { + for (const c of this.fogChunks) c.rt.setVisible(false); + return; + } + const { world } = this.state; + const explored = this.state.explored[this.humanIdx]; + for (let r = 0; r < world.rows; r += 1) { + for (let c = 0; c < world.cols; c += 1) { + const idx = tileIndex(world, c, r); + if (explored[idx] && !this.exploredDrawn[idx]) { + this.exploredDrawn[idx] = 1; + const x = this.isoX(c, r) - TILE_W / 2; + const y = this.isoY(c, r); + this.forEachChunk(this.fogChunks, x - 4, y - 4, x + TILE_W + 4, y + TILE_H + 4, + (rt, ox, oy) => rt.erase(this.fogStamp, x - ox, y - oy)); + } + } + } + } + + // ------------------------------------------------------------------------- + // Dynamic layer (units + cities) + + refresh() { + this.updateFog(); + this.dynamic.removeAll(true); + const { state, rules } = this; + const povIdx = this.humanIdx >= 0 ? this.humanIdx : state.current; + const visible = computeVisible(state, povIdx); + const explored = this.humanIdx >= 0 ? state.explored[povIdx] : null; + + for (const city of state.cities) { + const idx = tileIndex(state.world, city.x, city.y); + if (explored && !explored[idx]) continue; + this.drawCity(city); + } + + // One marker per occupied tile (top defender), with a stack badge. + const byTile = new Map(); + for (const u of state.units) { + if (u.carriedBy) continue; + const idx = tileIndex(state.world, u.x, u.y); + if (u.civ !== povIdx) { + if (!visible.has(idx)) continue; + if (!isUnitVisibleTo(rules, state, u, povIdx)) continue; + } + (byTile.get(idx) ?? byTile.set(idx, []).get(idx)).push(u); + } + for (const [idx, units] of byTile) { + const c = idx % state.world.cols; + const r = (idx / state.world.cols) | 0; + const selected = units.find((u) => u.id === this.selectedUnitId); + const top = selected ?? units[0]; + this.drawUnit(top, units.length, c, r); + } + this.drawSelection(); + this.refreshMinimap(); + } + + drawCity(city) { + const { scene } = this; + const x = this.isoX(city.x, city.y); + const y = this.isoY(city.x, city.y); + const civ = this.state.civs[city.civ]; + const color = Phaser.Display.Color.HexStringToColor(civ.color).color; + const container = scene.add.container(x, y).setDepth(y); + const walled = !!city.buildings.citywalls; + + const themeKey = 'civilization-cities-classic'; + if (scene.textures.exists(themeKey)) { + const tier = city.size >= 13 ? 3 : city.size >= 8 ? 2 : city.size >= 4 ? 1 : 0; + const img = scene.add.image(0, TILE_H - FRAME_H + 48, themeKey, walled ? 4 + tier : tier); + container.add(img); + } else { + const g = scene.add.graphics(); + const cy = TILE_H / 2; + const tier = city.size >= 13 ? 3 : city.size >= 8 ? 2 : city.size >= 4 ? 1 : 0; + g.fillStyle(0x6b6255, 1); + for (let i = 0; i <= tier; i += 1) { + const bw = 26 - i * 3; + const bh = 16 + i * 8; + const bx = -24 + i * 16; + g.fillRect(bx, cy - bh + 4, bw, bh); + g.fillStyle(0x7d7466, 1); + } + g.fillStyle(0x332f28, 1); + for (let i = 0; i <= tier; i += 1) g.fillRect(-18 + i * 16, cy - 6, 5, 6); + if (walled) { + g.lineStyle(3, 0x9a8866, 1); + g.strokeRect(-34, cy - 14, 68, 22); + } + container.add(g); + } + + const banner = scene.add.rectangle(0, TILE_H + 10, 0, 22, 0x000000, 0.65) + .setStrokeStyle(1, color, 1); + const label = scene.add.text(0, TILE_H + 10, `${city.size} ${city.name}`, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: civ.color, + }).setOrigin(0.5); + banner.width = label.width + 16; + container.add([banner, label]); + container.setDepth(y + 1); + banner.setInteractive({ useHandCursor: true }); + banner.on('pointerdown', (pointer, lx, ly, event) => { + event.stopPropagation(); + this.cb.onCityClick?.(city); + }); + this.dynamic.add(container); + } + + drawUnit(unit, stackCount, c, r) { + const { scene, rules } = this; + const def = rules.units[unit.type]; + const civ = this.state.civs[unit.civ]; + const color = Phaser.Display.Color.HexStringToColor(civ.color).color; + const x = this.isoX(c, r); + const y = this.isoY(c, r) + TILE_H / 2; + const container = scene.add.container(x, y); + + if (scene.textures.exists('civilization-units')) { + const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1); + const img = scene.add.image(0, -6, 'civilization-units', def.frame); + container.add([ring, img]); + } else { + const g = scene.add.graphics(); + g.fillStyle(0x000000, 0.35); + g.fillEllipse(0, 12, 40, 14); + g.fillStyle(color, 1); + g.fillCircle(0, -2, 17); + g.lineStyle(2, 0xffffff, unit.fortified ? 1 : 0.5); + g.strokeCircle(0, -2, 17); + container.add(g); + const label = scene.add.text(0, -2, def.abbr, { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#ffffff', fontStyle: 'bold', + }).setOrigin(0.5); + container.add(label); + } + if (unit.vet) { + container.add(scene.add.circle(12, -14, 4, 0xd4a017).setStrokeStyle(1, 0x000000, 0.6)); + } + if (stackCount > 1) { + const badge = scene.add.circle(-16, -14, 8, 0x000000, 0.8); + const num = scene.add.text(-16, -14, `${stackCount}`, { + fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#ffffff', + }).setOrigin(0.5); + container.add([badge, num]); + } + container.setDepth(y + 2); + this.dynamic.add(container); + if (unit.id === this.selectedUnitId) this.selectedContainer = container; + } + + drawSelection() { + if (!this.selectedUnitId) return; + const unit = this.state.units.find((u) => u.id === this.selectedUnitId); + if (!unit) { this.selectedUnitId = null; return; } + const x = this.isoX(unit.x, unit.y); + const y = this.isoY(unit.x, unit.y) + TILE_H / 2; + const ring = this.scene.add.circle(x, y - 2, 24).setStrokeStyle(3, 0xffffff, 1); + ring.setDepth(y + 3); + this.dynamic.add(ring); + this.scene.tweens.add({ + targets: ring, alpha: 0.25, duration: 420, yoyo: true, repeat: -1, + }); + } + + showPath(path) { + this.pathGfx.clear(); + if (!path || !path.length) return; + this.pathGfx.lineStyle(3, 0xffffff, 0.6); + for (const [c, r] of path) { + const x = this.isoX(c, r); + const y = this.isoY(c, r) + TILE_H / 2; + this.pathGfx.strokeCircle(x, y, 7); + } + } + + // ------------------------------------------------------------------------- + // Minimap + + buildMinimap(x, y, width) { + const { world } = this.state; + const scale = width / (world.cols + world.rows); + this.miniScale = scale; + this.miniRoot = this.scene.add.container(x, y).setDepth(40); + const h = (world.cols + world.rows) * (scale / 2) + 8; + const bg = this.scene.add.rectangle(0, 0, width + 12, h + 12, COLORS.panel, 0.92) + .setOrigin(0, 0).setStrokeStyle(2, COLORS.accent); + this.miniGfx = this.scene.add.graphics(); + this.miniGfx.setPosition(6, 6); + this.miniRoot.add([bg, this.miniGfx]); + this.miniW = width; + this.miniH = h; + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerdown', (pointer, lx, ly) => { + // Invert the mini iso transform. + const mx = lx - 6; + const my = ly - 6; + const a = (mx - world.rows * (scale / 2)) / (scale / 2); + const b = my / (scale / 4); + const c = Math.round((a + b / 2) / 2); + const r = Math.round((b / 2 - a) / 2); + if (inBounds(world, c, r)) { + this.centerOn(c, r); + this.cb.onMinimapJump?.(); + } + }); + this.refreshMinimap(); + } + + refreshMinimap() { + if (!this.miniGfx) return; + const { world } = this.state; + const scale = this.miniScale; + const explored = this.humanIdx >= 0 ? this.state.explored[this.humanIdx] : null; + const g = this.miniGfx; + g.clear(); + for (let r = 0; r < world.rows; r += 1) { + for (let c = 0; c < world.cols; c += 1) { + const idx = tileIndex(world, c, r); + const px = (world.rows + c - r) * (scale / 2); + const py = (c + r) * (scale / 4); + if (explored && !explored[idx]) { + g.fillStyle(0x05060a, 1); + } else { + const terr = this.rules.terrainList[world.terrain[idx]]; + g.fillStyle(Phaser.Display.Color.HexStringToColor(terr.color).color, 1); + } + g.fillRect(px, py, Math.max(1.5, scale / 2), Math.max(1.5, scale / 2)); + } + } + for (const city of this.state.cities) { + const idx = tileIndex(world, city.x, city.y); + if (explored && !explored[idx]) continue; + const px = (world.rows + city.x - city.y) * (scale / 2); + const py = (city.x + city.y) * (scale / 4); + g.fillStyle(Phaser.Display.Color.HexStringToColor(this.state.civs[city.civ].color).color, 1); + g.fillRect(px - 1, py - 1, 4, 4); + } + } +} + +function specialColor(id) { + const map = { + buffalo: 0x8a5a2a, wheat: 0xe8c84a, pheasant: 0xc06030, silk: 0xe8e8f0, + coal: 0x30302e, wine: 0x7a2050, gold: 0xffd700, iron: 0x8a8a92, + oasis: 0x30a060, oil: 0x1a1a1a, game: 0x9a6a3a, furs: 0xb0885a, + ivory: 0xf0ead8, glacieroil: 0x1a1a1a, peat: 0x5a4a2a, spice: 0xd07030, + gems: 0x30c0c0, fruit: 0xe07040, fish: 0x60a0e0, whales: 0x4060a0, + }; + return map[id] ?? 0xffffff; +} diff --git a/src/games/civilization/CivilizationRules.js b/src/games/civilization/CivilizationRules.js new file mode 100644 index 0000000..ea3bb3b --- /dev/null +++ b/src/games/civilization/CivilizationRules.js @@ -0,0 +1,153 @@ +// Civilization — rules compiler. Pure data module, no Phaser imports, so it can +// run headless in Node (tools/verifyCivilization.js) and in the browser scene. +// +// compileRules(json) validates data/civilization-rules.json and returns an +// indexed, derived rule set the engine and UI both consume. + +export function compileRules(json) { + const errors = []; + const need = (cond, msg) => { if (!cond) errors.push(msg); }; + + need(Array.isArray(json.techs) && json.techs.length > 0, 'techs missing'); + need(Array.isArray(json.units) && json.units.length > 0, 'units missing'); + need(Array.isArray(json.terrains) && json.terrains.length > 0, 'terrains missing'); + 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'); + if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`); + + const byId = (list, label) => { + const map = {}; + for (const item of list) { + need(typeof item.id === 'string' && item.id.length > 0, `${label} entry missing id`); + need(!map[item.id], `${label} duplicate id ${item.id}`); + map[item.id] = item; + } + return map; + }; + + const techs = byId(json.techs, 'tech'); + const units = byId(json.units, 'unit'); + const terrains = byId(json.terrains, 'terrain'); + const specials = byId(json.specials ?? [], 'special'); + const buildings = byId(json.buildings, 'building'); + const governments = byId(json.governments, 'government'); + const difficulties = byId(json.difficulties, 'difficulty'); + const improvements = byId(json.improvements ?? [], 'improvement'); + const worldSizes = byId(json.worldSizes ?? [], 'worldSize'); + + // --- tech graph validation: prereqs resolve, <=2 each, acyclic, all reachable + for (const t of json.techs) { + need(Array.isArray(t.prereqs) && t.prereqs.length <= 2, `tech ${t.id} needs 0-2 prereqs`); + 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`); + } + if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`); + + // Topological rank: rank 0 = no prereqs; rank(t) = 1 + max(rank(prereqs)). + // Also proves acyclicity and reachability (unrankable => cycle or dangling). + const rank = {}; + let assigned = 0; + let progress = true; + while (progress) { + progress = false; + for (const t of json.techs) { + if (rank[t.id] !== undefined) continue; + if (t.prereqs.every((p) => rank[p] !== undefined)) { + rank[t.id] = t.prereqs.length ? 1 + Math.max(...t.prereqs.map((p) => rank[p])) : 0; + assigned += 1; + progress = true; + } + } + } + need(assigned === json.techs.length, + `tech graph has a cycle or unreachable techs (${json.techs.length - assigned} unranked)`); + + // --- cross references + for (const u of json.units) { + if (u.prereq) need(!!techs[u.prereq], `unit ${u.id} prereq tech ${u.prereq} unknown`); + if (u.obsoletedBy) need(!!units[u.obsoletedBy], `unit ${u.id} obsoletedBy ${u.obsoletedBy} unknown`); + need(['land', 'sea', 'air', 'project'].includes(u.domain), `unit ${u.id} bad domain`); + } + for (const b of json.buildings) { + if (b.prereq) need(!!techs[b.prereq], `building ${b.id} prereq tech ${b.prereq} unknown`); + if (b.requires) need(!!buildings[b.requires], `building ${b.id} requires ${b.requires} unknown`); + } + for (const g of json.governments) { + if (g.prereq) need(!!techs[g.prereq], `government ${g.id} prereq tech ${g.prereq} unknown`); + } + for (const s of json.specials ?? []) { + need(!!terrains[s.terrain], `special ${s.id} terrain ${s.terrain} unknown`); + } + for (const t of json.terrains) { + if (t.transform) need(!!terrains[t.transform], `terrain ${t.id} transform ${t.transform} 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`); + } + if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`); + + // --- derived: what each tech unlocks (for UI hovers and the "every tech + // matters" verify check) + const gates = {}; + for (const id of Object.keys(techs)) gates[id] = { units: [], buildings: [], governments: [], improvements: [], prereqOf: [] }; + for (const u of json.units) if (u.prereq) gates[u.prereq].units.push(u.id); + for (const b of json.buildings) if (b.prereq) gates[b.prereq].buildings.push(b.id); + for (const g of json.governments) if (g.prereq) gates[g.prereq].governments.push(g.id); + for (const imp of json.improvements ?? []) if (imp.prereq) gates[imp.prereq].improvements.push(imp.id); + for (const t of json.techs) for (const p of t.prereqs) gates[p].prereqOf.push(t.id); + + // Specials grouped by terrain for worldgen. + const specialsByTerrain = {}; + for (const s of json.specials ?? []) { + (specialsByTerrain[s.terrain] ??= []).push(s); + } + + return { + version: json.version ?? 1, + raw: json, + techs, units, terrains, specials, buildings, governments, difficulties, + improvements, worldSizes, + techList: json.techs, + unitList: json.units, + terrainList: json.terrains, + specialList: json.specials ?? [], + buildingList: json.buildings, + governmentList: json.governments, + difficultyList: json.difficulties, + improvementList: json.improvements ?? [], + worldSizeList: json.worldSizes ?? [], + techRank: rank, + techGates: gates, + specialsByTerrain, + grasslandShieldFrame: json.grasslandShieldFrame ?? 1, + spaceship: json.spaceship, + playerColors: json.playerColors, + cityNames: json.cityNames, + yearCurve: json.yearCurve, + }; +} + +// Beaker cost of the (n+1)-th tech when n techs are already known. +export function techCost(nKnown, researchFactor = 1) { + return Math.round((10 + 10 * (nKnown + 1)) * researchFactor); +} + +// Civ II-style turn -> calendar year. +export function turnToYear(turn, yearCurve) { + let year = -4000; + for (let i = 0; i < turn; i += 1) { + let step = 1; + for (const seg of yearCurve) { + if (year < seg.until) { step = seg.step; break; } + } + year += step; + if (year === 0) year = 1; // no year zero + } + return year; +} + +export function formatYear(year) { + return year < 0 ? `${-year} BC` : `${year} AD`; +} diff --git a/src/games/civilization/CivilizationScreens.js b/src/games/civilization/CivilizationScreens.js new file mode 100644 index 0000000..16c1952 --- /dev/null +++ b/src/games/civilization/CivilizationScreens.js @@ -0,0 +1,412 @@ +// Civilization — secondary screens: tech tree, diplomacy (with the standard +// opponents' mood videos via Portrait.js), spaceship status, victory overlay. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { createOpponentPortrait } from '../../ui/Portrait.js'; +import * as Logic from './CivilizationLogic.js'; + +const FONT = '"Julius Sans One"'; +const ERAS = ['ancient', 'medieval', 'industrial', 'modern']; +const ERA_LABEL = { ancient: 'ANCIENT', medieval: 'MEDIEVAL', industrial: 'INDUSTRIAL', modern: 'MODERN' }; + +function modalShell(scene, title, onClose, { width = 1700, height = 940 } = {}) { + const root = scene.add.container(0, 0).setDepth(65); + const dim = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65) + .setInteractive(); + const panel = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, width, height, COLORS.panel) + .setStrokeStyle(3, COLORS.accent); + const titleTxt = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - height / 2 + 34, title, { + fontFamily: 'Righteous', fontSize: '32px', color: COLORS.accentHex, + }).setOrigin(0.5); + root.add([dim, panel, titleTxt]); + const close = () => { root.destroy(true); onClose(); }; + const closeBtn = new Button(scene, GAME_WIDTH / 2 + width / 2 - 70, GAME_HEIGHT / 2 - height / 2 + 40, + '✕', close, { width: 60, height: 44, fontSize: 22, variant: 'ghost' }); + root.add(closeBtn); + return { root, close }; +} + +// --------------------------------------------------------------------------- +// Tech tree + +export function openTechScreen(scene, rules, state, onClose) { + const civ = state.civs[state.humanIndex]; + const { root, close } = modalShell(scene, 'RESEARCH', onClose); + const width = 1700; + const height = 940; + const left = GAME_WIDTH / 2 - width / 2 + 30; + const top = GAME_HEIGHT / 2 - height / 2 + 70; + + const cost = Logic.currentResearchCost(rules, state, civ); + const current = civ.researching + ? `Researching: ${rules.techs[civ.researching].name} (${civ.beakers}/${cost} beakers)` + : 'Pick a technology to research'; + root.add(scene.add.text(GAME_WIDTH / 2, top - 6, current, { + fontFamily: FONT, fontSize: '21px', color: COLORS.goldHex, + }).setOrigin(0.5)); + + // Scrollable grid: 4 era columns, techs sorted by rank inside each era. + const scrollArea = scene.add.container(0, 0); + root.add(scrollArea); + const maskShape = scene.make.graphics({ add: false }); + maskShape.fillStyle(0xffffff); + maskShape.fillRect(left, top + 20, width - 60, height - 130); + scrollArea.setMask(maskShape.createGeometryMask()); + + const available = new Set(Logic.availableTechs(rules, civ).map((t) => t.id)); + const colW = (width - 60) / 4; + const rowH = 42; + let maxRows = 0; + ERAS.forEach((era, col) => { + const list = rules.techList.filter((t) => t.era === era) + .sort((a, b) => rules.techRank[a.id] - rules.techRank[b.id]); + maxRows = Math.max(maxRows, list.length); + const cx = left + col * colW; + scrollArea.add(scene.add.text(cx + colW / 2, top + 34, ERA_LABEL[era], { + fontFamily: FONT, fontSize: '19px', color: COLORS.mutedHex, + }).setOrigin(0.5)); + list.forEach((t, row) => { + const y = top + 70 + row * rowH; + const known = !!civ.known[t.id] || (t.repeatable && civ.futureCount > 0); + const canPick = available.has(t.id); + const isCurrent = civ.researching === t.id; + const bg = known ? 0x24401f : canPick ? 0x3a3222 : 0x181510; + const stroke = isCurrent ? COLORS.gold : known ? 0x4a9e44 : canPick ? COLORS.accent : COLORS.muted; + const rect = scene.add.rectangle(cx + colW / 2, y, colW - 18, rowH - 8, bg) + .setStrokeStyle(isCurrent ? 3 : 1, stroke, canPick || known || isCurrent ? 1 : 0.4); + const name = t.repeatable && civ.futureCount > 0 ? `${t.name} (${civ.futureCount})` : t.name; + const txt = scene.add.text(cx + colW / 2 - (colW - 18) / 2 + 10, y, name, { + fontFamily: FONT, fontSize: '16px', + color: known ? '#9fdf9a' : canPick ? COLORS.textHex : COLORS.mutedHex, + }).setOrigin(0, 0.5); + scrollArea.add(rect); + scrollArea.add(txt); + // Gate summary on hover. + const g = rules.techGates[t.id]; + const unlocks = [ + ...g.units.map((u) => rules.units[u].name), + ...g.buildings.map((b) => rules.buildings[b].name), + ...g.governments.map((gv) => rules.governments[gv].name), + ...g.improvements.map((im) => rules.improvements[im].name), + ]; + rect.setInteractive({ useHandCursor: canPick }); + rect.on('pointerover', () => { + hoverText.setText(`${t.name}${t.prereqs.length ? ` ⟵ ${t.prereqs.map((p) => rules.techs[p].name).join(' + ')}` : ''}` + + (unlocks.length ? `\nUnlocks: ${unlocks.join(', ')}` : '')); + }); + rect.on('pointerout', () => hoverText.setText('')); + if (canPick) { + rect.on('pointerdown', () => { + Logic.setResearch(rules, state, civ, t.id); + close(); + }); + } + }); + }); + + const hoverText = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + height / 2 - 46, '', { + fontFamily: FONT, fontSize: '17px', color: COLORS.goldHex, align: 'center', + }).setOrigin(0.5); + root.add(hoverText); + + // Wheel scroll for tall columns. + const contentH = 70 + maxRows * rowH; + const viewH = height - 130; + let scrollY = 0; + scene.input.on('wheel', onWheel); + function onWheel(pointer, objs, dx, dy) { + scrollY = Phaser.Math.Clamp(scrollY + dy * 0.5, 0, Math.max(0, contentH - viewH)); + scrollArea.y = -scrollY; + } + root.once('destroy', () => scene.input.off('wheel', onWheel)); +} + +// --------------------------------------------------------------------------- +// Diplomacy + +export function openDiplomacyScreen(scene, rules, state, opponentsData, respondToProposal, onClose) { + const human = state.humanIndex; + const civ = state.civs[human]; + let portrait = null; + const { root, close } = modalShell(scene, 'DIPLOMACY', () => { + portrait?.destroy(); + onClose(); + }); + const width = 1700; + const height = 940; + const left = GAME_WIDTH / 2 - width / 2 + 40; + const top = GAME_HEIGHT / 2 - height / 2 + 80; + + const contacts = state.civs.filter((c) => c.id !== human && c.alive + && civ.relations[c.id] !== 'nocontact'); + + if (!contacts.length) { + root.add(scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2, + 'You have not met any other civilizations yet.\nSend units out to explore the world.', { + fontFamily: FONT, fontSize: '26px', color: COLORS.mutedHex, align: 'center', + }).setOrigin(0.5)); + return; + } + + let detail = scene.add.container(0, 0); + root.add(detail); + let selected = contacts[0]; + + // Left rail: contacted civs. + contacts.forEach((other, i) => { + const y = top + 30 + i * 74; + const rel = civ.relations[other.id]; + const rect = scene.add.rectangle(left + 190, y, 380, 64, 0x181510) + .setStrokeStyle(2, other === selected ? COLORS.gold : COLORS.muted, 0.9); + const mood = Logic.attitudeMood(civ.attitude[other.id] ?? 0); + const moodDot = { upset: 0xe06c75, idle: 0xc8a84b, happy: 0x4a9e44 }[mood]; + const dot = scene.add.circle(left + 30, y, 9, moodDot); + const txt = scene.add.text(left + 52, y, `${other.name}\n${relLabel(rel)}`, { + fontFamily: FONT, fontSize: '17px', color: COLORS.textHex, lineSpacing: 3, + }).setOrigin(0, 0.5); + rect.setInteractive({ useHandCursor: true }); + rect.on('pointerdown', () => { selected = other; drawDetail(); }); + root.add([rect, dot, txt]); + }); + + function relLabel(rel) { + return { contact: 'No treaty', war: 'AT WAR', ceasefire: 'Cease-fire', peace: 'Peace treaty', alliance: 'Alliance' }[rel] ?? rel; + } + + function drawDetail() { + portrait?.destroy(); + portrait = null; + detail.destroy(true); + detail = scene.add.container(0, 0); + root.add(detail); + const other = selected; + const rel = civ.relations[other.id]; + const attitude = civ.attitude[other.id] ?? 0; + const mood = Logic.attitudeMood(attitude); + const cx = left + 700; + + // Video portrait with the character's current mood. + const opData = opponentsData.find((o) => o.id === other.leaderId) + ?? { id: other.leaderId, name: other.name, spriteIndex: 0 }; + try { + portrait = createOpponentPortrait(scene, opData, cx, top + 170, 130, 66, { playIntro: false }); + if (mood !== 'idle') portrait.playEmotion(mood); + } catch (_) { /* portrait optional */ } + + const moodWord = { upset: 'is furious with you', idle: 'is indifferent', happy: 'is friendly' }[mood]; + detail.add(scene.add.text(cx, top + 330, + `${other.name} of the ${other.name}ites ${moodWord}.\nStatus: ${relLabel(rel)}`, { + fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, align: 'center', lineSpacing: 6, + }).setOrigin(0.5, 0)); + + // Their known-tech / power intel. + detail.add(scene.add.text(cx, top + 410, + `Technologies: ${Logic.knownCount(other)} · Cities: ${Logic.civCities(state, other.id).length}`, { + fontFamily: FONT, fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5, 0)); + + // Action buttons. + const actions = []; + if (rel === 'war') actions.push(['PROPOSE CEASE-FIRE', () => propose('ceasefire')]); + if (rel === 'ceasefire') actions.push(['PROPOSE PEACE', () => propose('peace')]); + if (rel === 'contact') actions.push(['PROPOSE PEACE', () => propose('peace')]); + if (rel === 'peace') actions.push(['PROPOSE ALLIANCE', () => propose('alliance')]); + if (rel !== 'war') actions.push(['DECLARE WAR', () => { + Logic.declareWar(rules, state, human, other.id); + drawDetail(); + }]); + actions.push(['GIFT 50 GOLD', () => { + if (Logic.giftGold(state, human, other.id, 50)) drawDetail(); + }]); + actions.push(['EXCHANGE TECH', () => openExchange(other)]); + actions.push(['GIFT TECH', () => openGift(other)]); + + actions.forEach(([label, fn], i) => { + const bx = cx - 340 + (i % 2) * 360; + const by = top + 480 + Math.floor(i / 2) * 66; + detail.add(new Button(scene, bx + 170, by, label, fn, { width: 330, height: 52, fontSize: 18 })); + }); + + function propose(kind) { + if (!Logic.canPropose(state, human, other.id, kind)) return; + const accepted = respondToProposal(rules, state, other.id, human, kind); + if (accepted) { + Logic.applyTreaty(state, human, other.id, kind); + portrait?.playEmotion('happy'); + } else { + portrait?.playEmotion('upset'); + Logic.bumpAttitude(state, other.id, human, -3); + } + scene.time.delayedCall(900, drawDetail); + } + + function openGift(target) { + const mine = Object.keys(civ.known).filter((t) => !target.known[t]); + pickTech(scene, rules, detail, cx, top + 120, 'GIFT A TECHNOLOGY', mine, (techId) => { + Logic.giftTech(rules, state, human, target.id, techId); + portrait?.playEmotion('happy'); + drawDetail(); + }); + } + + function openExchange(target) { + const mine = Object.keys(civ.known).filter((t) => !target.known[t]); + const theirs = Object.keys(target.known).filter((t) => !civ.known[t]); + if (!mine.length || !theirs.length) return; + pickTech(scene, rules, detail, cx, top + 120, 'OFFER WHICH TECH?', mine, (giveId) => { + pickTech(scene, rules, detail, cx, top + 120, 'ASK FOR WHICH TECH?', theirs, (getId) => { + const ok = respondToProposal(rules, state, target.id, human, 'exchange', { giveId, getId }); + if (ok) { + Logic.exchangeTechs(rules, state, human, target.id, giveId, getId); + portrait?.playEmotion('happy'); + } else { + portrait?.playEmotion('upset'); + } + scene.time.delayedCall(900, drawDetail); + }); + }); + } + } + + drawDetail(); +} + +// Small chooser list overlaid on the diplomacy panel. +function pickTech(scene, rules, parent, cx, cy, title, techIds, onPick) { + const box = scene.add.container(0, 0); + parent.add(box); + const h = Math.min(560, 80 + techIds.length * 34); + box.add(scene.add.rectangle(cx + 480, cy + h / 2, 360, h, 0x11100c, 0.98) + .setStrokeStyle(2, COLORS.accent)); + box.add(scene.add.text(cx + 480, cy + 24, title, { + fontFamily: FONT, fontSize: '18px', color: COLORS.goldHex, + }).setOrigin(0.5)); + techIds.slice(0, 14).forEach((id, i) => { + const t = scene.add.text(cx + 480, cy + 58 + i * 34, rules.techs[id].name, { + fontFamily: FONT, fontSize: '17px', color: COLORS.textHex, + }).setOrigin(0.5).setInteractive({ useHandCursor: true }); + t.on('pointerover', () => t.setColor(COLORS.goldHex)); + t.on('pointerout', () => t.setColor(COLORS.textHex)); + t.on('pointerdown', () => { box.destroy(true); onPick(id); }); + box.add(t); + }); +} + +// --------------------------------------------------------------------------- +// Spaceship + +export function openSpaceshipScreen(scene, rules, state, onClose) { + const { root, close } = modalShell(scene, 'SPACESHIP TO ALPHA CENTAURI', onClose, + { width: 1100, height: 760 }); + const human = state.humanIndex; + const civ = state.civs[human]; + const cx = GAME_WIDTH / 2; + const top = GAME_HEIGHT / 2 - 380 + 80; + const need = rules.spaceship; + + const rows = [ + ['Structural', civ.spaceship.structural, need.structuralNeeded, 'ssstructural'], + ['Components', civ.spaceship.component, need.componentsNeeded, 'sscomponent'], + ['Modules', civ.spaceship.module, need.modulesNeeded, 'ssmodule'], + ]; + rows.forEach(([label, have, needed, unitId], i) => { + const y = top + 60 + i * 90; + const unit = rules.units[unitId]; + const gated = unit.prereq && !civ.known[unit.prereq]; + root.add(scene.add.text(cx - 380, y, label, { + fontFamily: FONT, fontSize: '26px', color: COLORS.textHex, + }).setOrigin(0, 0.5)); + for (let s = 0; s < needed; s += 1) { + const filled = s < have; + root.add(scene.add.rectangle(cx - 120 + s * 56, y, 44, 44, + filled ? 0x4a9e44 : 0x181510).setStrokeStyle(2, filled ? 0x9fdf9a : COLORS.muted)); + } + root.add(scene.add.text(cx + 400, y, + gated ? `needs ${rules.techs[unit.prereq].name}` : `${have}/${needed}`, { + fontFamily: FONT, fontSize: '20px', color: gated ? COLORS.mutedHex : COLORS.goldHex, + }).setOrigin(1, 0.5)); + }); + + const shipY = top + 360; + if (civ.spaceship.launched) { + root.add(scene.add.text(cx, shipY, `Spaceship en route!\nArrival: ${civ.spaceship.arrivalTurn - state.turn} turns`, { + fontFamily: FONT, fontSize: '28px', color: '#9fdf9a', align: 'center', lineSpacing: 8, + }).setOrigin(0.5)); + root.add(scene.add.text(cx, shipY + 100, 'Guard your capital — losing it destroys the ship.', { + fontFamily: FONT, fontSize: '19px', color: COLORS.dangerHex, + }).setOrigin(0.5)); + } else { + const ready = civ.spaceship.structural >= need.structuralNeeded + && civ.spaceship.component >= need.componentsNeeded + && civ.spaceship.module >= need.modulesNeeded; + const launch = new Button(scene, cx, shipY, ready ? 'LAUNCH!' : 'LAUNCH (parts missing)', () => { + if (Logic.launchSpaceship(rules, state, civ)) close(); + }, { width: 420, height: 64, fontSize: 26, variant: ready ? 'solid' : 'ghost' }); + root.add(launch); + } + + // Rival intel. + const rivals = state.civs.filter((c) => c.alive && c.id !== human + && state.civs[human].relations[c.id] !== 'nocontact'); + const intel = rivals.map((c) => { + const s = c.spaceship; + if (s.launched) return `${c.name}: LAUNCHED (arrives turn ${s.arrivalTurn})`; + const parts = s.structural + s.component + s.module; + return `${c.name}: ${parts ? `${parts} parts built` : 'no spaceship'}`; + }); + root.add(scene.add.text(cx, shipY + 180, intel.join('\n') || 'No rival intelligence.', { + fontFamily: FONT, fontSize: '18px', color: COLORS.mutedHex, align: 'center', lineSpacing: 6, + }).setOrigin(0.5, 0)); +} + +// --------------------------------------------------------------------------- +// Victory / defeat + +export function showVictoryOverlay(scene, rules, state, opponentsData, onPlayAgain) { + const root = scene.add.container(0, 0).setDepth(90); + root.add(scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.8) + .setInteractive()); + const human = state.humanIndex; + const winnerIdx = state.over?.winner ?? -1; + const winner = winnerIdx >= 0 ? state.civs[winnerIdx] : null; + const humanWon = winnerIdx === human; + const mode = state.over?.type; + + const title = humanWon + ? (mode === 'spaceship' ? 'YOUR SHIP REACHES ALPHA CENTAURI!' : 'THE WORLD IS YOURS!') + : (winner ? `${winner.name.toUpperCase()} HAS ${mode === 'spaceship' ? 'REACHED THE STARS' : 'CONQUERED THE WORLD'}` : 'CIVILIZATION HAS FALLEN'); + root.add(scene.add.text(GAME_WIDTH / 2, 280, title, { + fontFamily: 'Righteous', fontSize: '52px', + color: humanWon ? COLORS.goldHex : COLORS.dangerHex, align: 'center', + wordWrap: { width: 1500 }, + }).setOrigin(0.5)); + + if (winner) { + const opData = opponentsData.find((o) => o.id === winner.leaderId) + ?? { id: winner.leaderId, name: winner.name, spriteIndex: 0 }; + try { + const p = createOpponentPortrait(scene, opData, GAME_WIDTH / 2, 480, 120, 95, { playIntro: false }); + p.playEmotion(humanWon ? 'happy' : 'happy'); // the winner celebrates either way + root.once('destroy', () => p.destroy()); + } catch (_) { /* optional */ } + } + + // Final scores. + const scores = state.civs + .map((c) => ({ name: c.name, score: Logic.civScore(rules, state, c.id), alive: c.alive })) + .sort((a, b) => b.score - a.score) + .map((s, i) => `${i + 1}. ${s.name}${s.alive ? '' : ' †'} — ${s.score}`); + root.add(scene.add.text(GAME_WIDTH / 2, 660, `FINAL SCORES\n${scores.join('\n')}`, { + fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, align: 'center', lineSpacing: 8, + }).setOrigin(0.5, 0)); + + root.add(new Button(scene, GAME_WIDTH / 2 - 160, GAME_HEIGHT - 120, 'PLAY AGAIN', () => { + root.destroy(true); + onPlayAgain(); + }, { width: 280, height: 60, fontSize: 24 })); + root.add(new Button(scene, GAME_WIDTH / 2 + 160, GAME_HEIGHT - 120, 'MAIN MENU', () => { + scene.scene.start('GameMenu'); + }, { width: 280, height: 60, fontSize: 24, variant: 'ghost' })); +} diff --git a/src/games/civilization/CivilizationWorldGen.js b/src/games/civilization/CivilizationWorldGen.js new file mode 100644 index 0000000..b9051b5 --- /dev/null +++ b/src/games/civilization/CivilizationWorldGen.js @@ -0,0 +1,273 @@ +// Civilization — seeded world generation. Headless (no Phaser). +// +// Square grid (col,row), rendered isometrically by the view. Flat world (no +// east-west wrap). Terrain from layered value noise: elevation picks land/sea +// (exact land fraction via percentile threshold), a ridged channel places +// hills/mountains, latitude bands place tundra/glacier/desert, and a moisture +// channel places forest/jungle/swamp. Specials sit on a Civ II-style diagonal +// lattice; huts scatter on land away from starting positions. + +export function mulberry32(seed) { + let a = seed >>> 0; + return function next() { + a |= 0; a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Smooth value noise in [0,1]: random lattice at `freq` cells, cosine-blended. +function makeNoise(rng, cols, rows, octaves = 3, baseFreq = 5) { + const layers = []; + for (let o = 0; o < octaves; o += 1) { + const freq = baseFreq * (1 << o); + const gw = freq + 2; + const gh = Math.max(2, Math.round(freq * (rows / cols))) + 2; + const grid = []; + for (let i = 0; i < gw * gh; i += 1) grid.push(rng()); + layers.push({ grid, gw, gh, amp: 1 / (1 << o) }); + } + const smooth = (t) => (1 - Math.cos(t * Math.PI)) / 2; + return (x, y) => { + let sum = 0; + let ampSum = 0; + for (const { grid, gw, gh, amp } of layers) { + const fx = (x / cols) * (gw - 2); + const fy = (y / rows) * (gh - 2); + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const tx = smooth(fx - x0); + const ty = smooth(fy - y0); + const v00 = grid[y0 * gw + x0]; + const v10 = grid[y0 * gw + x0 + 1]; + const v01 = grid[(y0 + 1) * gw + x0]; + const v11 = grid[(y0 + 1) * gw + x0 + 1]; + sum += amp * ((v00 * (1 - tx) + v10 * tx) * (1 - ty) + (v01 * (1 - tx) + v11 * tx) * ty); + ampSum += amp; + } + return sum / ampSum; + }; +} + +// Civ II-ish deterministic lattices (independent of RNG so they look "placed"). +export function shieldGrassAt(x, y) { return ((x * 2 + y) % 4) < 2; } +function specialSlotAt(x, y, offset) { + return ((x * 3 + y * 5 + offset) % 16) === 0; +} +function specialPick(x, y) { return ((x >> 1) + (y >> 1)) % 2; } + +// Improvement bit flags stored per tile. +export const IMP = { ROAD: 1, RAILROAD: 2, IRRIGATION: 4, FARMLAND: 8, MINE: 16, FORTRESS: 32 }; + +export function generateWorld(rules, { sizeId = 'medium', seed = 1, numCivs = 4 } = {}) { + const size = rules.worldSizes[sizeId]; + if (!size) throw new Error(`unknown world size ${sizeId}`); + const { cols, rows } = size; + const terrainIndex = {}; + rules.terrainList.forEach((t, i) => { terrainIndex[t.id] = i; }); + const T = terrainIndex; + + // Retry with derived seeds until the continent constraint holds. + for (let attempt = 0; attempt < 12; attempt += 1) { + const rng = mulberry32((seed * 7919 + attempt * 104729) >>> 0); + const world = tryGenerate(rules, cols, rows, T, rng, numCivs, seed, attempt); + if (world) { + world.sizeId = sizeId; + world.seed = seed; + return world; + } + } + throw new Error(`worldgen failed after 12 attempts (seed ${seed}, ${sizeId}, ${numCivs} civs)`); +} + +function tryGenerate(rules, cols, rows, T, rng, numCivs, seed, attempt) { + const n = cols * rows; + const elevNoise = makeNoise(rng, cols, rows, 3, 4); + const moistNoise = makeNoise(rng, cols, rows, 3, 5); + const ridgeNoise = makeNoise(rng, cols, rows, 2, 6); + const splitNoise = makeNoise(rng, cols, rows, 2, 7); + + // Elevation with edge falloff so continents pull away from the map border. + const elev = new Float64Array(n); + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < cols; x += 1) { + const ex = Math.min(x, cols - 1 - x) / (cols / 2); + const ey = Math.min(y, rows - 1 - y) / (rows / 2); + const falloff = Math.min(1, Math.min(ex, ey) * 3.2); + elev[y * cols + x] = elevNoise(x, y) * (0.35 + 0.65 * falloff); + } + } + + // Land threshold at the exact percentile for the target land fraction. + const landTarget = 0.30 + (rng() - 0.5) * 0.05; // 27.5%..32.5% + const sorted = Array.from(elev).sort((a, b) => a - b); + const threshold = sorted[Math.floor(n * (1 - landTarget))]; + + const terrain = new Array(n); + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < cols; x += 1) { + const i = y * cols + x; + if (elev[i] < threshold) { terrain[i] = T.ocean; continue; } + const lat = Math.abs(y - (rows - 1) / 2) / ((rows - 1) / 2); + const moist = moistNoise(x, y); + const ridge = 1 - Math.abs(2 * ridgeNoise(x, y) - 1); + if (lat > 0.92) { terrain[i] = T.glacier; continue; } + if (ridge > 0.86) { terrain[i] = T.mountains; continue; } + if (ridge > 0.74) { terrain[i] = T.hills; continue; } + if (lat > 0.78) { terrain[i] = moist > 0.6 ? T.tundra : (rng() < 0.4 ? T.glacier : T.tundra); continue; } + if (lat < 0.35 && moist < 0.32) { terrain[i] = T.desert; continue; } + if (lat < 0.42 && moist > 0.74) { terrain[i] = T.jungle; continue; } + if (moist > 0.82) { terrain[i] = T.swamp; continue; } + if (moist > 0.56) { terrain[i] = T.forest; continue; } + terrain[i] = splitNoise(x, y) > 0.48 ? T.grassland : T.plains; + } + } + + // Continent labels (4-neighbour flood fill over land). + const continent = new Int16Array(n).fill(-1); + const contSizes = []; + for (let i = 0; i < n; i += 1) { + if (terrain[i] === T.ocean || continent[i] !== -1) continue; + const label = contSizes.length; + let count = 0; + const stack = [i]; + continent[i] = label; + while (stack.length) { + const cur = stack.pop(); + count += 1; + const cx = cur % cols; + const cy = (cur / cols) | 0; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = cx + dx; + const ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue; + const ni = ny * cols + nx; + if (terrain[ni] !== T.ocean && continent[ni] === -1) { + continent[ni] = label; + stack.push(ni); + } + } + } + contSizes.push(count); + } + const landCount = terrain.reduce((acc, t) => acc + (t === T.ocean ? 0 : 1), 0); + const largest = Math.max(0, ...contSizes); + if (landCount === 0 || largest / landCount < 0.15) return null; // reroll + + // Specials on the diagonal lattice (grassland gets shield variant instead). + const latticeOffset = Math.floor(rng() * 16); + const special = new Array(n).fill(-1); + const specialIndexById = {}; + rules.specialList.forEach((s, i) => { specialIndexById[s.id] = i; }); + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < cols; x += 1) { + const i = y * cols + x; + const terr = rules.terrainList[terrain[i]]; + if (terr.id === 'grassland') continue; + if (!specialSlotAt(x, y, latticeOffset)) continue; + const opts = rules.specialsByTerrain[terr.id]; + if (!opts || !opts.length) continue; + const pick = opts[specialPick(x, y) % opts.length]; + special[i] = specialIndexById[pick.id]; + } + } + + // Starting positions: quality-scored land tiles, greedy farthest-point. + const starts = pickStarts(rules, cols, rows, T, terrain, special, continent, contSizes, numCivs, rng); + if (!starts) return null; + + // Goody huts: ~1 per 40 land tiles, away from starts, not on glacier. + const huts = new Uint8Array(n); + const hutTarget = Math.floor(landCount / 40); + let placed = 0; + for (let tries = 0; tries < hutTarget * 30 && placed < hutTarget; tries += 1) { + const x = Math.floor(rng() * cols); + const y = Math.floor(rng() * rows); + const i = y * cols + x; + if (terrain[i] === T.ocean || terrain[i] === T.glacier || huts[i]) continue; + if (starts.some(([sx, sy]) => Math.max(Math.abs(sx - x), Math.abs(sy - y)) <= 2)) continue; + huts[i] = 1; + placed += 1; + } + + return { + cols, + rows, + terrain, + special, + improvements: new Array(n).fill(0), + huts: Array.from(huts), + continent: Array.from(continent), + starts, + landFraction: landCount / n, + largestContinentFrac: largest / landCount, + attempt, + }; +} + +// Quality of a would-be city site: yields of the centre + 8 neighbours. +export function siteQuality(rules, world, x, y) { + const { cols, rows } = world; + let q = 0; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue; + const i = ny * cols + nx; + const terr = rules.terrainList[world.terrain[i]]; + const spec = world.special[i] >= 0 ? rules.specialList[world.special[i]] : null; + const food = spec ? spec.food : terr.food; + const shield = spec ? spec.shield : terr.shield; + const trade = spec ? spec.trade : terr.trade; + q += food * 3 + shield * 2 + trade; + } + } + return q; +} + +function pickStarts(rules, cols, rows, T, terrain, special, continent, contSizes, numCivs, rng) { + const world = { cols, rows, terrain, special }; + const candidates = []; + for (let y = 1; y < rows - 1; y += 1) { + for (let x = 1; x < cols - 1; x += 1) { + const i = y * cols + x; + const terr = rules.terrainList[terrain[i]]; + if (terr.water || terr.id === 'glacier' || terr.id === 'mountains') continue; + if (contSizes[continent[i]] < 8) continue; // no dinghy islands + const q = siteQuality(rules, world, x, y); + candidates.push({ x, y, q }); + } + } + if (candidates.length < numCivs * 4) return null; + candidates.sort((a, b) => b.q - a.q); + const qualityFloor = candidates[Math.floor(candidates.length * 0.5)].q; // top half only + const pool = candidates.filter((c) => c.q >= qualityFloor); + + const minDist = Math.max(6, Math.floor(Math.min(cols, rows) / (numCivs))); + for (let relax = 0; relax < 4; relax += 1) { + const dist = Math.max(4, minDist - relax * 2); + const chosen = []; + // Seed with a random high-quality site, then greedy farthest-point. + const first = pool[Math.floor(rng() * Math.min(pool.length, 20))]; + chosen.push(first); + while (chosen.length < numCivs) { + let best = null; + let bestScore = -1; + for (const c of pool) { + let d = Infinity; + for (const s of chosen) { + d = Math.min(d, Math.max(Math.abs(s.x - c.x), Math.abs(s.y - c.y))); + } + if (d < dist) continue; + const score = d * 10 + c.q * 0.1; + if (score > bestScore) { bestScore = score; best = c; } + } + if (!best) break; + chosen.push(best); + } + if (chosen.length === numCivs) return chosen.map((c) => [c.x, c.y]); + } + return null; +} diff --git a/src/games/civilization/sprites.md b/src/games/civilization/sprites.md new file mode 100644 index 0000000..2e1bc48 --- /dev/null +++ b/src/games/civilization/sprites.md @@ -0,0 +1,280 @@ +# Civilization — Sprite / Art Spec + +Everything in Civilization renders procedurally out of the box, so the game is +fully playable with **no** art: terrain is flat-shaded isometric diamonds with +glyphs, units are colored roundels with two-letter labels, cities are little +box clusters. This document lists the optional sprite sheets you can drop in +to replace those placeholders, with exact dimensions, layouts, and +frame-by-frame maps. + +All art is wired through **`public/data/civilization-artwork.json`**. The scene +checks whether each sheet's texture is loaded; if it is, it uses your frames, +otherwise it falls back to procedural drawing. You never need to touch code — +just add a PNG under `public/assets/images/civilization/` and set its `path` +in that JSON. + +Frame numbering everywhere is **row-major, 0-based**: frame 0 is top-left, +count left-to-right then down to the next row. + +**The isometric diamond**: map tiles occupy a 128 × 64 px diamond. Terrain and +city frames are 128 × 96 px — the bottom 64 px is the diamond footprint, the +top 32 px is headroom for tall features (peaks, trees, towers). The diamond's +four corners touch the frame edges at (64, 32), (128, 64+32), (64, 96), and +(0, 64+32) — i.e. left/right corners at frame-y 64, top corner at frame-y 32, +bottom corner at frame-y 96. Fill the whole diamond opaquely; leave the +headroom transparent except where the feature rises into it. Tiles butt +against each other with no blending in v1 (hard Civ I-style edges). + +> **Future work (not in v1):** terrain blend transitions between neighboring +> tile types, river overlays, and coastline foam. Roads, railroads and +> irrigation are currently vector-drawn; the improvements sheet below covers +> the badge-style overlays only. + +--- + +## 1. Terrain base sheet — `civilization-terrain.png` + +| | | +|---|---| +| **Path** | `public/assets/images/civilization/civilization-terrain.png` | +| **Sheet size** | **768 × 192 px** | +| **Frame size** | **128 × 96 px** | +| **Layout** | 6 columns × 2 rows = 12 frames | +| **Status** | ❌ not created — procedural diamonds render meanwhile | +| **JSON** | `terrainSheet` | + +### Frame map + +| Frame | id | Name | Notes | +|---:|---|---|---| +| 0 | `grassland` | Grassland | plain green | +| 1 | — | Grassland (shield) | grassland variant with the little shield mark | +| 2 | `plains` | Plains | dry golden grass | +| 3 | `forest` | Forest | trees rise into the 32px headroom | +| 4 | `hills` | Hills | rolling mounds | +| 5 | `mountains` | Mountains | peaks use full headroom, snow caps | +| 6 | `desert` | Desert | sand, maybe a dune | +| 7 | `tundra` | Tundra | patchy frozen grass | +| 8 | `glacier` | Glacier | ice sheet | +| 9 | `swamp` | Swamp | murky pools, reeds | +| 10 | `jungle` | Jungle | dense canopy, brighter green than forest | +| 11 | `ocean` | Ocean | deep blue, light wave marks | + +Frame indexes are pinned in `data/civilization-rules.json` (`terrains[].frame`, +`grasslandShieldFrame`) — append-only, never renumber. + +--- + +## 2. Special resources sheet — `civilization-resources.png` + +Drawn centered on top of the base terrain tile (badge layer, 64 × 64 centered +on the diamond). + +| | | +|---|---| +| **Path** | `public/assets/images/civilization/civilization-resources.png` | +| **Sheet size** | **320 × 256 px** | +| **Frame size** | **64 × 64 px** | +| **Layout** | 5 columns × 4 rows = 20 frames | +| **Status** | ❌ not created — colored dot badges render meanwhile | +| **JSON** | `resourceSheet` | + +### Frame map + +| Frame | id | Name | On terrain | +|---:|---|---|---| +| 0 | `buffalo` | Buffalo | Plains | +| 1 | `wheat` | Wheat | Plains | +| 2 | `pheasant` | Pheasant | Forest | +| 3 | `silk` | Silk | Forest | +| 4 | `coal` | Coal | Hills | +| 5 | `wine` | Wine | Hills | +| 6 | `gold` | Gold | Mountains | +| 7 | `iron` | Iron | Mountains | +| 8 | `oasis` | Oasis | Desert | +| 9 | `oil` | Oil | Desert | +| 10 | `game` | Game | Tundra | +| 11 | `furs` | Furs | Tundra | +| 12 | `ivory` | Ivory | Glacier | +| 13 | `glacieroil` | Oil (arctic) | Glacier | +| 14 | `peat` | Peat | Swamp | +| 15 | `spice` | Spice | Swamp | +| 16 | `gems` | Gems | Jungle | +| 17 | `fruit` | Fruit | Jungle | +| 18 | `fish` | Fish | Ocean | +| 19 | `whales` | Whales | Ocean | + +--- + +## 3. Tile improvements sheet — `civilization-improvements.png` + +Badge overlays drawn centered on the tile. Roads and railroads stay +vector-drawn (they connect dynamically between tiles), so they are NOT here. + +| | | +|---|---| +| **Path** | `public/assets/images/civilization/civilization-improvements.png` | +| **Sheet size** | **384 × 64 px** | +| **Frame size** | **64 × 64 px** | +| **Layout** | 6 columns × 1 row = 6 frames | +| **Status** | ❌ not created — vector glyphs render meanwhile | +| **JSON** | `improvementSheet` | + +### Frame map + +| Frame | id | Name | Notes | +|---:|---|---|---| +| 0 | `irrigation` | Irrigation | furrow lines | +| 1 | `farmland` | Farmland | denser irrigation (Refrigeration upgrade) | +| 2 | `mine` | Mine | pit head / spoil heap | +| 3 | `fortress` | Fortress | square rampart outline | +| 4 | `hut` | Goody hut | little thatched hut | +| 5 | `fortify` | Fortify shield | reserved for a unit "fortified" badge | + +--- + +## 4. Units sheet — `civilization-units.png` + +Unit art is **neutral** (no civ color baked in): the game draws a player-color +ring/roundel underneath your frame. Center the unit in the frame with its feet +around y = 52 so it sits on the tile. + +| | | +|---|---| +| **Path** | `public/assets/images/civilization/civilization-units.png` | +| **Sheet size** | **512 × 448 px** | +| **Frame size** | **64 × 64 px** | +| **Layout** | 8 columns × 7 rows = 56 frames (51 used, 52–55 reserved) | +| **Status** | ❌ not created — roundel + 2-letter label renders meanwhile | +| **JSON** | `unitSheet` | + +### Frame map + +| Frame | id | Name | | Frame | id | Name | +|---:|---|---|---|---:|---|---| +| 0 | `settlers` | Settlers | | 26 | `cannon` | Cannon | +| 1 | `engineers` | Engineers | | 27 | `artillery` | Artillery | +| 2 | `explorer` | Explorer | | 28 | `howitzer` | Howitzer | +| 3 | `caravan` | Caravan | | 29 | `fighter` | Fighter | +| 4 | `freight` | Freight | | 30 | `bomber` | Bomber | +| 5 | `warriors` | Warriors | | 31 | `helicopter` | Helicopter | +| 6 | `phalanx` | Phalanx | | 32 | `stealthfighter` | Stealth Ftr. | +| 7 | `archers` | Archers | | 33 | `stealthbomber` | Stealth Bmb. | +| 8 | `legion` | Legion | | 34 | `cruisemsl` | Cruise Msl. | +| 9 | `pikemen` | Pikemen | | 35 | `nuclearmsl` | Nuclear Msl. | +| 10 | `musketeers` | Musketeers | | 36 | `trireme` | Trireme | +| 11 | `riflemen` | Riflemen | | 37 | `caravel` | Caravel | +| 12 | `alpinetroops` | Alpine Troops | | 38 | `galleon` | Galleon | +| 13 | `partisans` | Partisans | | 39 | `frigate` | Frigate | +| 14 | `marines` | Marines | | 40 | `ironclad` | Ironclad | +| 15 | `paratroopers` | Paratroopers | | 41 | `destroyer` | Destroyer | +| 16 | `mechinf` | Mech. Inf. | | 42 | `cruiser` | Cruiser | +| 17 | `horsemen` | Horsemen | | 43 | `aegiscruiser` | AEGIS Cruiser | +| 18 | `chariot` | Chariot | | 44 | `battleship` | Battleship | +| 19 | `elephant` | Elephant | | 45 | `submarine` | Submarine | +| 20 | `knights` | Knights | | 46 | `carrier` | Carrier | +| 21 | `crusaders` | Crusaders | | 47 | `transport` | Transport | +| 22 | `dragoons` | Dragoons | | 48 | `ssstructural` | SS Structural | +| 23 | `cavalry` | Cavalry | | 49 | `sscomponent` | SS Component | +| 24 | `armor` | Armor | | 50 | `ssmodule` | SS Module | +| 25 | `catapult` | Catapult | | 51–55 | — | reserved | + +Frame indexes are pinned in `data/civilization-rules.json` (`units[].frame`). + +--- + +## 5. City sheet (classic theme) — `civilization-cities-classic.png` + +Cities scale with population and show walls when City Walls are built. This is +the **classic/western** theme; more themes (e.g. `asian`) drop in later by +adding an entry to `citySheets` in the artwork JSON — the format already +supports it and no code changes are needed. + +| | | +|---|---| +| **Path** | `public/assets/images/civilization/civilization-cities-classic.png` | +| **Sheet size** | **512 × 192 px** | +| **Frame size** | **128 × 96 px** (same diamond+headroom as terrain) | +| **Status** | ❌ not created — box-cluster fallback renders meanwhile | +| **Layout** | 4 columns × 2 rows = 8 frames | +| **JSON** | `citySheets.classic` | + +### Frame map + +| Frame | Size tier | Walls | Notes | +|---:|---|---|---| +| 0 | 1–3 (village) | no | a few small buildings | +| 1 | 4–7 (town) | no | denser, a landmark roof | +| 2 | 8–12 (city) | no | multi-story buildings | +| 3 | 13+ (metropolis) | no | grand skyline into the headroom | +| 4 | 1–3 | **yes** | tier 0 ringed by walls | +| 5 | 4–7 | **yes** | | +| 6 | 8–12 | **yes** | | +| 7 | 13+ | **yes** | | + +The player-color banner and name plate are drawn by the game below the tile — +keep the bottom ~6 px of the diamond clear of critical detail. + +--- + +## 6. HUD icons sheet — `civilization-icons.png` + +Small interface icons (yields, governments, spaceship parts, moods). + +| | | +|---|---| +| **Path** | `public/assets/images/civilization/civilization-icons.png` | +| **Sheet size** | **480 × 96 px** | +| **Frame size** | **48 × 48 px** | +| **Layout** | 10 columns × 2 rows = 20 frames | +| **Status** | ❌ not created — text labels render meanwhile | +| **JSON** | `iconSheet` | + +### Frame map + +| Frame | id | Notes | | Frame | id | Notes | +|---:|---|---|---|---:|---|---| +| 0 | `food` | wheat sheaf | | 10 | `gov-despotism` | | +| 1 | `shield` | production shield | | 11 | `gov-anarchy` | | +| 2 | `trade` | trade arrow | | 12 | `gov-monarchy` | | +| 3 | `gold` | coin | | 13 | `gov-communism` | | +| 4 | `beaker` | science flask | | 14 | `gov-republic` | | +| 5 | `pop` | citizen head | | 15 | `gov-democracy` | | +| 6 | `ss-structural` | | | 16 | `mood-happy` | diplomacy list dot | +| 7 | `ss-component` | | | 17 | `mood-idle` | | +| 8 | `ss-module` | | | 18 | `mood-upset` | | +| 9 | `vet` | veteran chevron | | 19 | — | reserved | + +Government frames match `governments[].frame` in the rules JSON (10–15). + +--- + +## 7. Menu icon — `game-icons.png` frame 83 + +| | | +|---|---| +| **Path** | `public/assets/images/game-icons.png` (shared sheet) | +| **Sheet size** | 660 × 660 px (15 × 15 grid of 44 × 44 frames) | +| **Frame** | **83** (row 5, col 8) | +| **Status** | ❌ needs painting | + +Suggested motif: a tiny isometric globe/hex of terrain, or a laurel-wreathed +city. The menu shows a generic fallback until painted. + +--- + +## Quick checklist + +| # | File | Size | Priority | +|---|---|---|---| +| 1 | `civilization-terrain.png` | 768×192 | **High** — biggest visual upgrade | +| 2 | `civilization-cities-classic.png` | 512×192 | **High** | +| 3 | `civilization-units.png` | 512×448 | **High** (51 frames — biggest lift) | +| 4 | `civilization-resources.png` | 320×256 | Medium | +| 5 | `civilization-improvements.png` | 384×64 | Medium | +| 6 | `civilization-icons.png` | 480×96 | Low | +| 7 | `game-icons.png` frame 83 | 44×44 | Medium (menu presence) | + +After painting a sheet, set its `path` in `public/data/civilization-artwork.json`, +e.g. `"path": "assets/images/civilization/civilization-terrain.png"`, and +reload — no code changes. diff --git a/src/main.js b/src/main.js index cfa7cec..4a9efb6 100644 --- a/src/main.js +++ b/src/main.js @@ -91,6 +91,7 @@ import PeggleGame from './games/peggle/PeggleGame.js'; import PeggleEditor from './games/peggle/PeggleEditor.js'; import ColoradoDefenseGame from './games/coloradodefense/ColoradoDefenseGame.js'; import StarControlGame from './games/starcontrol/StarControlGame.js'; +import CivilizationGame from './games/civilization/CivilizationGame.js'; const config = { type: Phaser.AUTO, @@ -195,6 +196,7 @@ const config = { PeggleEditor, ColoradoDefenseGame, StarControlGame, + CivilizationGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 2a49292..e57bb3a 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 43341de..e6cc171 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -59,6 +59,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('swdbg-artwork', 'data/swdbg-artwork.json'); this.load.json('jumble', 'data/jumble.json'); this.load.json('balatro-artwork', 'data/balatro-artwork.json'); + this.load.json('civilization-artwork', 'data/civilization-artwork.json'); this.load.json('peggle-levels', 'assets/gamedata/peggle/levels.json'); this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json'); this.load.json('star-control-ships', 'data/star-control-ships.json'); diff --git a/tools/verifyCivilization.js b/tools/verifyCivilization.js new file mode 100644 index 0000000..3c1980d --- /dev/null +++ b/tools/verifyCivilization.js @@ -0,0 +1,1149 @@ +// Headless verification for Civilization (Civ II-lite). +// node tools/verifyCivilization.js [--quick] +// Exits non-zero on any failure. +// +// 1. Rules integrity: ids unique, prereqs resolve, tech DAG acyclic & fully +// reachable, every tech gates something or feeds a later tech, frames sane. +// 2. Worldgen: determinism, land fraction, continents, start quality/spacing. +// 3. City fixtures: growth, despotism penalty, corruption, auto-assign, buy math. +// 4. Combat: deterministic fixtures + Monte Carlo vs analytic model. +// 5. Research pacing per difficulty. +// 6. Trade + diplomacy state machine (incl. fuzz). +// 7. Spaceship: launch gating, countdown, capital-capture loss. +// 8. Serialization round-trip. +// 9. AI self-play soak: full games end in victory, invariants hold, perf budget. + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { compileRules, techCost, turnToYear } from '../src/games/civilization/CivilizationRules.js'; +import { generateWorld, siteQuality, shieldGrassAt } from '../src/games/civilization/CivilizationWorldGen.js'; +import * as Logic from '../src/games/civilization/CivilizationLogic.js'; +import * as AI from '../src/games/civilization/CivilizationAI.js'; + +const QUICK = process.argv.includes('--quick'); +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const rulesJson = JSON.parse(readFileSync(join(root, 'data/civilization-rules.json'), 'utf8')); + +let failures = 0; +let passes = 0; +function check(name, cond, detail = '') { + if (cond) { passes += 1; return; } + failures += 1; + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); +} +function section(name) { console.log(`\n== ${name}`); } + +// --------------------------------------------------------------------------- +section('1. rules integrity'); + +let RULES = null; +try { + RULES = compileRules(rulesJson); +} catch (err) { + check('rules compile', false, err.message); +} + +if (RULES) { + check('rules compile', true); + + const techIds = Object.keys(RULES.techs); + check('tech count sane (80+ incl future tech)', techIds.length >= 80, `${techIds.length}`); + check('future tech present & repeatable', RULES.techs.futuretech?.repeatable === true); + + // Cut techs must NOT be present (wonders/happiness/espionage systems removed). + for (const cut of ['theology', 'espionage', 'fundamentalism', 'environmentalism', + 'geneticengineering', 'recycling']) { + check(`cut tech absent: ${cut}`, !RULES.techs[cut]); + } + + // Every tech is reachable (compileRules ranks all) and matters: it gates a + // unit/building/government/improvement or is a prereq of another tech. + for (const t of RULES.techList) { + check(`tech ranked: ${t.id}`, RULES.techRank[t.id] !== undefined); + const g = RULES.techGates[t.id]; + const matters = g.units.length + g.buildings.length + g.governments.length + + g.improvements.length + g.prereqOf.length > 0 || t.repeatable; + check(`tech matters: ${t.id}`, matters); + } + // Rank must respect prereqs. + for (const t of RULES.techList) { + for (const p of t.prereqs) { + check(`rank order ${p} < ${t.id}`, RULES.techRank[p] < RULES.techRank[t.id]); + } + } + + // Roots: the classic 8 starting techs. + const roots = RULES.techList.filter((t) => t.prereqs.length === 0).map((t) => t.id).sort(); + check('8 root techs', roots.length === 8, roots.join(',')); + + // Units. + check('unit count 51', RULES.unitList.length === 51, `${RULES.unitList.length}`); + const unitFrames = new Set(); + for (const u of RULES.unitList) { + check(`unit stats sane: ${u.id}`, u.attack >= 0 && u.attack <= 99 && u.defense >= 0 + && u.move >= 0 && u.hp >= 1 && u.fp >= 1 && u.cost >= 10 && u.cost <= 320); + check(`unit frame unique: ${u.id}`, !unitFrames.has(u.frame), `${u.frame}`); + unitFrames.add(u.frame); + check(`unit frame in sheet: ${u.id}`, u.frame >= 0 && u.frame < 56); + check(`unit abbr: ${u.id}`, typeof u.abbr === 'string' && u.abbr.length === 2); + if (u.domain === 'project') check(`ss unit flagged: ${u.id}`, u.flags.includes('spaceship')); + } + const ssUnits = RULES.unitList.filter((u) => u.domain === 'project'); + check('3 spaceship parts', ssUnits.length === 3); + check('spaceship config', RULES.spaceship.structuralNeeded === 8 + && RULES.spaceship.componentsNeeded === 4 && RULES.spaceship.modulesNeeded === 3 + && RULES.spaceship.travelTurns > 0); + + // Terrain + specials. + check('11 terrains', RULES.terrainList.length === 11); + check('exactly one water terrain', RULES.terrainList.filter((t) => t.water).length === 1); + const terrFrames = new Set(); + for (const t of RULES.terrainList) { + check(`terrain frame unique: ${t.id}`, !terrFrames.has(t.frame)); + terrFrames.add(t.frame); + check(`terrain frame in sheet: ${t.id}`, t.frame >= 0 && t.frame < 12); + check(`terrain yields sane: ${t.id}`, t.food >= 0 && t.shield >= 0 && t.trade >= 0 + && t.move >= 1 && t.defense >= 1); + check(`terrain color: ${t.id}`, /^#[0-9a-f]{6}$/i.test(t.color)); + } + check('grassland shield frame distinct', !terrFrames.has(RULES.grasslandShieldFrame) + && RULES.grasslandShieldFrame >= 0 && RULES.grasslandShieldFrame < 12); + check('20 specials', RULES.specialList.length === 20); + const specFrames = new Set(); + for (const s of RULES.specialList) { + check(`special frame unique: ${s.id}`, !specFrames.has(s.frame)); + specFrames.add(s.frame); + check(`special frame in sheet: ${s.id}`, s.frame >= 0 && s.frame < 20); + } + const specTerrains = Object.keys(RULES.specialsByTerrain); + check('specials cover 10 terrains (all but grassland)', specTerrains.length === 10 + && !specTerrains.includes('grassland')); + for (const terr of specTerrains) { + check(`2 specials on ${terr}`, RULES.specialsByTerrain[terr].length === 2); + } + + // Buildings. + check('26 buildings', RULES.buildingList.length === 26, `${RULES.buildingList.length}`); + for (const cut of ['temple', 'colosseum', 'cathedral', 'policestation', 'masstransit', + 'recyclingcenter', 'solarplant']) { + check(`cut building absent: ${cut}`, !RULES.buildings[cut]); + } + const powerPlants = RULES.buildingList.filter((b) => b.effect === 'power'); + check('3 mutually exclusive power plants', powerPlants.length === 3); + + // Governments & difficulties. + check('6 governments', RULES.governmentList.length === 6); + check('despotism/anarchy need no tech', !RULES.governments.despotism.prereq + && !RULES.governments.anarchy.prereq); + check('5 difficulties', RULES.difficultyList.length === 5); + check('difficulty ordering', RULES.difficultyList[0].aiProdBonus + < RULES.difficultyList[4].aiProdBonus); + + // 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); + check('8 player colors', RULES.playerColors.length === 8 + && RULES.playerColors.every((c) => /^#[0-9a-f]{6}$/i.test(c))); + check('city name pool 64', RULES.cityNames.length === 64 + && new Set(RULES.cityNames).size === 64); + + // Tech cost + year curve helpers. + check('tech cost grows', techCost(0) < techCost(10) && techCost(10) < techCost(50)); + check('tech cost difficulty factor', techCost(10, 1.2) > techCost(10, 1.0)); + check('year starts 4000 BC', turnToYear(0, RULES.yearCurve) === -4000); + const y60 = turnToYear(60, RULES.yearCurve); + check('year curve reaches ~1000 BC by turn 60', y60 === -1000, `${y60}`); + let prev = -4000; + let monotonic = true; + for (let i = 1; i <= 500; i += 1) { + const y = turnToYear(i, RULES.yearCurve); + if (y <= prev) { monotonic = false; break; } + prev = y; + } + check('year curve strictly increasing over 500 turns', monotonic); + + // Artwork JSON contract <-> assetManifest fields (checked once artwork file exists). + try { + const art = JSON.parse(readFileSync(join(root, 'data/civilization-artwork.json'), 'utf8')); + for (const field of ['terrainSheet', 'resourceSheet', 'improvementSheet', 'unitSheet', 'iconSheet']) { + check(`artwork field ${field}`, !!art[field] && 'path' in art[field] + && art[field].frameWidth > 0 && art[field].frameHeight > 0); + } + check('artwork citySheets map', !!art.citySheets && !!art.citySheets.classic + && 'path' in art.citySheets.classic); + } catch { + console.log(' (data/civilization-artwork.json not present yet — skipping contract checks)'); + } +} + +// --------------------------------------------------------------------------- +section('2. world generation'); + +if (RULES) { + const hashWorld = (w) => JSON.stringify([w.terrain, w.special, w.huts, w.starts]); + for (const sizeId of ['small', 'medium', 'large']) { + const size = RULES.worldSizes[sizeId]; + let landOk = 0; + let contOk = 0; + let startsOk = 0; + let detOk = 0; + let qualityOk = 0; + const seeds = QUICK ? 6 : 20; + for (let s = 1; s <= seeds; s += 1) { + const numCivs = 3 + (s % 5); // 3..7 + const w = generateWorld(RULES, { sizeId, seed: s * 31, numCivs }); + const w2 = generateWorld(RULES, { sizeId, seed: s * 31, numCivs }); + if (hashWorld(w) === hashWorld(w2)) detOk += 1; + if (w.landFraction >= 0.25 && w.landFraction <= 0.38) landOk += 1; + if (w.largestContinentFrac >= 0.15) contOk += 1; + const T = {}; + RULES.terrainList.forEach((t, i) => { T[t.id] = i; }); + let good = w.starts.length === numCivs; + for (let a = 0; a < w.starts.length && good; a += 1) { + const [x, y] = w.starts[a]; + const terr = RULES.terrainList[w.terrain[y * size.cols + x]]; + if (terr.water || terr.id === 'glacier' || terr.id === 'mountains') good = false; + for (let b = a + 1; b < w.starts.length; b += 1) { + const [x2, y2] = w.starts[b]; + if (Math.max(Math.abs(x - x2), Math.abs(y - y2)) < 4) good = false; + } + } + if (good) startsOk += 1; + const minQ = Math.min(...w.starts.map(([x, y]) => siteQuality(RULES, w, x, y))); + if (minQ >= 12) qualityOk += 1; + } + check(`${sizeId}: deterministic (same seed => same world)`, detOk === seeds, `${detOk}/${seeds}`); + check(`${sizeId}: land fraction 25-38%`, landOk === seeds, `${landOk}/${seeds}`); + check(`${sizeId}: largest continent >= 15% of land`, contOk === seeds, `${contOk}/${seeds}`); + check(`${sizeId}: starts valid & spaced`, startsOk === seeds, `${startsOk}/${seeds}`); + check(`${sizeId}: start quality floor`, qualityOk === seeds, `${qualityOk}/${seeds}`); + } + + // Density checks on one representative map. + const w = generateWorld(RULES, { sizeId: 'medium', seed: 42, numCivs: 5 }); + const land = w.terrain.filter((t) => !RULES.terrainList[t].water).length; + const hutCount = w.huts.reduce((a, b) => a + b, 0); + check('hut density ~1/40 land', hutCount >= Math.floor(land / 40) * 0.6 + && hutCount <= Math.ceil(land / 40), `${hutCount} huts, ${land} land`); + const specCount = w.special.filter((s) => s >= 0).length; + check('specials density 1/64..1/8 of tiles', specCount >= w.terrain.length / 64 + && specCount <= w.terrain.length / 8, `${specCount}`); + let specMatch = true; + for (let i = 0; i < w.terrain.length; i += 1) { + if (w.special[i] < 0) continue; + const spec = RULES.specialList[w.special[i]]; + if (spec.terrain !== RULES.terrainList[w.terrain[i]].id) { specMatch = false; break; } + } + check('every special sits on its terrain', specMatch); + let hutsOnLand = true; + for (let i = 0; i < w.terrain.length; i += 1) { + if (w.huts[i] && (RULES.terrainList[w.terrain[i]].water + || RULES.terrainList[w.terrain[i]].id === 'glacier')) hutsOnLand = false; + } + check('huts on land (not glacier)', hutsOnLand); + check('shield-grass lattice ~50%', (() => { + let c = 0; + for (let y = 0; y < 20; y += 1) for (let x = 0; x < 20; x += 1) c += shieldGrassAt(x, y) ? 1 : 0; + return c === 200; + })()); +} + +// --------------------------------------------------------------------------- +// Fixture helpers: hand-built flat worlds for deterministic engine tests. + +function T(id) { return RULES.terrainList.findIndex((t) => t.id === id); } + +function mkCiv(i, total, human = false) { + const relations = {}; + const attitude = {}; + for (let j = 0; j < total; j += 1) if (j !== i) { relations[j] = 'contact'; attitude[j] = 0; } + return { + id: i, leaderId: `test${i}`, name: `Civ${i}`, color: '#ffffff', human, + alive: true, government: 'despotism', revolutionTurns: 0, pendingGovernment: null, + gold: 100, beakers: 0, researching: null, known: {}, futureCount: 0, + relations, attitude, reputation: 0, + spaceship: { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 }, + nameCursor: i, nameOrder: Array.from({ length: RULES.cityNames.length }, (_, k) => k), + score: 0, + }; +} + +function makeFlatState({ cols = 16, rows = 16, civs = 2, terrain = 'grassland' } = {}) { + const n = cols * rows; + const world = { + cols, rows, + terrain: new Array(n).fill(T(terrain)), + special: new Array(n).fill(-1), + improvements: new Array(n).fill(0), + huts: new Array(n).fill(0), + continent: new Array(n).fill(0), + starts: [], landFraction: 1, largestContinentFrac: 1, sizeId: 'small', seed: 1, + }; + const state = { + version: 1, seed: 1, rngState: 987654321, sizeId: 'small', difficultyId: 'prince', + turn: 1, current: 0, humanIndex: 0, world, + civs: Array.from({ length: civs }, (_, i) => mkCiv(i, civs, i === 0)), + cities: [], units: [], nextUnitId: 1, nextCityId: 1, + explored: Array.from({ length: civs }, () => new Array(n).fill(1)), + over: null, events: [], + }; + return state; +} + +function setWar(state, a, b) { + state.civs[a].relations[b] = 'war'; + state.civs[b].relations[a] = 'war'; +} + +// --------------------------------------------------------------------------- +section('3. city fixtures'); + +if (RULES) { + // Founding: settler consumed, size 1, first city gets palace + leader name. + { + const st = makeFlatState(); + const settler = Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null); + const city = Logic.foundCity(RULES, st, settler); + check('city founded', !!city && city.size === 1); + check('settler consumed', st.units.length === 0); + check('first city has palace', !!city.buildings.palace); + check('capital named for leader', city.name === 'Civ0 City'); + check('min distance blocks adjacent city', !Logic.canFoundCity(RULES, st, 6, 5)); + check('distance 2 allowed', Logic.canFoundCity(RULES, st, 7, 5)); + } + + // Growth on flat grassland: foodbox fills, granary keeps half. + { + const st = makeFlatState(); + const settler = Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null); + const city = Logic.foundCity(RULES, st, settler); + city.build = { type: 'building', id: 'granary' }; + let grewAt = -1; + for (let t = 0; t < 30 && grewAt < 0; t += 1) { + Logic.beginCivTurn(RULES, st, 0); + if (city.size >= 2) grewAt = t; + } + check('city grows on grassland within 30 turns', grewAt >= 0); + check('foodbox reset after growth', city.foodBox < (city.size + 1) * Logic.FOODBOX_PER_SIZE); + // Force a granary growth and check the half-box carryover. + city.buildings.granary = true; + city.foodBox = (city.size + 1) * Logic.FOODBOX_PER_SIZE; + const sizeBefore = city.size; + Logic.beginCivTurn(RULES, st, 0); + check('granary growth', city.size === sizeBefore + 1); + check('granary keeps half box', city.foodBox >= Math.floor(((sizeBefore + 1) * Logic.FOODBOX_PER_SIZE) / 2)); + } + + // Despotism penalty & government trade bonus on tile yields. + { + const st = makeFlatState({ terrain: 'plains' }); + const i = 5 * 16 + 5; + st.world.special[i] = RULES.specialList.findIndex((s) => s.id === 'wheat'); // 3/1/0 + let y = Logic.tileYield(RULES, st, 0, null, 5, 5); + check('despotism -1 on 3-food wheat', y.food === 2, `${y.food}`); + st.civs[0].government = 'monarchy'; + y = Logic.tileYield(RULES, st, 0, null, 5, 5); + check('monarchy full wheat', y.food === 3); + st.civs[0].government = 'republic'; + const j = 6 * 16 + 6; + st.world.improvements[j] = 1; // road on plains: +1 trade, republic: +1 more + y = Logic.tileYield(RULES, st, 0, null, 6, 6); + check('road + republic trade', y.trade === 2, `${y.trade}`); + st.civs[0].government = 'despotism'; + y = Logic.tileYield(RULES, st, 0, null, 6, 6); + check('road under despotism trade 1', y.trade === 1, `${y.trade}`); + } + + // Irrigation / mine / railroad effects. + { + const st = makeFlatState({ terrain: 'hills' }); + st.civs[0].government = 'monarchy'; + const i = 5 * 16 + 5; + st.world.improvements[i] = 16; // mine on hills: +3 shields + let y = Logic.tileYield(RULES, st, 0, null, 5, 5); + check('hills mine +3 shields', y.shield === 3, `${y.shield}`); + st.world.improvements[i] |= 2; // railroad: +1 when shields >= 1 + y = Logic.tileYield(RULES, st, 0, null, 5, 5); + check('railroad +1 shield', y.shield === 4); + } + + // Corruption: distance, courthouse, democracy, communism-flat. + { + const st = makeFlatState({ cols: 32, rows: 8 }); + st.civs[0].government = 'monarchy'; + const cap = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 2, 4, null)); + const near = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 6, 4, null)); + const far = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 28, 4, null)); + near.size = 6; far.size = 6; + // Give both cities identical trade via routes so corruption math is isolated. + near.routes = [{ cityId: cap.id, amount: 20 }]; + far.routes = [{ cityId: cap.id, amount: 20 }]; + const yNear = Logic.cityYields(RULES, st, near); + const yFar = Logic.cityYields(RULES, st, far); + check('corruption grows with distance', yFar.corruption > yNear.corruption, + `${yNear.corruption} vs ${yFar.corruption}`); + far.buildings.courthouse = true; + const yFarCourt = Logic.cityYields(RULES, st, far); + check('courthouse halves corruption', yFarCourt.corruption === Math.floor(yFar.corruption / 2)); + delete far.buildings.courthouse; + st.civs[0].government = 'democracy'; + check('democracy zero corruption', Logic.cityYields(RULES, st, far).corruption === 0); + st.civs[0].government = 'communism'; + const cNear = Logic.cityYields(RULES, st, near).corruption; + const cFar = Logic.cityYields(RULES, st, far).corruption; + check('communism flat corruption', cNear === cFar, `${cNear} vs ${cFar}`); + } + + // Auto-assign is optimal for the linear emphasis objective. + { + const st = makeFlatState(); + // Scatter mixed terrain around a city site. + const kinds = ['plains', 'forest', 'hills', 'mountains', 'desert', 'ocean', 'swamp']; + let k = 0; + for (let y = 3; y <= 7; y += 1) { + for (let x = 3; x <= 7; x += 1) { + st.world.terrain[y * 16 + x] = T(kinds[k % kinds.length]); + k += 3; + } + } + st.world.terrain[5 * 16 + 5] = T('grassland'); + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); + city.size = 5; + for (const emphasis of ['balanced', 'food', 'production', 'trade']) { + city.emphasis = emphasis; + Logic.autoAssignTiles(RULES, st, city); + const weights = { balanced: [3, 2, 1], food: [6, 1, 1], production: [1, 5, 1], trade: [1, 1, 5] }[emphasis]; + const wOf = (idx) => { + const yld = Logic.tileYield(RULES, st, 0, city, idx % 16, (idx / 16) | 0); + return yld.food * weights[0] + yld.shield * weights[1] + yld.trade * weights[2]; + }; + const chosen = city.worked.reduce((s, idx) => s + wOf(idx), 0); + // Brute force: weights of every candidate tile, top-5 sum. + const cand = []; + for (const [dx, dy] of Logic.CITY_RADIUS) { + if (dx === 0 && dy === 0) continue; + cand.push(wOf((5 + dy) * 16 + (5 + dx))); + } + cand.sort((a, b) => b - a); + const best = cand.slice(0, 5).reduce((a, b) => a + b, 0); + check(`auto-assign optimal (${emphasis})`, chosen === best, `${chosen} vs ${best}`); + } + } + + // Buy math + class-switch penalty + building multipliers. + { + const st = makeFlatState(); + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); + city.build = { type: 'unit', id: 'warriors' }; + check('warriors buy cost 25', Logic.buyCost(RULES, city) === 25); + Logic.setBuild(RULES, st, city, 'building', 'granary'); + check('granary buy cost 120', Logic.buyCost(RULES, city) === 120); + city.shieldBox = 40; + check('partial buy cost', Logic.buyCost(RULES, city) === 40); // (60-40)*2 + Logic.setBuild(RULES, st, city, 'unit', 'warriors'); + check('class switch halves shields', city.shieldBox === 20); + st.civs[0].gold = 200; + city.build = { type: 'building', id: 'granary' }; + city.shieldBox = 0; + check('buy succeeds', Logic.buyBuild(RULES, st, city) && st.civs[0].gold === 80); + check('no double buy same turn', !Logic.buyBuild(RULES, st, city)); + // Science multiplier: library +50%. + city.size = 4; + city.routes = [{ cityId: 99, amount: 12 }]; + st.civs[0].government = 'monarchy'; + const sBefore = Logic.cityYields(RULES, st, city).science; + city.buildings.library = true; + const sAfter = Logic.cityYields(RULES, st, city).science; + check('library +50% science', sAfter === Math.floor(sBefore * 1.5), `${sBefore}->${sAfter}`); + } + + // Size caps: 8 without aqueduct, 12 without sewer. + { + const st = makeFlatState(); + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); + city.size = 8; + city.foodBox = (city.size + 1) * Logic.FOODBOX_PER_SIZE + 5; + Logic.beginCivTurn(RULES, st, 0); + check('size capped at 8 without aqueduct', city.size === 8); + city.buildings.aqueduct = true; + city.foodBox = (city.size + 1) * Logic.FOODBOX_PER_SIZE; + Logic.beginCivTurn(RULES, st, 0); + check('aqueduct unlocks growth', city.size === 9); + } + + // Settler build waits for size 2 and costs a citizen. + { + const st = makeFlatState(); + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); + city.build = { type: 'unit', id: 'settlers' }; + city.shieldBox = 200; + Logic.beginCivTurn(RULES, st, 0); + check('settler waits at size 1', st.units.length === 0 && city.size === 1); + city.size = 3; + Logic.beginCivTurn(RULES, st, 0); + check('settler built at size>=2 costs pop', st.units.length === 1 && city.size === 2); + } + + // Research: cost, completion, prereq gating. + { + const st = makeFlatState(); + const civ = st.civs[0]; + check('cannot research gated tech', !Logic.setResearch(RULES, st, civ, 'monarchy')); + check('can research root tech', Logic.setResearch(RULES, st, civ, 'alphabet')); + civ.beakers = 1000; + Logic.beginCivTurn(RULES, st, 0); + check('tech completes', !!civ.known.alphabet); + civ.known.codeoflaws = true; + civ.known.ceremonialburial = true; + check('monarchy now available', Logic.setResearch(RULES, st, civ, 'monarchy')); + const cost3 = Logic.currentResearchCost(RULES, st, civ); + civ.known.pottery = true; + check('cost rises with known count', Logic.currentResearchCost(RULES, st, civ) > cost3); + } + + // Revolution: anarchy interlude then target government. + { + const st = makeFlatState(); + const civ = st.civs[0]; + civ.known.monarchy = true; + check('revolution starts', Logic.startRevolution(RULES, st, civ, 'monarchy')); + check('in anarchy', civ.government === 'anarchy'); + for (let i = 0; i < 6; i += 1) Logic.beginCivTurn(RULES, st, 0); + check('revolution completes', civ.government === 'monarchy'); + check('cannot switch to unknown gov', !Logic.startRevolution(RULES, st, civ, 'democracy')); + } +} + +// --------------------------------------------------------------------------- +section('4. combat'); + +if (RULES) { + // Deterministic strength modifiers. + { + const st = makeFlatState({ terrain: 'mountains' }); + setWar(st, 0, 1); + st.world.terrain[5 * 16 + 4] = T('grassland'); + const attacker = Logic.spawnUnit(RULES, st, 0, 'warriors', 4, 5, null); + const defender = Logic.spawnUnit(RULES, st, 1, 'phalanx', 5, 5, null); + let d = Logic.defenderStrength(RULES, st, defender, attacker); + check('mountain phalanx D=6', Math.abs(d - 6) < 1e-9, `${d}`); + defender.fortified = true; + d = Logic.defenderStrength(RULES, st, defender, attacker); + check('fortified adds x1.5', Math.abs(d - 9) < 1e-9, `${d}`); + } + { + // Walls x3 vs land; howitzer ignores them. + const st = makeFlatState(); + setWar(st, 0, 1); + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 1, 'settlers', 8, 8, null)); + city.buildings.citywalls = true; + const defender = Logic.spawnUnit(RULES, st, 1, 'musketeers', 8, 8, null); + const rifle = Logic.spawnUnit(RULES, st, 0, 'riflemen', 7, 8, null); + const how = Logic.spawnUnit(RULES, st, 0, 'howitzer', 7, 8, null); + const dWalls = Logic.defenderStrength(RULES, st, defender, rifle); + const dHow = Logic.defenderStrength(RULES, st, defender, how); + check('walls triple defense vs land', Math.abs(dWalls - 9) < 1e-9, `${dWalls}`); + check('howitzer ignores walls (city base applies)', Math.abs(dHow - 4.5) < 1e-9, `${dHow}`); + } + { + // Pikemen double vs mounted only. + const st = makeFlatState(); + setWar(st, 0, 1); + const pike = Logic.spawnUnit(RULES, st, 1, 'pikemen', 5, 5, null); + const knight = Logic.spawnUnit(RULES, st, 0, 'knights', 4, 5, null); + const legion = Logic.spawnUnit(RULES, st, 0, 'legion', 4, 5, null); + const dVsKnight = Logic.defenderStrength(RULES, st, pike, knight); + const dVsLegion = Logic.defenderStrength(RULES, st, pike, legion); + check('pikemen x2 vs mounted', Math.abs(dVsKnight - 2 * dVsLegion) < 1e-9); + } + { + // Veteran and hp scaling on attack. + const st = makeFlatState(); + const u = Logic.spawnUnit(RULES, st, 0, 'legion', 5, 5, null); + const a0 = Logic.attackerStrength(RULES, st, u); + u.vet = true; + check('vet x1.5 attack', Math.abs(Logic.attackerStrength(RULES, st, u) - a0 * 1.5) < 1e-9); + u.hp = 5; + check('hp halves attack', Math.abs(Logic.attackerStrength(RULES, st, u) - a0 * 1.5 * 0.5) < 1e-9); + } + + // Monte Carlo: duel win rates track the analytic single-round model. + { + const st = makeFlatState(); + const trials = QUICK ? 2000 : 10000; + // Equal units: expect ~50%. + let wins = 0; + for (let i = 0; i < trials; i += 1) { + if (Logic.simulateDuel(st, 4, 20, 1, 4, 20, 1, 20).attackerWon) wins += 1; + } + const even = wins / trials; + check('equal duel ~50%', even > 0.46 && even < 0.54, `${even.toFixed(3)}`); + // 2:1 attacker: p(round)=2/3; 10-round-to-kill race strongly favours attacker. + wins = 0; + for (let i = 0; i < trials; i += 1) { + if (Logic.simulateDuel(st, 8, 20, 1, 4, 20, 1, 20).attackerWon) wins += 1; + } + const strong = wins / trials; + check('2:1 duel > 85%', strong > 0.85, `${strong.toFixed(3)}`); + // Higher firepower shortens the race for the attacker. + wins = 0; + for (let i = 0; i < trials; i += 1) { + if (Logic.simulateDuel(st, 4, 20, 2, 4, 20, 1, 20).attackerWon) wins += 1; + } + const fp = wins / trials; + check('fp advantage wins > 65%', fp > 0.65, `${fp.toFixed(3)}`); + check('duel ordering sane', strong > fp && fp > even); + } + + // Stack death outside cities/fortresses, survival inside. + { + const st = makeFlatState(); + setWar(st, 0, 1); + Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); + Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); + Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); + const tank = Logic.spawnUnit(RULES, st, 0, 'armor', 4, 5, null); + tank.vet = true; + let out = { result: '' }; + for (let i = 0; i < 10 && Logic.unitsAt(st, 5, 5).length; i += 1) { + tank.mp = 9; tank.hp = 30; + out = Logic.resolveAttack(RULES, st, tank, 5, 5); + if (out.won) break; + } + check('stack dies on open ground', out.won === true && Logic.unitsAt(st, 5, 5).length === 0); + + const st2 = makeFlatState(); + setWar(st2, 0, 1); + const city = Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 5, 5, null)); + Logic.spawnUnit(RULES, st2, 1, 'warriors', 5, 5, null); + Logic.spawnUnit(RULES, st2, 1, 'warriors', 5, 5, null); + const tank2 = Logic.spawnUnit(RULES, st2, 0, 'armor', 4, 5, null); + tank2.vet = true; + let won2 = false; + for (let i = 0; i < 10 && !won2; i += 1) { + tank2.mp = 9; tank2.hp = 30; + const o = Logic.resolveAttack(RULES, st2, tank2, 5, 5); + won2 = o.won === true; + } + check('city stack loses only defender', won2 && Logic.unitsAt(st2, 5, 5).length === 1, + `${Logic.unitsAt(st2, 5, 5).length} left, city ${!!city}`); + } + + // City capture: pop loss, loot, palace relocation, elimination. + { + const st = makeFlatState(); + setWar(st, 0, 1); + const cityA = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 1, 'settlers', 5, 5, null)); + const cityB = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 1, 'settlers', 10, 10, null)); + cityA.size = 4; + st.civs[1].gold = 200; + const tank = Logic.spawnUnit(RULES, st, 0, 'armor', 4, 5, null); + const out = Logic.tryMove(RULES, st, tank, 1, 0); + check('undefended city captured', out.result === 'captured'); + check('captured city loses a pop', cityA.size === 3); + check('capturer moves in', tank.x === 5 && tank.y === 5); + check('loot transferred', st.civs[0].gold > 100); + check('palace relocates', !!cityB.buildings.palace && !cityA.buildings.palace); + check('civ still alive with one city', st.civs[1].alive); + const tank2 = Logic.spawnUnit(RULES, st, 0, 'armor', 9, 10, null); + Logic.tryMove(RULES, st, tank2, 1, 0); + check('last city falls => civ eliminated', !st.civs[1].alive); + check('conquest victory declared', st.over?.type === 'conquest' && st.over.winner === 0); + } + + // Attacks require war; nukes and SDI. + { + const st = makeFlatState({ civs: 3 }); + const u0 = Logic.spawnUnit(RULES, st, 0, 'legion', 4, 5, null); + Logic.spawnUnit(RULES, st, 1, 'legion', 5, 5, null); + const blocked = Logic.tryMove(RULES, st, u0, 1, 0); + check('attack blocked without war', blocked.result === 'blocked' && blocked.needsWar === 1); + + setWar(st, 0, 1); + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 1, 'settlers', 10, 5, null)); + city.size = 8; + Logic.spawnUnit(RULES, st, 1, 'riflemen', 10, 5, null); + Logic.spawnUnit(RULES, st, 1, 'riflemen', 10, 5, null); + const nuke = Logic.spawnUnit(RULES, st, 0, 'nuclearmsl', 9, 5, null); + const out = Logic.resolveAttack(RULES, st, nuke, 10, 5); + check('nuke clears stack', out.result === 'nuked' && Logic.unitsAt(st, 10, 5).length === 0); + check('nuke halves city pop', city.size === 4); + check('nuke consumed', !st.units.some((u) => u.type === 'nuclearmsl')); + + city.buildings.sdidefense = true; + Logic.spawnUnit(RULES, st, 1, 'riflemen', 10, 5, null); + const nuke2 = Logic.spawnUnit(RULES, st, 0, 'nuclearmsl', 9, 5, null); + const out2 = Logic.resolveAttack(RULES, st, nuke2, 10, 5); + check('SDI blocks nuke', out2.result === 'nukeBlocked' + && Logic.unitsAt(st, 10, 5).length === 1 && city.size === 4); + } + + // Missiles are consumed even on a won conventional attack. + { + const st = makeFlatState(); + setWar(st, 0, 1); + Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); + const cm = Logic.spawnUnit(RULES, st, 0, 'cruisemsl', 4, 5, null); + const out = Logic.resolveAttack(RULES, st, cm, 5, 5); + check('cruise missile consumed', !st.units.some((u) => u.type === 'cruisemsl'), out.result); + } + + // Movement: roads, rails, boarding, disembark, trireme coast rule. + { + const st = makeFlatState(); + const u = Logic.spawnUnit(RULES, st, 0, 'warriors', 5, 5, null); + check('grass step costs full move', (() => { Logic.tryMove(RULES, st, u, 1, 0); return u.mp === 0; })()); + const i1 = 5 * 16 + 6; + const i2 = 5 * 16 + 7; + st.world.improvements[i1] = 1; st.world.improvements[i2] = 1; + u.mp = 3; + Logic.tryMove(RULES, st, u, 1, 0); + check('road step costs 1/3', u.mp === 2, `${u.mp}`); + st.world.improvements[i2] |= 2; + const i3 = 5 * 16 + 8; + st.world.improvements[i3] = 3; + Logic.tryMove(RULES, st, u, 1, 0); + check('rail step free', u.mp === 2, `${u.mp}`); + + // Boarding & disembark. + const st2 = makeFlatState(); + for (let y = 0; y < 16; y += 1) st2.world.terrain[y * 16 + 8] = T('ocean'); + const boat = Logic.spawnUnit(RULES, st2, 0, 'transport', 8, 5, null); + const inf = Logic.spawnUnit(RULES, st2, 0, 'riflemen', 7, 5, null); + const bOut = Logic.tryMove(RULES, st2, inf, 1, 0); + check('boards transport', bOut.result === 'boarded' && inf.carriedBy === boat.id); + boat.mp = 15; + Logic.tryMove(RULES, st2, boat, 0, 1); + check('cargo rides along', inf.x === 8 && inf.y === 6); + inf.mp = 3; + const dOut = Logic.disembark(RULES, st2, inf, 1, 0); + check('disembarks ashore', dOut.result === 'moved' && inf.carriedBy === null && inf.x === 9); + + // Trireme must hug the coast. + const st3 = makeFlatState(); + for (let y = 0; y < 16; y += 1) { + for (let x = 6; x < 16; x += 1) st3.world.terrain[y * 16 + x] = T('ocean'); + } + const tri = Logic.spawnUnit(RULES, st3, 0, 'trireme', 6, 5, null); + check('trireme coast tile ok', Logic.canOccupy(RULES, st3, tri, 6, 8)); + check('trireme open sea blocked', !Logic.canOccupy(RULES, st3, tri, 12, 8)); + const dd = Logic.spawnUnit(RULES, st3, 0, 'destroyer', 8, 5, null); + check('destroyer open sea ok', Logic.canOccupy(RULES, st3, dd, 12, 8)); + check('land unit cannot walk on water', !Logic.canOccupy(RULES, st3, + Logic.spawnUnit(RULES, st3, 0, 'warriors', 3, 3, null), 8, 8)); + } + + // Pathfinding: prefers roads, avoids blocked tiles, respects domains. + { + const st = makeFlatState(); + for (let x = 3; x <= 12; x += 1) st.world.improvements[7 * 16 + x] |= 1; + const u = Logic.spawnUnit(RULES, st, 0, 'warriors', 3, 7, null); + const path = Logic.findPath(RULES, st, u, 12, 7); + check('path found', !!path && path.length === 9); + check('path follows road', path.every(([, y]) => y === 7)); + const sea = Logic.findPath(RULES, st, u, 12, 7); + check('path deterministic', JSON.stringify(sea) === JSON.stringify(path)); + } + + // Air crash rule at end of turn. + { + const st = makeFlatState(); + Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); + const f1 = Logic.spawnUnit(RULES, st, 0, 'fighter', 5, 5, null); + const f2 = Logic.spawnUnit(RULES, st, 0, 'fighter', 10, 10, null); + Logic.endCivTurn(RULES, st, 0); + check('fighter in city survives', st.units.includes(f1)); + check('fighter in the field crashes', !st.units.includes(f2)); + } + + // Work orders: road, irrigation water rule, mine/irrigation exclusivity, transform. + { + const st = makeFlatState(); + const eng = Logic.spawnUnit(RULES, st, 0, 'engineers', 5, 5, null); + st.civs[0].known.explosives = true; + check('can start road', Logic.startWork(RULES, st, eng, 'road')); + Logic.beginCivTurn(RULES, st, 0); // 2 work points (engineer) = road done + const idx = 5 * 16 + 5; + check('road built', (st.world.improvements[idx] & 1) === 1); + check('irrigation needs water', !Logic.canWork(RULES, st, eng, 'irrigation')); + st.world.terrain[5 * 16 + 6] = T('ocean'); + check('irrigation ok next to ocean', Logic.canWork(RULES, st, eng, 'irrigation')); + Logic.startWork(RULES, st, eng, 'irrigation'); + Logic.beginCivTurn(RULES, st, 0); + Logic.beginCivTurn(RULES, st, 0); + check('irrigation built', (st.world.improvements[idx] & 4) === 4); + // Mining hills clears irrigation. + const st2 = makeFlatState({ terrain: 'hills' }); + const eng2 = Logic.spawnUnit(RULES, st2, 0, 'engineers', 5, 5, null); + st2.world.improvements[idx] = 4; + Logic.startWork(RULES, st2, eng2, 'mine'); + Logic.beginCivTurn(RULES, st2, 0); + Logic.beginCivTurn(RULES, st2, 0); + check('mine replaces irrigation', (st2.world.improvements[idx] & (16 | 4)) === 16); + // Transform swamp -> grassland (engineer only). + const st3 = makeFlatState({ terrain: 'swamp' }); + st3.civs[0].known.explosives = true; + const sett = Logic.spawnUnit(RULES, st3, 0, 'settlers', 4, 4, null); + check('settler cannot transform', !Logic.canWork(RULES, st3, sett, 'transform')); + const eng3 = Logic.spawnUnit(RULES, st3, 0, 'engineers', 5, 5, null); + Logic.startWork(RULES, st3, eng3, 'transform'); + for (let i = 0; i < 5; i += 1) Logic.beginCivTurn(RULES, st3, 0); + check('swamp transformed to grassland', st3.world.terrain[idx] === T('grassland')); + } + + // Huts: all outcomes reachable, ambush can kill. + { + const st = makeFlatState(); + const seen = new Set(); + for (let i = 0; i < 200; i += 1) { + st.world.huts[5 * 16 + 6] = 1; + const u = Logic.spawnUnit(RULES, st, 0, 'legion', 5, 5, null); + const out = Logic.tryMove(RULES, st, u, 1, 0); + if (out.hut) seen.add(out.hut.outcome); + for (const un of [...st.units]) Logic.removeUnit(st, un); + } + check('hut outcomes cover gold/tech/unit/ambush', seen.has('gold') && seen.has('tech') + && seen.has('unit') && (seen.has('ambushWon') || seen.has('ambushLost')), [...seen].join(',')); + } +} + +// --------------------------------------------------------------------------- +section('6. trade & diplomacy'); + +if (RULES) { + // Trade routes: distance gate, bonus math, max-3 replacement. + { + const st = makeFlatState({ cols: 32, rows: 8 }); + const home = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 2, 4, null)); + const near = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 6, 4, null)); + const far = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 20, 4, null)); + const cv1 = Logic.spawnUnit(RULES, st, 0, 'caravan', 6, 4, home.id); + check('route needs 8+ distance', Logic.canEstablishRoute(RULES, st, cv1) === null); + const cv2 = Logic.spawnUnit(RULES, st, 0, 'caravan', 20, 4, home.id); + const goldBefore = st.civs[0].gold; + const out = Logic.establishTradeRoute(RULES, st, cv2); + check('route established', !!out && out.bonus > 0); + check('caravan consumed', !st.units.some((u) => u.type === 'caravan' && u.x === 20)); + check('bonus paid in gold+beakers', st.civs[0].gold === goldBefore + out.bonus + && st.civs[0].beakers >= out.bonus); + check('both cities got the route', home.routes.length === 1 && far.routes.length === 1 + && home.routes[0].cityId === far.id); + check('route trade feeds yields', Logic.cityYields(RULES, st, far).routeTrade === out.amount); + // Max 3: pile on routes, weakest is dropped. + home.routes = [{ cityId: 90, amount: 2 }, { cityId: 91, amount: 3 }, { cityId: 92, amount: 4 }]; + const cv3 = Logic.spawnUnit(RULES, st, 0, 'caravan', 20, 4, home.id); + Logic.establishTradeRoute(RULES, st, cv3); + check('max 3 routes, worst replaced', home.routes.length === 3 + && !home.routes.some((r) => r.cityId === 90)); + // Foreign routes pay double. + const st2 = makeFlatState({ cols: 32, rows: 8, civs: 2 }); + const h2 = Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 0, 'settlers', 2, 4, null)); + Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 20, 4, null)); + const cvF = Logic.spawnUnit(RULES, st2, 0, 'caravan', 20, 4, h2.id); + const outF = Logic.establishTradeRoute(RULES, st2, cvF); + check('foreign route pays roughly double', !!outF && outF.bonus >= out.bonus * 1.5, + `${out.bonus} vs ${outF?.bonus}`); + } + + // Diplomacy state machine: legal steps only. + { + const st = makeFlatState({ civs: 3 }); + check('contact->peace legal', Logic.canPropose(st, 0, 1, 'peace')); + check('contact->alliance illegal', !Logic.canPropose(st, 0, 1, 'alliance')); + check('contact->ceasefire illegal', !Logic.canPropose(st, 0, 1, 'ceasefire')); + check('apply peace', Logic.applyTreaty(st, 0, 1, 'peace') + && st.civs[1].relations[0] === 'peace'); + check('peace->alliance legal', Logic.applyTreaty(st, 0, 1, 'alliance')); + check('sneak attack scars reputation', (() => { + const rep = st.civs[0].reputation; + Logic.declareWar(RULES, st, 0, 1); + return st.civs[0].reputation < rep && st.civs[0].relations[1] === 'war'; + })()); + check('war->peace illegal (need ceasefire)', !Logic.canPropose(st, 0, 1, 'peace')); + check('war->ceasefire->peace', Logic.applyTreaty(st, 0, 1, 'ceasefire') + && Logic.applyTreaty(st, 0, 1, 'peace')); + check('third party attitude fell on sneak attack', st.civs[2].attitude[0] < 0); + check('cancel treaty back to contact', Logic.cancelTreaty(st, 0, 1) + && st.civs[0].relations[1] === 'contact'); + check('war on nocontact illegal', (() => { + st.civs[0].relations[2] = 'nocontact'; + return !Logic.declareWar(RULES, st, 0, 2); + })()); + } + + // Gifts, exchanges, tribute. + { + const st = makeFlatState({ civs: 2 }); + st.civs[0].gold = 100; + check('gift gold', Logic.giftGold(st, 0, 1, 50) && st.civs[1].gold === 150); + check('gift beyond means fails', !Logic.giftGold(st, 0, 1, 500)); + check('gift raises attitude', st.civs[1].attitude[0] > 0); + st.civs[0].known.alphabet = true; + st.civs[1].known.pottery = true; + check('tech exchange', Logic.exchangeTechs(RULES, st, 0, 1, 'alphabet', 'pottery') + && st.civs[0].known.pottery && st.civs[1].known.alphabet); + check('re-exchange fails', !Logic.exchangeTechs(RULES, st, 0, 1, 'alphabet', 'pottery')); + const paid = Logic.payTribute(st, 1, 0, 75); + check('tribute paid', paid === 75 && st.civs[1].attitude[0] < 0); + } + + // Fuzz: random diplomacy actions never reach an illegal state and + // attitudes stay bounded. + { + const st = makeFlatState({ civs: 4 }); + st.rngState = 424242; + const legalStates = new Set(['nocontact', 'contact', 'war', 'ceasefire', 'peace', 'alliance']); + let legal = true; + let bounded = true; + const actions = QUICK ? 300 : 1000; + for (let i = 0; i < actions; i += 1) { + const a = Logic.randInt(st, 4); + let b = Logic.randInt(st, 4); + if (a === b) b = (b + 1) % 4; + const roll = Logic.rand(st); + if (roll < 0.25) Logic.declareWar(RULES, st, a, b); + else if (roll < 0.5) { + const kinds = ['ceasefire', 'peace', 'alliance']; + Logic.applyTreaty(st, a, b, kinds[Logic.randInt(st, 3)]); + } else if (roll < 0.65) Logic.cancelTreaty(st, a, b); + else if (roll < 0.8) { st.civs[a].gold = 50; Logic.giftGold(st, a, b, 25); } + else Logic.updateAttitudes(RULES, st, a); + for (const civ of st.civs) { + for (const [other, rel] of Object.entries(civ.relations)) { + if (!legalStates.has(rel)) legal = false; + if (st.civs[other].relations[civ.id] !== rel) legal = false; // symmetry + } + for (const v of Object.values(civ.attitude)) { + if (v < -100 || v > 100 || Number.isNaN(v)) bounded = false; + } + } + if (!legal || !bounded) break; + } + check('fuzz: relations stay legal & symmetric', legal); + check('fuzz: attitudes bounded', bounded); + } + + // Attitude -> portrait mood mapping. + check('mood mapping', Logic.attitudeMood(-60) === 'upset' && Logic.attitudeMood(0) === 'idle' + && Logic.attitudeMood(60) === 'happy'); +} + +// --------------------------------------------------------------------------- +section('7. spaceship'); + +if (RULES) { + const st = makeFlatState({ civs: 2 }); + const civ = st.civs[0]; + check('launch blocked without parts', !Logic.launchSpaceship(RULES, st, civ)); + civ.spaceship.structural = 8; + civ.spaceship.component = 4; + civ.spaceship.module = 2; + check('launch blocked missing modules', !Logic.launchSpaceship(RULES, st, civ)); + civ.spaceship.module = 3; + check('launch succeeds with full parts', Logic.launchSpaceship(RULES, st, civ)); + check('arrival scheduled', civ.spaceship.arrivalTurn === st.turn + RULES.spaceship.travelTurns); + check('double launch blocked', !Logic.launchSpaceship(RULES, st, civ)); + + // Countdown to victory via endCivTurn wrapping. + for (let i = 0; i < RULES.spaceship.travelTurns + 1 && !st.over; i += 1) { + Logic.endCivTurn(RULES, st, 0); + Logic.endCivTurn(RULES, st, 1); + } + check('spaceship arrival wins', st.over?.type === 'spaceship' && st.over.winner === 0); + + // Capital capture kills the ship. + { + const st2 = makeFlatState({ civs: 2 }); + setWar(st2, 0, 1); + const cap = Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 5, 5, null)); + Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 10, 10, null)); + const civ1 = st2.civs[1]; + civ1.spaceship = { structural: 8, component: 4, module: 3, launched: false, arrivalTurn: 0 }; + Logic.launchSpaceship(RULES, st2, civ1); + const tank = Logic.spawnUnit(RULES, st2, 0, 'armor', 4, 5, null); + Logic.tryMove(RULES, st2, tank, 1, 0); + check('capital captured', cap.civ === 0); + check('spaceship lost with capital', !civ1.spaceship.launched + && civ1.spaceship.structural === 0); + check('game continues (civ lives on)', civ1.alive && !st2.over); + } + + // Spaceship parts respect their caps in the build list. + { + const st3 = makeFlatState(); + const civ0 = st3.civs[0]; + civ0.known.spaceflight = true; + const city = Logic.foundCity(RULES, st3, Logic.spawnUnit(RULES, st3, 0, 'settlers', 5, 5, null)); + let avail = Logic.availableUnits(RULES, st3, civ0, city).map((u) => u.id); + check('structural buildable with tech', avail.includes('ssstructural')); + check('component gated on plastics', !avail.includes('sscomponent')); + civ0.spaceship.structural = 8; + avail = Logic.availableUnits(RULES, st3, civ0, city).map((u) => u.id); + check('structural capped at 8', !avail.includes('ssstructural')); + } +} + +// --------------------------------------------------------------------------- +section('8. serialization'); + +if (RULES) { + const leaders = [{ id: 'steve', name: 'Steve' }, { id: 'gerome', name: 'Gerome' }, + { id: 'jerry', name: 'Jerry' }]; + const st = Logic.createGame(RULES, { sizeId: 'small', seed: 11, difficultyId: 'prince', leaders }); + // Play a few scripted turns. + for (let t = 0; t < 5; t += 1) { + for (let c = 0; c < st.civs.length; c += 1) { + Logic.beginCivTurn(RULES, st, c); + for (const u of Logic.civUnits(st, c)) { + if (u.type === 'settlers' && Logic.canFoundCity(RULES, st, u.x, u.y)) { + Logic.foundCity(RULES, st, u); + } else if (u.mp > 0) { + Logic.tryMove(RULES, st, u, (t + c) % 3 - 1, (t * c) % 3 - 1); + } + } + Logic.endCivTurn(RULES, st, c); + } + } + const json = Logic.serialize(st); + const st2 = Logic.deserialize(json); + check('round trip parses', !!st2); + check('round trip identical', Logic.serialize(st2) === json); + check('hash stable', Logic.hashState(st) === Logic.hashState(st2)); + check('version mismatch rejected', Logic.deserialize(JSON.stringify({ version: 99 })) === null); + + // Determinism: same seed + same script => same hash. + const stA = Logic.createGame(RULES, { sizeId: 'small', seed: 77, difficultyId: 'king', leaders }); + const stB = Logic.createGame(RULES, { sizeId: 'small', seed: 77, difficultyId: 'king', leaders }); + check('createGame deterministic', Logic.hashState(stA) === Logic.hashState(stB)); +} + +// --------------------------------------------------------------------------- +section('5+9. AI self-play soak (+ research pacing)'); + +if (RULES) { + const LEADER_POOL = ['steve', 'gerome', 'jerry', 'aiko', 'natasha', 'brad', 'cybro'] + .map((id) => ({ id, name: id[0].toUpperCase() + id.slice(1) })); + + function checkInvariants(st, label) { + for (const civ of st.civs) { + if (civ.gold < 0 || Number.isNaN(civ.gold)) return `${label}: negative/NaN gold civ ${civ.id}`; + if (civ.beakers < 0) return `${label}: negative beakers`; + } + for (const c of st.cities) { + if (c.size < 1) return `${label}: city size ${c.size}`; + if (!st.civs[c.civ].alive) return `${label}: city owned by dead civ`; + const seen = new Set(); + for (const t of c.worked) { + if (seen.has(t)) return `${label}: duplicate worked tile`; + seen.add(t); + } + } + for (const u of st.units) { + if (!Logic.inBounds(st.world, u.x, u.y)) return `${label}: unit off map`; + if (u.hp <= 0) return `${label}: dead unit alive`; + const def = RULES.units[u.type]; + const terr = Logic.terrainAt(RULES, st.world, u.x, u.y); + if (def.domain === 'land' && terr.water && !u.carriedBy) return `${label}: land unit swimming`; + if (def.domain === 'sea' && !terr.water && !Logic.cityAt(st, u.x, u.y)) return `${label}: ship aground`; + if (!st.civs[u.civ].alive) return `${label}: unit of dead civ`; + } + // Worked tiles disjoint across cities. + const workedAll = new Set(); + for (const c of st.cities) { + for (const t of c.worked) { + if (workedAll.has(t)) return `${label}: worked tile shared between cities`; + workedAll.add(t); + } + } + return null; + } + + function runGame(gameIdx, { sizeId, numCivs, difficultyId, seed, turnCap = 600 }) { + const leaders = LEADER_POOL.slice(0, numCivs); + const st = Logic.createGame(RULES, { sizeId, seed, difficultyId, leaders, humanIndex: -1 }); + let invariantErr = null; + let aiTime = 0; + let aiTurns = 0; + let firstSpaceflight = null; + while (!st.over && st.turn < turnCap) { + const c = st.current; + Logic.beginCivTurn(RULES, st, c); + const t0 = performance.now(); + AI.runAITurn(RULES, st, c); + aiTime += performance.now() - t0; + aiTurns += 1; + Logic.endCivTurn(RULES, st, c); + if (!firstSpaceflight && st.civs.some((cv) => cv.known.spaceflight)) { + firstSpaceflight = st.turn; + } + if (st.turn % 50 === 0 && !invariantErr) { + invariantErr = checkInvariants(st, `game ${gameIdx} turn ${st.turn}`); + } + } + if (!invariantErr) invariantErr = checkInvariants(st, `game ${gameIdx} final`); + return { st, invariantErr, avgAiMs: aiTime / Math.max(1, aiTurns), firstSpaceflight }; + } + + const games = []; + const N = QUICK ? 6 : 28; + const sizes = ['small', 'medium', 'small', 'medium']; + const diffs = ['chieftain', 'warlord', 'prince', 'king', 'emperor']; + const configs = []; + for (let g = 0; g < N; g += 1) { + configs.push({ + sizeId: sizes[g % sizes.length], + numCivs: 3 + (g % 3), + difficultyId: diffs[g % diffs.length], + seed: 1000 + g * 17, + }); + } + // Pinned seeds known to end in conquest (deterministic engine), so the + // victory-mix coverage below cannot flake on an all-peaceful draw. + configs.push({ sizeId: 'small', numCivs: 4, difficultyId: 'emperor', seed: 7077 }); + configs.push({ sizeId: 'small', numCivs: 4, difficultyId: 'king', seed: 8088 }); + configs.forEach((cfg, g) => { + const out = runGame(g, cfg); + games.push({ cfg, ...out }); + check(`game ${g} (${cfg.sizeId}/${cfg.numCivs}civ/${cfg.difficultyId}) no invariant breaks`, + out.invariantErr === null, out.invariantErr ?? ''); + }); + + const finished = games.filter((g) => g.st.over); + const conquest = finished.filter((g) => g.st.over.type === 'conquest'); + const space = finished.filter((g) => g.st.over.type === 'spaceship'); + console.log(` ${finished.length}/${games.length} games decided ` + + `(${conquest.length} conquest, ${space.length} spaceship); ` + + `avg AI turn ${(games.reduce((a, g) => a + g.avgAiMs, 0) / games.length).toFixed(2)}ms`); + check('a healthy share of games reach a victory (>=30%)', + finished.length >= Math.ceil(games.length * 0.3), `${finished.length}/${games.length}`); + check('conquest victories occur', conquest.length > 0); + if (QUICK) { + console.log(' (--quick: spaceship coverage check runs in the full suite only)'); + } else { + check('spaceship victories occur', space.length > 0, 'no spaceship win in suite'); + } + check('AI turn time budget (<=50ms avg)', games.every((g) => g.avgAiMs <= 50), + `worst ${Math.max(...games.map((g) => g.avgAiMs)).toFixed(1)}ms`); + + // Research pacing: someone should reach Space Flight in a sensible window. + const sfTurns = games.map((g) => g.firstSpaceflight).filter((t) => t !== null); + check('space flight reached in some games', sfTurns.length > 0); + if (sfTurns.length) { + const median = sfTurns.sort((a, b) => a - b)[Math.floor(sfTurns.length / 2)]; + check('space flight window turn 150-600', median >= 150 && median <= 600, `median ${median}`); + } + + // Determinism: replaying the same seed produces the same final hash. + { + const cfg = { sizeId: 'small', numCivs: 3, difficultyId: 'prince', seed: 4242, turnCap: 120 }; + const a = runGame(-1, cfg); + const b = runGame(-2, cfg); + check('soak replay deterministic', Logic.hashState(a.st) === Logic.hashState(b.st)); + } +} + +// --------------------------------------------------------------------------- +console.log(`\n${passes} passed, ${failures} failed`); +if (failures > 0) process.exit(1);