feat(mastervega): implement MOO1-style branching tech rungs and corrected racial cost model
- Add branching tech alternatives across all six fields (computers, construction, forcefields, planetology, propulsion, weapons). Each branched tier has 2+ techs sharing the same rung; researching any one clears the rung while leaving siblings researchable as side-picks. - Correct racial skill to affect tech COST only (Poor 125%, Average 100%, Good 80%, Excellent 60%), not availability. Availability is now a flat 50% roll per tech (75% for Cerebrai/psilon analogue), with a safety net that no branched rung can end up with zero available options. - Add techRungsByField data structure and rungCleared/canResearch/setResearchTarget logic so the research screen lets players pick among open rung alternatives without losing banked beakers. - Update sprite sheet layout (+1 row, frames 66-77 for new tech icons). - Add comprehensive verification tests for cost buckets, rung gating, setResearchTarget behaviour, and statistical availability rates.
This commit is contained in:
parent
d16e699a8d
commit
97dbe18dd2
|
|
@ -275,8 +275,8 @@
|
|||
"frameWidth": 48,
|
||||
"frameHeight": 48,
|
||||
"cols": 10,
|
||||
"rows": 7,
|
||||
"_layout": "Frames 0-5 are the six tech FIELDS (techFields[].iconFrame); frames 6-65 are the individual techs (techs[].iconFrame). 66-69 spare."
|
||||
"rows": 8,
|
||||
"_layout": "Frames 0-5 are the six tech FIELDS (techFields[].iconFrame); frames 6-65 are the original 60 individual techs, 66-77 are the MOO1-branching alternates added alongside them (techs[].iconFrame). 78-79 spare."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,32 +17,38 @@
|
|||
{ "id": "battlescanner", "name": "Battle Scanner", "field": "computers", "tier": 1, "prereqs": ["electroniccomputer"], "cost": 150, "iconFrame": 7, "desc": "Reveals enemy fleet composition and adds combat initiative.", "effects": { "initiative": 2, "scanRange": 1 } },
|
||||
{ "id": "optroniccomputer", "name": "Optronic Computer", "field": "computers", "tier": 2, "prereqs": ["battlescanner"], "cost": 400, "iconFrame": 8, "desc": "Mark II battle computer. +2 to hit.", "effects": { "targeting": 2 } },
|
||||
{ "id": "neuralscanner", "name": "Neural Scanner", "field": "computers", "tier": 3, "prereqs": ["optroniccomputer"], "cost": 900, "iconFrame": 9, "desc": "Deep-scan interrogation. Espionage missions far more likely.", "effects": { "espionage": 15 } },
|
||||
{ "id": "tachyoncomputer", "name": "Tachyon Computer", "field": "computers", "tier": 3, "prereqs": ["optroniccomputer"], "cost": 900, "iconFrame": 66, "desc": "Overclocked targeting matrix, no spycraft. +4 to hit.", "effects": { "targeting": 4 } },
|
||||
{ "id": "positroniccomputer", "name": "Positronic Computer", "field": "computers", "tier": 4, "prereqs": ["neuralscanner"], "cost": 1800, "iconFrame": 10, "desc": "Mark III battle computer. +3 to hit.", "effects": { "targeting": 3 } },
|
||||
{ "id": "cybersecuritylink", "name": "Cyber Security Link", "field": "computers", "tier": 5, "prereqs": ["positroniccomputer"], "cost": 3200, "iconFrame": 11, "desc": "Hardened colony networks. Enemy spies rarely get through.", "effects": { "counterEspionage": 25 } },
|
||||
{ "id": "emissionsguidance", "name": "Emissions Guidance", "field": "computers", "tier": 6, "prereqs": ["cybersecuritylink"], "cost": 5400, "iconFrame": 12, "desc": "Mark IV battle computer. +4 to hit.", "effects": { "targeting": 4 } },
|
||||
{ "id": "oracleinterface", "name": "Oracle Interface", "field": "computers", "tier": 7, "prereqs": ["emissionsguidance"], "cost": 8600, "iconFrame": 13, "desc": "Mark V battle computer, and research output climbs sharply.", "effects": { "targeting": 5, "researchMult": 1.15 } },
|
||||
{ "id": "omniscanarray", "name": "Omniscan Array", "field": "computers", "tier": 7, "prereqs": ["emissionsguidance"], "cost": 8600, "iconFrame": 67, "desc": "Full-spectrum battle sensors. +5 to hit and always strikes first.", "effects": { "targeting": 5, "initiative": 5 } },
|
||||
{ "id": "cybertroniccomputer","name": "Cybertronic Computer", "field": "computers", "tier": 8, "prereqs": ["oracleinterface"], "cost": 13000, "iconFrame": 14, "desc": "Mark VI battle computer. +6 to hit.", "effects": { "targeting": 6 } },
|
||||
{ "id": "galacticcybernet", "name": "Galactic Cybernet", "field": "computers", "tier": 9, "prereqs": ["cybertroniccomputer"], "cost": 20000, "iconFrame": 15, "desc": "Mark VII battle computer and an empire-wide research grid.", "effects": { "targeting": 7, "researchMult": 1.3 } },
|
||||
|
||||
{ "id": "reinforcedhull", "name": "Reinforced Hull", "field": "construction", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 16, "desc": "Cross-braced spaceframes. Hulls absorb 25% more damage.", "effects": { "armor": { "name": "Reinforced", "hpMult": 1.25 } } },
|
||||
{ "id": "duralloyarmor", "name": "Duralloy Armour", "field": "construction", "tier": 1, "prereqs": ["reinforcedhull"], "cost": 160, "iconFrame": 17, "desc": "Duralloy plating. Hulls absorb 50% more damage.", "effects": { "armor": { "name": "Duralloy", "hpMult": 1.5 } } },
|
||||
{ "id": "improvedindustrial", "name": "Improved Industrial Tech","field": "construction","tier": 2,"prereqs": ["duralloyarmor"], "cost": 420, "iconFrame": 18, "desc": "Factories cost 20% less to build and to run.", "effects": { "factoryCostMult": 0.8 } },
|
||||
{ "id": "nanitehullsealant", "name": "Nanite Hull Sealant", "field": "construction", "tier": 2, "prereqs": ["duralloyarmor"], "cost": 420, "iconFrame": 68, "desc": "Self-sealing microfractures. Ships repair 2% of their hull between combat rounds.", "effects": { "repairPerRound": 0.02 } },
|
||||
{ "id": "zortriumarmor", "name": "Zortrium Armour", "field": "construction", "tier": 3, "prereqs": ["improvedindustrial"], "cost": 950, "iconFrame": 19, "desc": "Zortrium plating. Hulls absorb 80% more damage.", "effects": { "armor": { "name": "Zortrium", "hpMult": 1.8 } } },
|
||||
{ "id": "autorepairunit", "name": "Automated Repair Unit", "field": "construction", "tier": 4, "prereqs": ["zortriumarmor"], "cost": 1900, "iconFrame": 20, "desc": "Ships repair 3% of their hull between combat rounds.", "effects": { "repairPerRound": 0.03 } },
|
||||
{ "id": "andriumarmor", "name": "Andrium Armour", "field": "construction", "tier": 5, "prereqs": ["autorepairunit"], "cost": 3400, "iconFrame": 21, "desc": "Andrium plating. Hulls absorb 110% more damage.", "effects": { "armor": { "name": "Andrium", "hpMult": 2.1 } } },
|
||||
{ "id": "damagecontrol", "name": "Advanced Damage Control","field": "construction","tier": 6, "prereqs": ["andriumarmor"], "cost": 5600, "iconFrame": 22, "desc": "Damaged ships rejoin the line faster and cost less to refit.", "effects": { "repairPerRound": 0.06, "refitCostMult": 0.7 } },
|
||||
{ "id": "redundantlifesupport","name": "Redundant Life Support","field": "construction", "tier": 6, "prereqs": ["andriumarmor"], "cost": 5600, "iconFrame": 69, "desc": "Backup systems patch hulls faster in the heat of battle. Ships repair 7% each round.", "effects": { "repairPerRound": 0.07 } },
|
||||
{ "id": "tritaniumarmor", "name": "Tritanium Armour", "field": "construction", "tier": 7, "prereqs": ["damagecontrol"], "cost": 9000, "iconFrame": 23, "desc": "Tritanium plating. Hulls absorb 150% more damage.", "effects": { "armor": { "name": "Tritanium", "hpMult": 2.5 } } },
|
||||
{ "id": "adamantiumarmor", "name": "Adamantium Armour", "field": "construction", "tier": 8, "prereqs": ["tritaniumarmor"], "cost": 14000, "iconFrame": 24, "desc": "Adamantium plating. Hulls absorb 200% more damage.", "effects": { "armor": { "name": "Adamantium", "hpMult": 3.0 } } },
|
||||
{ "id": "neutroniumarmor", "name": "Neutronium Armour", "field": "construction", "tier": 9, "prereqs": ["adamantiumarmor"], "cost": 21000, "iconFrame": 25, "desc": "Degenerate-matter plating. Hulls absorb 260% more damage.", "effects": { "armor": { "name": "Neutronium", "hpMult": 3.6 } } },
|
||||
|
||||
{ "id": "deflectori", "name": "Class I Deflector Shield","field": "forcefields","tier": 0, "prereqs": [], "cost": 80, "iconFrame": 26, "desc": "Absorbs 1 point from every incoming hit.", "effects": { "shield": 1 } },
|
||||
{ "id": "personalshield", "name": "Personal Shield", "field": "forcefields", "tier": 1, "prereqs": ["deflectori"], "cost": 200, "iconFrame": 27, "desc": "Shielded infantry. +10 to ground combat on attack and defence.","effects": { "groundAttack": 10, "groundDefense": 10 } },
|
||||
{ "id": "pointdefensegrid", "name": "Point-Defense Grid", "field": "forcefields", "tier": 1, "prereqs": ["deflectori"], "cost": 200, "iconFrame": 70, "desc": "Automated point-defense turrets. Fleets react a beat faster in combat.", "effects": { "initiative": 1 } },
|
||||
{ "id": "deflectoriii", "name": "Class III Deflector", "field": "forcefields", "tier": 2, "prereqs": ["personalshield"], "cost": 500, "iconFrame": 28, "desc": "Absorbs 2 points from every incoming hit.", "effects": { "shield": 2 } },
|
||||
{ "id": "planetaryshield5", "name": "Planetary Shield V", "field": "forcefields", "tier": 3, "prereqs": ["deflectoriii"], "cost": 1100, "iconFrame": 29, "desc": "Colonies shrug off 5 points from every orbital bombardment.", "effects": { "planetaryShield": 5 } },
|
||||
{ "id": "deflectorv", "name": "Class V Deflector", "field": "forcefields", "tier": 4, "prereqs": ["planetaryshield5"], "cost": 2100, "iconFrame": 30, "desc": "Absorbs 3 points from every incoming hit.", "effects": { "shield": 3 } },
|
||||
{ "id": "stealthfield", "name": "Stealth Field", "field": "forcefields", "tier": 5, "prereqs": ["deflectorv"], "cost": 3600, "iconFrame": 31, "desc": "Cloaked hulls. Fleets move unseen and strike first.", "effects": { "cloaked": true, "initiative": 3 } },
|
||||
{ "id": "deflectorvii", "name": "Class VII Deflector", "field": "forcefields", "tier": 6, "prereqs": ["stealthfield"], "cost": 6000, "iconFrame": 32, "desc": "Absorbs 5 points from every incoming hit.", "effects": { "shield": 5 } },
|
||||
{ "id": "planetaryshield15", "name": "Planetary Shield XV", "field": "forcefields", "tier": 7, "prereqs": ["deflectorvii"], "cost": 9500, "iconFrame": 33, "desc": "Colonies shrug off 15 points from every orbital bombardment.", "effects": { "planetaryShield": 15 } },
|
||||
{ "id": "deepshieldharmonics","name": "Deep Shield Harmonics", "field": "forcefields", "tier": 7, "prereqs": ["deflectorvii"], "cost": 9500, "iconFrame": 71, "desc": "Ship-side shield regenerators. Absorbs 6 points from every incoming hit.", "effects": { "shield": 6 } },
|
||||
{ "id": "deflectorx", "name": "Class X Deflector", "field": "forcefields", "tier": 8, "prereqs": ["planetaryshield15"], "cost": 15000, "iconFrame": 34, "desc": "Absorbs 7 points from every incoming hit.", "effects": { "shield": 7 } },
|
||||
{ "id": "blackholegenerator", "name": "Black Hole Generator", "field": "forcefields", "tier": 9, "prereqs": ["deflectorx"], "cost": 22000, "iconFrame": 35, "desc": "Folds enemy hulls into a singularity. Devastating in battle.", "effects": { "shield": 9, "singularity": true } },
|
||||
|
||||
|
|
@ -51,18 +57,22 @@
|
|||
{ "id": "enhancedeco", "name": "Enhanced Eco Restoration","field": "planetology","tier": 2, "prereqs": ["controlledbarren"], "cost": 460, "iconFrame": 38, "desc": "Industrial waste costs 50% less to clean up.", "effects": { "wasteMult": 0.5 } },
|
||||
{ "id": "controlleddead", "name": "Controlled Dead Environment","field": "planetology","tier": 3,"prereqs": ["enhancedeco"], "cost": 1000, "iconFrame": 39, "desc": "Colonise dead worlds stripped of atmosphere.", "effects": { "colonizeHostility": 2 } },
|
||||
{ "id": "soilenrichment", "name": "Soil Enrichment", "field": "planetology", "tier": 4, "prereqs": ["controlleddead"], "cost": 2000, "iconFrame": 40, "desc": "Every colony supports 15 more population.", "effects": { "maxPopBonus": 15 } },
|
||||
{ "id": "efficientwastereclamation","name": "Efficient Waste Reclamation","field": "planetology","tier": 4,"prereqs": ["controlleddead"], "cost": 2000, "iconFrame": 72, "desc": "Industrial waste is recycled almost as fast as it's produced. Cleanup costs 40% less.", "effects": { "wasteMult": 0.6 } },
|
||||
{ "id": "controlledinferno", "name": "Controlled Inferno Environment","field": "planetology","tier": 5,"prereqs": ["soilenrichment"], "cost": 3500, "iconFrame": 41, "desc": "Colonise inferno worlds.", "effects": { "colonizeHostility": 3 } },
|
||||
{ "id": "atmosphericterraforming","name": "Atmospheric Terraforming","field": "planetology","tier": 6,"prereqs": ["controlledinferno"], "cost": 5800, "iconFrame": 42, "desc": "Every colony supports 25 more population.", "effects": { "maxPopBonus": 25 } },
|
||||
{ "id": "controlledtoxic", "name": "Controlled Toxic Environment","field": "planetology","tier": 7,"prereqs": ["atmosphericterraforming"],"cost": 9200,"iconFrame": 43,"desc": "Colonise toxic worlds.", "effects": { "colonizeHostility": 4 } },
|
||||
{ "id": "advancedsoil", "name": "Advanced Soil Enrichment","field": "planetology","tier": 8, "prereqs": ["controlledtoxic"], "cost": 14500, "iconFrame": 44, "desc": "Every colony supports 40 more population.", "effects": { "maxPopBonus": 40 } },
|
||||
{ "id": "deepterraforming", "name": "Deep Terraforming", "field": "planetology", "tier": 8, "prereqs": ["controlledtoxic"], "cost": 14500, "iconFrame": 73, "desc": "Near-total reclamation. Industrial waste costs 65% less to clean up.", "effects": { "wasteMult": 0.35 } },
|
||||
{ "id": "controlledradiated", "name": "Controlled Radiated Environment","field": "planetology","tier": 9,"prereqs": ["advancedsoil"], "cost": 21500, "iconFrame": 45, "desc": "Colonise radiated worlds. Nowhere in the galaxy is closed.", "effects": { "colonizeHostility": 5, "maxPopBonus": 15 } },
|
||||
|
||||
{ "id": "nuclearengines", "name": "Nuclear Engines", "field": "propulsion", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 46, "desc": "Warp 1 drives. Fleets crawl, but they cross the dark.", "effects": { "engine": { "name": "Nuclear", "speed": 1 } } },
|
||||
{ "id": "deuteriumcells", "name": "Deuterium Fuel Cells", "field": "propulsion", "tier": 1, "prereqs": ["nuclearengines"], "cost": 170, "iconFrame": 47, "desc": "Fuel range 7 parsecs. The map opens a little.", "effects": { "fuelRange": 7 } },
|
||||
{ "id": "reactionthrusters", "name": "Reaction Thrusters", "field": "propulsion", "tier": 1, "prereqs": ["nuclearengines"], "cost": 170, "iconFrame": 74, "desc": "Snap-fire manoeuvring jets. Fleets react a beat faster in combat, though range stays short.", "effects": { "initiative": 1 } },
|
||||
{ "id": "fusiondrive", "name": "Fusion Drive", "field": "propulsion", "tier": 2, "prereqs": ["deuteriumcells"], "cost": 440, "iconFrame": 48, "desc": "Warp 2 drives.", "effects": { "engine": { "name": "Fusion", "speed": 2 } } },
|
||||
{ "id": "iridiumcells", "name": "Iridium Fuel Cells", "field": "propulsion", "tier": 3, "prereqs": ["fusiondrive"], "cost": 980, "iconFrame": 49, "desc": "Fuel range 9 parsecs.", "effects": { "fuelRange": 9 } },
|
||||
{ "id": "sublightdrive", "name": "Sub-Light Drive", "field": "propulsion", "tier": 4, "prereqs": ["iridiumcells"], "cost": 2000, "iconFrame": 50, "desc": "Warp 3 drives.", "effects": { "engine": { "name": "Sub-Light", "speed": 3 } } },
|
||||
{ "id": "uridiumcells", "name": "Uridium Fuel Cells", "field": "propulsion", "tier": 5, "prereqs": ["sublightdrive"], "cost": 3600, "iconFrame": 51, "desc": "Fuel range 12 parsecs. Half the galaxy lights up.", "effects": { "fuelRange": 12 } },
|
||||
{ "id": "tachyondrivecoils", "name": "Tachyon Drive Coils", "field": "propulsion", "tier": 5, "prereqs": ["sublightdrive"], "cost": 3600, "iconFrame": 75, "desc": "Precognitive drive telemetry. A further edge in combat tempo, at the cost of range.", "effects": { "initiative": 2 } },
|
||||
{ "id": "impulsedrive", "name": "Impulse Drive", "field": "propulsion", "tier": 6, "prereqs": ["uridiumcells"], "cost": 5900, "iconFrame": 52, "desc": "Warp 4 drives.", "effects": { "engine": { "name": "Impulse", "speed": 4 } } },
|
||||
{ "id": "thoriumcells", "name": "Thorium Fuel Cells", "field": "propulsion", "tier": 7, "prereqs": ["impulsedrive"], "cost": 9400, "iconFrame": 53, "desc": "Fuel range 18 parsecs. Range stops being a constraint.", "effects": { "fuelRange": 18 } },
|
||||
{ "id": "interphaseddrive", "name": "Interphased Drive", "field": "propulsion", "tier": 8, "prereqs": ["thoriumcells"], "cost": 14800, "iconFrame": 54, "desc": "Warp 5 drives.", "effects": { "engine": { "name": "Interphased", "speed": 5 } } },
|
||||
|
|
@ -71,10 +81,12 @@
|
|||
{ "id": "lasercannon", "name": "Laser Cannon", "field": "weapons", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 56, "desc": "The first real gun. 1-4 damage.", "effects": { "weapon": { "id": "laser", "name": "Laser Cannon", "kind": "beam", "min": 1, "max": 4, "shots": 1, "space": 2, "cost": 8 } } },
|
||||
{ "id": "hypervrockets", "name": "Hyper-V Rockets", "field": "weapons", "tier": 1, "prereqs": ["lasercannon"], "cost": 160, "iconFrame": 57, "desc": "Cheap standoff missiles. 6 damage, 2 salvoes.", "effects": { "weapon": { "id": "hyperv", "name": "Hyper-V Rocket", "kind": "missile", "min": 6, "max": 6, "shots": 4, "space": 4, "cost": 10 } } },
|
||||
{ "id": "neutronpellet", "name": "Neutron Pellet Gun", "field": "weapons", "tier": 2, "prereqs": ["hypervrockets"], "cost": 430, "iconFrame": 58, "desc": "Shield-piercing pellets. 2-6 damage.", "effects": { "weapon": { "id": "pellet", "name": "Neutron Pellet Gun", "kind": "beam", "min": 2, "max": 6, "shots": 1, "space": 3, "cost": 12, "shieldPierce": 2 } } },
|
||||
{ "id": "protontorpedoes", "name": "Proton Torpedoes", "field": "weapons", "tier": 2, "prereqs": ["hypervrockets"], "cost": 430, "iconFrame": 76, "desc": "Slow, but heavier warheads than Hyper-V. 10 damage, 2 salvoes.", "effects": { "weapon": { "id": "protontorpedo", "name": "Proton Torpedo", "kind": "missile", "min": 10, "max": 10, "shots": 2, "space": 5, "cost": 14 } } },
|
||||
{ "id": "ioncannon", "name": "Ion Cannon", "field": "weapons", "tier": 3, "prereqs": ["neutronpellet"], "cost": 960, "iconFrame": 59, "desc": "3-8 damage.", "effects": { "weapon": { "id": "ion", "name": "Ion Cannon", "kind": "beam", "min": 3, "max": 8, "shots": 1, "space": 4, "cost": 18 } } },
|
||||
{ "id": "merculitemissiles", "name": "Merculite Missiles", "field": "weapons", "tier": 4, "prereqs": ["ioncannon"], "cost": 1950, "iconFrame": 60, "desc": "10 damage, 2 salvoes, long reach.", "effects": { "weapon": { "id": "merculite", "name": "Merculite Missile", "kind": "missile", "min": 10, "max": 10, "shots": 4, "space": 6, "cost": 20 } } },
|
||||
{ "id": "neutronblaster", "name": "Neutron Blaster", "field": "weapons", "tier": 5, "prereqs": ["merculitemissiles"], "cost": 3400, "iconFrame": 61, "desc": "5-12 damage and it kills crew through the hull.", "effects": { "weapon": { "id": "neutronblaster", "name": "Neutron Blaster", "kind": "beam", "min": 5, "max": 12, "shots": 1, "space": 5, "cost": 30, "shieldPierce": 1 } } },
|
||||
{ "id": "hyperxrockets", "name": "Hyper-X Rockets", "field": "weapons", "tier": 6, "prereqs": ["neutronblaster"], "cost": 5700, "iconFrame": 62, "desc": "14 damage, 3 salvoes.", "effects": { "weapon": { "id": "hyperx", "name": "Hyper-X Rocket", "kind": "missile", "min": 16, "max": 16, "shots": 5, "space": 8, "cost": 32 } } },
|
||||
{ "id": "gravitonbeam", "name": "Graviton Beam", "field": "weapons", "tier": 6, "prereqs": ["neutronblaster"], "cost": 5700, "iconFrame": 77, "desc": "Warps space directly onto the target. 12-20 damage, punches through shields.", "effects": { "weapon": { "id": "gravitonbeam", "name": "Graviton Beam", "kind": "beam", "min": 12, "max": 20, "shots": 1, "space": 7, "cost": 34, "shieldPierce": 2 } } },
|
||||
{ "id": "fusionbeam", "name": "Fusion Beam", "field": "weapons", "tier": 7, "prereqs": ["hyperxrockets"], "cost": 9100, "iconFrame": 63, "desc": "8-16 damage.", "effects": { "weapon": { "id": "fusionbeam", "name": "Fusion Beam", "kind": "beam", "min": 8, "max": 16, "shots": 1, "space": 6, "cost": 45 } } },
|
||||
{ "id": "deathray", "name": "Death Ray", "field": "weapons", "tier": 8, "prereqs": ["fusionbeam"], "cost": 14200, "iconFrame": 64, "desc": "15-40 damage. Fleets evaporate.", "effects": { "weapon": { "id": "deathray", "name": "Death Ray", "kind": "beam", "min": 15, "max": 40, "shots": 1, "space": 12, "cost": 90 } } },
|
||||
{ "id": "stellarconverter", "name": "Stellar Converter", "field": "weapons", "tier": 9, "prereqs": ["deathray"], "cost": 21000, "iconFrame": 65, "desc": "30-70 damage, and it can crack a colony from orbit.", "effects": { "weapon": { "id": "stellarconverter", "name": "Stellar Converter", "kind": "beam", "min": 30, "max": 70, "shots": 1, "space": 20, "cost": 150, "planetCracker": true } } }
|
||||
|
|
@ -167,7 +179,7 @@
|
|||
{ "id": "blackhole","name": "Black Hole", "color": "#3a2b52", "coreColor": "#050308", "radius": 10, "weight": 1, "planetBias": -9, "richBias": 0, "special": "blackhole", "desc": "Light bends around it. Fleets that linger do not return." }
|
||||
],
|
||||
|
||||
"_speciesReadme": "MOO1's ten races with everything but Humans renamed. Multipliers are relative to 1.0; flat modifiers are additive percentage points. techAffinity biases which techs a species can research at all (see VegaLogic.rollTechAvailability).",
|
||||
"_speciesReadme": "MOO1's ten races with everything but Humans renamed. Multipliers are relative to 1.0; flat modifiers are additive percentage points. techAffinity is bucketed by VegaRules.techCostFactor into MOO1's Poor/Average/Good/Excellent research-COST multiplier (125/100/80/60%) for that field — matching the real game, where racial skill changes what a tech costs, never whether it exists in your tree. It is ALSO still read by VegaLogic.rollTechAvailability to bias which techs are available at all, which is the OPPOSITE of how MOO1 actually works (there, availability is a flat ~50% roll per tech regardless of skill, 75% for a single research-focused race, with the sole guarantee that the last surviving tech in a rung is never struck out) — that double duty is a known stopgap, to be removed once the tech tree grows branching alternatives per rung for the roll to have something real to strike between.",
|
||||
"species": [
|
||||
{
|
||||
"id": "human", "name": "Human", "adjective": "Human", "plural": "Humans",
|
||||
|
|
@ -278,7 +290,7 @@
|
|||
"id": "cerebrai", "name": "Cerebrai", "adjective": "Cerebrai", "plural": "Cerebrai",
|
||||
"color": "#4fb8a8", "portraitFrame": 7, "shipFrame": 7, "homeworld": "terran",
|
||||
"desc": "Vast fragile intellects. They will out-think the galaxy if they survive long enough to finish thinking.",
|
||||
"strengths": ["Double research output", "Can research anything"],
|
||||
"strengths": ["Double research output", "Rarely misses a breakthrough (75% vs 50% research odds)"],
|
||||
"weaknesses": ["Hopeless in a ground fight"],
|
||||
"colonyNames": ["The Contemplation", "Axiom's Rest", "Thoughtwell", "The Cogitation", "Insight Reach", "Ponder's Hollow", "The Deduction", "Mindspire", "Theorem Hold", "The Reflection", "Synapse Colony", "Wisdomreach", "The Postulate", "Enigma Hollow", "The Calculation", "Noesis Point", "The Hypothesis", "Reasonhold", "The Contemplative Deep", "Cognizance Reach", "The Inquiry", "Thoughtform Colony", "The Proof", "Logician's Rest", "The Abstraction", "Intellect Hold", "The Meditation", "Puzzlework", "The Conjecture", "Ponderhold", "The Epiphany", "Mindweave Reach", "The Deliberation", "Wisdomhold", "The Paradox", "Thoughtline", "The Revelation", "Synthesis Hollow", "The Understanding", "Cerebrum Reach", "The Discourse", "Knowledgehold", "The Singularity Thought", "Rationale Reach", "The Perception", "Sagehold", "The Great Thinking", "Insightform Colony", "The Awareness", "Mindsreach"],
|
||||
"traits": {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
// splits into the six tech fields.
|
||||
|
||||
import { generateGalaxy, parsecs, mulberry32 } from './VegaGalaxyGen.js';
|
||||
import { techCost } from './VegaRules.js';
|
||||
import { techCost, techCostFactor } from './VegaRules.js';
|
||||
import { designFor, bestComponents, markFor, refitCost } from './VegaShips.js';
|
||||
import { createBattle, runBattle, resolveInvasion } from './VegaCombat.js';
|
||||
import { breakStalemate, declareWar } from './VegaDiplomacy.js';
|
||||
|
|
@ -340,20 +340,32 @@ export function fleetPower(rules, state, fleet) {
|
|||
// --------------------------------------------------------------------------
|
||||
// Game creation
|
||||
|
||||
function rollTechAvailability(state, rules, emp, spec) {
|
||||
// MOO1's actual rule, corrected: racial skill (techAffinity, via
|
||||
// techCostFactor) changes what a tech COSTS, never whether it exists in your
|
||||
// tree. Availability is a flat roll per tech — 50% for every species, 75%
|
||||
// for Cerebrai, the one species built around research breadth (uniformly
|
||||
// >=1.2 techAffinity across every field, researchMult 2 — our closest
|
||||
// analogue to Psilons). The one guarantee is that a rung of 2+ alternatives
|
||||
// can never end up with zero available techs; a size-1 rung (most of the
|
||||
// tree today) has nothing to fall back on, so its lone tech can still
|
||||
// legitimately miss its roll and simply be skipped (rungCleared) — that is
|
||||
// what makes tech trading matter for the majority of the tree, not just the
|
||||
// branched rungs.
|
||||
const PSILON_ANALOGUE = 'cerebrai';
|
||||
|
||||
export function rollTechAvailability(state, rules, emp, spec) {
|
||||
const available = {};
|
||||
// A species that "can research anything" gets the whole tree; everyone else
|
||||
// rolls per tech, weighted by how naturally the field comes to them. Because
|
||||
// the chains are linear, an unavailable tech does NOT block the rest of its
|
||||
// field — research simply skips it (see nextResearchTarget), and the only way
|
||||
// to ever own it is trade, conquest or espionage. That is MOO1's rule and it
|
||||
// is what makes tech trading matter.
|
||||
const openAll = spec.traits.researchMult >= 2;
|
||||
const chance = spec.id === PSILON_ANALOGUE ? 0.75 : 0.5;
|
||||
for (const t of rules.techList) {
|
||||
if (t.tier === 0 || openAll) { available[t.id] = true; continue; }
|
||||
const affinity = spec.techAffinity[t.field] ?? 1;
|
||||
const chance = Math.max(0.3, Math.min(1, 0.55 * affinity + 0.2));
|
||||
available[t.id] = rand(state) < chance;
|
||||
available[t.id] = t.tier === 0 ? true : rand(state) < chance;
|
||||
}
|
||||
for (const field of Object.keys(rules.techFields)) {
|
||||
for (const rung of rules.techRungsByField[field]) {
|
||||
if (rung.techs.length <= 1) continue;
|
||||
if (rung.techs.every((t) => !available[t.id])) {
|
||||
available[rung.techs[randInt(state, rung.techs.length)].id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
|
@ -593,14 +605,22 @@ function consolidateFleets(rules, state, e) {
|
|||
// --------------------------------------------------------------------------
|
||||
// Research
|
||||
|
||||
// A rung (tech.field + tech.tier) clears once ANY one of its techs is known
|
||||
// — MOO1-style: pick one alternative to advance, the rest stay legitimate
|
||||
// side-picks rather than being blocked or required. A rung with zero
|
||||
// available techs (every option missed its species roll) is also cleared —
|
||||
// there's nothing left to wait on, so research simply moves past it.
|
||||
function rungCleared(emp, rung) {
|
||||
return rung.techs.some((t) => emp.known[t.id]) || rung.techs.every((t) => !emp.available[t.id]);
|
||||
}
|
||||
|
||||
export function canResearch(rules, state, e, tech) {
|
||||
const emp = state.empires[e];
|
||||
if (emp.known[tech.id] || !emp.available[tech.id]) return false;
|
||||
// Every lower tier in the field must be resolved — known, or rolled
|
||||
// unavailable and therefore skipped.
|
||||
for (const t of rules.techsByField[tech.field]) {
|
||||
if (t.tier >= tech.tier) break;
|
||||
if (!emp.known[t.id] && emp.available[t.id]) return false;
|
||||
// Every lower rung in the field must be cleared.
|
||||
for (const rung of rules.techRungsByField[tech.field]) {
|
||||
if (rung.tier >= tech.tier) break;
|
||||
if (!rungCleared(emp, rung)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -612,6 +632,18 @@ export function nextResearchTarget(rules, state, e, field) {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Player (or a future AI override) picks a specific tech within its field's
|
||||
// currently-open rung. Deliberately does not touch emp.beakers[field] — RP
|
||||
// banked in a field is a shared pool (see processResearch below), so
|
||||
// switching which tech it's funding never loses progress, matching MOO1.
|
||||
export function setResearchTarget(rules, state, e, field, techId) {
|
||||
const emp = state.empires[e];
|
||||
const tech = rules.techs[techId];
|
||||
if (!tech || tech.field !== field || !canResearch(rules, state, e, tech)) return false;
|
||||
emp.researching[field] = techId;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function grantTech(rules, state, e, techId, source = 'research') {
|
||||
const emp = state.empires[e];
|
||||
if (emp.known[techId]) return false;
|
||||
|
|
@ -639,7 +671,7 @@ function processResearch(rules, state, e, beakers) {
|
|||
if (!targetId) continue;
|
||||
emp.beakers[field] += beakers * (emp.alloc[field] ?? 0);
|
||||
const tech = rules.techs[targetId];
|
||||
const cost = techCost(rules, tech, emp.knownInField[field]);
|
||||
const cost = techCost(rules, tech, emp.knownInField[field], techCostFactor(spec, field));
|
||||
if (emp.beakers[field] >= cost) {
|
||||
emp.beakers[field] -= cost;
|
||||
grantTech(rules, state, e, targetId);
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@
|
|||
|
||||
import * as Phaser from 'phaser';
|
||||
import { FONT, D, modalShell, slider } from './VegaScreens.js';
|
||||
import { techCost } from './VegaRules.js';
|
||||
import { setResearchAlloc, nextResearchTarget } from './VegaLogic.js';
|
||||
import { techCost, techCostFactor } from './VegaRules.js';
|
||||
import {
|
||||
setResearchAlloc, nextResearchTarget, setResearchTarget, canResearch,
|
||||
} from './VegaLogic.js';
|
||||
import {
|
||||
makeSpeciesPortrait, sourceWidth, speciesResearchVideoKey, hasSpeciesResearchVideo,
|
||||
} from './VegaArt.js';
|
||||
|
|
@ -152,6 +154,7 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
|
|||
// allocLocked at all — VegaLogic.deserialize() back-fills this for the
|
||||
// normal load path, but guard here too rather than trust every caller.
|
||||
emp.allocLocked ??= {};
|
||||
const spec = rules.species[emp.speciesId];
|
||||
const fields = rules.techFieldList;
|
||||
|
||||
const tooltip = new Tooltip(scene, { depth: D.modal + 5 });
|
||||
|
|
@ -253,7 +256,7 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
|
|||
const targetId = emp.researching[field.id] ?? nextResearchTarget(rules, state, e, field.id);
|
||||
if (targetId) {
|
||||
const tech = rules.techs[targetId];
|
||||
const cost = techCost(rules, tech, emp.knownInField[field.id]);
|
||||
const cost = techCost(rules, tech, emp.knownInField[field.id], techCostFactor(spec, field.id));
|
||||
const have = emp.beakers[field.id] ?? 0;
|
||||
const nameT = scene.add.text(bx + 14, y, tech.name, {
|
||||
fontFamily: FONT, fontSize: '15px', color: '#ffd88a', wordWrap: { width: boxW - 28 },
|
||||
|
|
@ -336,13 +339,29 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
|
|||
const known = !!emp.known[tech.id];
|
||||
const avail = !!emp.available[tech.id];
|
||||
const isCurrent = tech.id === targetId;
|
||||
// A branched rung can have more than one open (□) tech at once — this
|
||||
// is what makes it clickable: canResearch is the same gate
|
||||
// setResearchTarget itself checks, so "clickable" and "would actually
|
||||
// succeed" never disagree.
|
||||
const selectable = !known && canResearch(rules, state, e, tech);
|
||||
const colour = isCurrent ? '#ffd88a' : (known ? '#7fd8a0' : (avail ? '#9fb6cc' : '#5a4450'));
|
||||
const mark = known ? '■' : (avail ? '□' : '✕');
|
||||
const row = scene.add.text(detailX + 16, rowY, `${mark} ${tech.name}`, {
|
||||
fontFamily: FONT, fontSize: '26px', color: colour, wordWrap: { width: detailW - 32 },
|
||||
}).setInteractive();
|
||||
}).setInteractive({ useHandCursor: selectable });
|
||||
ladder.add(row);
|
||||
tooltip.attachTo(row, () => describeTechTooltip(rules, state, emp, tech));
|
||||
if (selectable) {
|
||||
row.on('pointerup', () => {
|
||||
if (!setResearchTarget(rules, state, e, selectedField, tech.id)) return;
|
||||
// The row this listener is on is about to be destroyed by the
|
||||
// rebuild below — Tooltip only clears itself on pointerout, which
|
||||
// never fires for a destroyed object, so hide it explicitly first.
|
||||
tooltip.hide();
|
||||
buildGrid();
|
||||
buildDetail();
|
||||
});
|
||||
}
|
||||
if (isCurrent) { targetRowTop = rowY; targetRowHeight = row.height; }
|
||||
rowY += row.height + 10;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,18 @@ export function compileRules(json) {
|
|||
if (techs[p]) need(t.cost > techs[p].cost, `tech ${t.id} costs no more than its prereq ${p}`);
|
||||
}
|
||||
}
|
||||
// A rung (same field + tier) can hold more than one tech — MOO1-style
|
||||
// alternatives, where researching either one clears the rung. That only
|
||||
// has one well-defined "is this rung cleared" answer if every tech sharing
|
||||
// a rung unlocks under the same condition.
|
||||
const rungPrereq = {};
|
||||
for (const t of json.techs) {
|
||||
const key = `${t.field}:${t.tier}`;
|
||||
const prereq = t.prereqs[0] ?? null;
|
||||
if (!(key in rungPrereq)) rungPrereq[key] = { first: t.id, prereq };
|
||||
else need(rungPrereq[key].prereq === prereq,
|
||||
`tech ${t.id} shares tier ${t.tier} of ${t.field} with ${rungPrereq[key].first} but has a different prereq`);
|
||||
}
|
||||
if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`);
|
||||
|
||||
// --- cross references
|
||||
|
|
@ -175,12 +187,29 @@ export function compileRules(json) {
|
|||
// --- derived indexes
|
||||
|
||||
// Techs grouped by field, ordered by tier — the research screen renders these
|
||||
// columns directly, and the AI walks them to find "the next thing".
|
||||
// columns directly, and the AI walks them to find "the next thing". Sort is
|
||||
// stability-guaranteed (ES2019+), so techs sharing a tier keep their
|
||||
// json.techs array order — nextResearchTarget's auto-pick among rung
|
||||
// alternatives relies on that, which is why a new branch tech must be
|
||||
// inserted AFTER its veteran sibling in the source data, not before.
|
||||
const techsByField = {};
|
||||
for (const f of Object.keys(techFields)) techsByField[f] = [];
|
||||
for (const t of json.techs) techsByField[t.field].push(t);
|
||||
for (const f of Object.keys(techsByField)) techsByField[f].sort((a, b) => a.tier - b.tier);
|
||||
|
||||
// Rungs: techs sharing a (field, tier). Most rungs are size 1 (the linear
|
||||
// chain every field started as); branching tiers have 2+. canResearch and
|
||||
// rollTechAvailability key off this instead of comparing tiers directly.
|
||||
const techRungsByField = {};
|
||||
for (const f of Object.keys(techFields)) {
|
||||
const byTier = {};
|
||||
for (const t of techsByField[f]) (byTier[t.tier] ??= []).push(t);
|
||||
techRungsByField[f] = Object.keys(byTier)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map((tier) => ({ tier, techs: byTier[tier] }));
|
||||
}
|
||||
|
||||
// What each tech unlocks — powers UI hovers and the "every tech matters"
|
||||
// verify check.
|
||||
const gates = {};
|
||||
|
|
@ -219,6 +248,7 @@ export function compileRules(json) {
|
|||
difficultyList: json.difficulties,
|
||||
techRank: rank,
|
||||
techsByField,
|
||||
techRungsByField,
|
||||
techGates: gates,
|
||||
colonizableTypes,
|
||||
sizeWeights: weightedPick(json.planetSizes),
|
||||
|
|
@ -236,11 +266,30 @@ export function compileRules(json) {
|
|||
// Research cost for a tech, scaled by how many techs the empire already knows
|
||||
// in that field. MOO-style: each field gets more expensive as you climb it, so
|
||||
// spreading research wide is cheaper than driving one field to the end.
|
||||
// `researchFactor` is where a species' racial skill belongs — see
|
||||
// techCostFactor() below.
|
||||
export function techCost(rules, tech, knownInField, researchFactor = 1) {
|
||||
const drag = 1 + 0.05 * Math.max(0, knownInField - tech.tier);
|
||||
return Math.round(tech.cost * drag * researchFactor);
|
||||
}
|
||||
|
||||
// MOO1's racial-skill rule, corrected: Poor/Average/Good/Excellent changes
|
||||
// what a tech COSTS, never whether it shows up (that is rollTechAvailability,
|
||||
// currently still keyed off this same techAffinity number as a stopgap — see
|
||||
// its own comment — pending the flat-roll rework tracked for the branching
|
||||
// tech tree). Bucketed from the species' existing techAffinity rating so no
|
||||
// new per-species data was needed to land the cost side of this fix.
|
||||
const COST_TIERS = [
|
||||
[0.85, 1.25], // Poor
|
||||
[1.05, 1], // Average
|
||||
[1.25, 0.8], // Good
|
||||
[Infinity, 0.6], // Excellent
|
||||
];
|
||||
export function techCostFactor(spec, field) {
|
||||
const affinity = spec.techAffinity[field] ?? 1;
|
||||
return COST_TIERS.find(([ceiling]) => affinity < ceiling)[1];
|
||||
}
|
||||
|
||||
// Turn -> in-fiction year. MOO starts in 2300 and runs a year per turn.
|
||||
export function turnToYear(turn) { return 2300 + turn; }
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { COLORS } from '../../config.js';
|
||||
import { coloniesAt, habitableForEmpire } from './VegaLogic.js';
|
||||
import { techCost } from './VegaRules.js';
|
||||
import { techCost, techCostFactor } from './VegaRules.js';
|
||||
|
||||
// Hover tooltip for a star on the map. `viewerEmp` is the human empire (or
|
||||
// null in observer mode, which sees everything). An unexplored star only
|
||||
|
|
@ -81,7 +81,8 @@ export function describeTechTooltip(rules, state, emp, tech) {
|
|||
],
|
||||
};
|
||||
}
|
||||
const cost = techCost(rules, tech, emp.knownInField[tech.field]);
|
||||
const spec = rules.species[emp.speciesId];
|
||||
const cost = techCost(rules, tech, emp.knownInField[tech.field], techCostFactor(spec, tech.field));
|
||||
return {
|
||||
title: tech.name,
|
||||
titleColor: COLORS.goldHex,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { compileRules, techCost, markNumeral } from '../src/games/mastervega/VegaRules.js';
|
||||
import { compileRules, techCost, techCostFactor, markNumeral } from '../src/games/mastervega/VegaRules.js';
|
||||
import { generateGalaxy, isConnected, parsecs, mulberry32, PARSEC_PX } from '../src/games/mastervega/VegaGalaxyGen.js';
|
||||
import * as Ships from '../src/games/mastervega/VegaShips.js';
|
||||
import * as Combat from '../src/games/mastervega/VegaCombat.js';
|
||||
|
|
@ -131,6 +131,103 @@ section('1. Rules integrity');
|
|||
check('research costs climb across a field', c9 > c0 * 50, `${c0} -> ${c9}`);
|
||||
check('markNumeral covers Mark VII', markNumeral(7) === 'VII');
|
||||
|
||||
// Racial skill (MOO1-corrected): Poor/Average/Good/Excellent changes what a
|
||||
// tech COSTS, bucketed from the species' existing techAffinity rating.
|
||||
{
|
||||
check('a Poor-field bucket costs 125%', techCostFactor(RULES.species.ursaal, 'computers') === 1.25);
|
||||
check('an Average-field bucket costs 100%', techCostFactor(RULES.species.human, 'weapons') === 1);
|
||||
check('a Good-field bucket costs 80%', techCostFactor(RULES.species.umbrix, 'computers') === 0.8);
|
||||
check('an Excellent-field bucket costs 60%', techCostFactor(RULES.species.cerebrai, 'computers') === 0.6);
|
||||
const tech = RULES.techs.electroniccomputer;
|
||||
const base = techCost(RULES, tech, 0);
|
||||
const excellent = techCost(RULES, tech, 0, techCostFactor(RULES.species.cerebrai, 'computers'));
|
||||
const poor = techCost(RULES, tech, 0, techCostFactor(RULES.species.ursaal, 'computers'));
|
||||
check('an Excellent species pays less than the base cost', excellent < base, `${excellent} vs ${base}`);
|
||||
check('a Poor species pays more than the base cost', poor > base, `${poor} vs ${base}`);
|
||||
// Every species must land in exactly one of the four buckets for every
|
||||
// field — a NaN or unbucketed affinity would silently zero out cost.
|
||||
for (const s of RULES.speciesList) {
|
||||
for (const f of Object.keys(RULES.techFields)) {
|
||||
const factor = techCostFactor(s, f);
|
||||
check(`${s.id}.${f} cost factor is one of the four MOO1 buckets`,
|
||||
[1.25, 1, 0.8, 0.6].includes(factor), `${factor}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Branching-tier rung gating (MOO1-style): a rung clears once ANY sibling
|
||||
// is known, not all of them, and the un-picked sibling stays researchable
|
||||
// (never force-required) afterward.
|
||||
{
|
||||
const branchedField = 'weapons';
|
||||
const rungTier = RULES.techs.protontorpedoes.tier; // 2 — same tier as neutronpellet
|
||||
const emp = {
|
||||
known: {}, available: Object.fromEntries(RULES.techList.map((t) => [t.id, true])),
|
||||
};
|
||||
const state = { empires: [emp] };
|
||||
const e = 0;
|
||||
// Grant everything below the branched rung so it's the live edge.
|
||||
for (const t of RULES.techsByField[branchedField]) {
|
||||
if (t.tier < rungTier) emp.known[t.id] = true;
|
||||
}
|
||||
check('branched rung is open before either sibling is known',
|
||||
Logic.canResearch(RULES, state, e, RULES.techs.neutronpellet)
|
||||
&& Logic.canResearch(RULES, state, e, RULES.techs.protontorpedoes));
|
||||
emp.known.neutronpellet = true; // pick ONE sibling
|
||||
const nextTier = RULES.techsByField[branchedField].find((t) => t.tier === rungTier + 1);
|
||||
check('the rung clears after only ONE sibling is known', Logic.canResearch(RULES, state, e, nextTier));
|
||||
check('the un-picked sibling stays researchable, not force-required',
|
||||
Logic.canResearch(RULES, state, e, RULES.techs.protontorpedoes));
|
||||
|
||||
// setResearchTarget switches emp.researching[field] without touching
|
||||
// emp.beakers[field] — RP banked in a field is a shared pool.
|
||||
emp.researching = { weapons: null };
|
||||
emp.beakers = { weapons: 137 };
|
||||
check('setResearchTarget accepts an open sibling',
|
||||
Logic.setResearchTarget(RULES, state, e, 'weapons', 'protontorpedoes'));
|
||||
check('setResearchTarget points researching at the chosen tech',
|
||||
emp.researching.weapons === 'protontorpedoes');
|
||||
check('setResearchTarget leaves banked beakers untouched', emp.beakers.weapons === 137);
|
||||
check('setResearchTarget rejects a tech gated by an uncleared lower rung',
|
||||
!Logic.setResearchTarget(RULES, state, e, 'weapons', 'deathray'));
|
||||
}
|
||||
|
||||
// Corrected MOO1 availability roll: flat per-tech odds regardless of
|
||||
// racial skill (skill affects cost only, techCostFactor above), 75% for
|
||||
// Cerebrai, 50% for everyone else, and a branched rung can never end up
|
||||
// with zero available techs. Statistical — one roll proves nothing.
|
||||
{
|
||||
const TRIALS = 4000;
|
||||
const rollRate = (spec) => {
|
||||
let availableCount = 0;
|
||||
let totalCount = 0;
|
||||
let everEmpty = false;
|
||||
for (let seed = 1; seed <= TRIALS; seed += 1) {
|
||||
const state = { rngState: (seed * 2654435761) | 0 };
|
||||
const available = Logic.rollTechAvailability(state, RULES, {}, spec);
|
||||
for (const field of Object.keys(RULES.techFields)) {
|
||||
for (const rung of RULES.techRungsByField[field]) {
|
||||
if (rung.techs.length > 1 && rung.techs.every((t) => !available[t.id])) everEmpty = true;
|
||||
}
|
||||
}
|
||||
for (const t of RULES.techList) {
|
||||
if (t.tier === 0) continue;
|
||||
totalCount += 1;
|
||||
if (available[t.id]) availableCount += 1;
|
||||
}
|
||||
}
|
||||
return { rate: availableCount / totalCount, everEmpty };
|
||||
};
|
||||
const human = rollRate(RULES.species.human);
|
||||
check('human (flat 50%) empirical availability rate is close to 50%',
|
||||
human.rate > 0.45 && human.rate < 0.55, `${human.rate.toFixed(3)}`);
|
||||
check('no branched rung is ever left with zero available techs (human roll)', !human.everEmpty);
|
||||
const cerebrai = rollRate(RULES.species.cerebrai);
|
||||
check("Cerebrai's (75%) empirical availability rate is close to 75% and above human's",
|
||||
cerebrai.rate > 0.70 && cerebrai.rate < 0.80 && cerebrai.rate > human.rate, `${cerebrai.rate.toFixed(3)}`);
|
||||
check('no branched rung is ever left with zero available techs (Cerebrai roll)', !cerebrai.everEmpty);
|
||||
}
|
||||
|
||||
// Species sanity: at least one clear strength each, and no species is
|
||||
// strictly better than another on every axis.
|
||||
for (const s of RULES.speciesList) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue