From bfef7ba59476d04e560bbe87f84ea54491ad4fe1 Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 16 Aug 2026 15:27:53 -0700 Subject: [PATCH 01/17] Capture a Factorio prototype oracle from the game itself The planner hardcodes entity names, item names, direction values and entity geometry. When Factorio changes any of those, nothing here notices: plans keep generating, they are just wrong. Factorio 2.0 renamed effectivity-module-N to efficiency-module-N and widened directions from 8-way to 16-way, and both went unnoticed. Rather than trust memory or the wiki, pull the facts out of the game. It ships four machine-readable sources, and this merges them into one small fixture: factorio --dump-data prototypes: names, boxes, pipe connections, pole supply and wire reach, beacon stats data/*/migrations/*.json every rename, as a table rather than a guess doc-html/runtime-api.json defines.direction, stamped to the install data/changelog.txt consulted by hand, not captured The capture runs with user mods disabled. Mods rewrite prototypes freely, so a capture that loads them describes one person's modded game rather than Factorio. Only core, base and the bundled DLC load. CI never runs the capture and needs no Factorio install: it reads the committed fixture. That is why the fixture is committed rather than generated on demand. The fixture stores raw prototype values, never values derived from them. Factorio's rule for turning supply_area_distance into a covered tile area is not one formula (poles come out as 2*distance, a beacon as 2*distance plus its own footprint, and substation's collision box fits neither), so encoding a guessed formula would produce a fixture that is confidently wrong and drifts where no test can see it. No source or planner behavior changes here. Pointing the fixture at the current constants already reports what is stale, which is the next commit's job: ItemNames.EfficiencyModule3 "effectivity-module-3" -> efficiency-module-3 ModuleSelect.vue effectivity-module{,-2,-3} -> efficiency-module* Direction.Right=2 2 now means northeast; east is 4 Direction.Down=4 4 now means east; south is 8 Direction.Left=6 6 now means southeast; west is 12 Verified: runs headless (exit 0), loads only core/base/DLC, and two independent captures are byte-identical. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 29 + .../OilField/factorio-oracle.json | 658 ++++++++++++++++++ tools/capture-factorio-oracle.sh | 143 ++++ tools/trim-factorio-oracle.py | 229 ++++++ 4 files changed, 1059 insertions(+) create mode 100644 test/FactorioTools.Test/OilField/factorio-oracle.json create mode 100755 tools/capture-factorio-oracle.sh create mode 100644 tools/trim-factorio-oracle.py diff --git a/CLAUDE.md b/CLAUDE.md index 8c956e2e..c4590689 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,35 @@ The `transpile-lua` CI job runs that script, fails if the committed `src/lua` no - Lua 5.2 manual: - Prefer official Factorio docs over forum/blog/wiki advice when changing runtime behavior. +### The Factorio oracle (re-capture after a game update) + +The planner hardcodes entity names, item names, direction values, and entity sizes. When Factorio changes any of those, nothing here notices - plans keep generating, they are just wrong. Factorio 2.0 renamed `effectivity-module-N` to `efficiency-module-N` and widened directions from 8-way to 16-way, and both went unnoticed for a long time. + +So don't trust memory or the wiki. The game is the only authority on what the game accepts, and it ships four machine-readable sources: + +| Source | Answers | +| --- | --- | +| `factorio --dump-data` | Every prototype: names, collision boxes, pipe connections, pole supply and wire reach, beacon stats | +| `data/*/migrations/*.json` | Every rename, as a table. This is a complete list, not a guess | +| `doc-html/runtime-api.json` | `defines.*` values, version-stamped to the install | +| `data/changelog.txt` | Behavior changes per patch | + +`tools/capture-factorio-oracle.sh` pulls all four into `test/FactorioTools.Test/OilField/factorio-oracle.json`: + +```bash +tools/capture-factorio-oracle.sh # auto-detects a Steam or /Applications install +tools/capture-factorio-oracle.sh --factorio /path/to/factorio.app +``` + +Notes on using it: + +- **Re-capture after every Factorio update and commit the diff.** A changed fixture is the signal that a hardcoded constant needs review. +- **It runs with user mods disabled** (`--mod-directory` pointed at an empty directory). Mods rewrite prototypes freely, so a capture that loads them describes one person's modded game rather than Factorio. The script prints which mods loaded; expect only `core base elevated-rails quality recycler space-age`. +- **CI never runs the capture** and needs no Factorio install - it reads the committed fixture. That is why the fixture is committed rather than generated on demand. +- Capture needs `python3` (for JSON trimming) and a Factorio install. Neither is needed to build or test. +- `EntityNames.AaiIndustry` names come from a mod, so they are deliberately absent from a vanilla capture. That is expected, not drift. +- Output is deterministic: two captures of the same install are byte-identical. + ## Testing notes - Tests use **xUnit v3 + Verify** (`xunit.v3` + `Verify.XunitV3`). Many tests assert against committed `*.verified.txt` snapshots under `test/FactorioTools.Test/OilField`. When behavior legitimately changes, update snapshots via Verify's accept workflow (received vs verified) rather than editing expected files by hand. A stale committed snapshot **does** fail on CI: `AutoVerify` is off there, so the test itself throws `VerifyException` with the diff (confirmed on a real runner, not just inferred - note that setting `CI`/`GITHUB_ACTIONS` locally does *not* reproduce the build-server detection, so local simulation of this is misleading). A "Check no Verify snapshots drifted" step backs that up by failing if `dotnet test` leaves any `*.verified.*` file dirty, in case the detection ever regresses. Commit regenerated snapshots with your change. diff --git a/test/FactorioTools.Test/OilField/factorio-oracle.json b/test/FactorioTools.Test/OilField/factorio-oracle.json new file mode 100644 index 00000000..23ad7d22 --- /dev/null +++ b/test/FactorioTools.Test/OilField/factorio-oracle.json @@ -0,0 +1,658 @@ +{ + "_comment": "Generated by tools/capture-factorio-oracle.sh. Do not hand-edit. Raw Factorio prototype values that the oil field planner depends on. Re-capture after a Factorio update and commit the diff.", + "captureInfo": { + "factorioVersion": "2.1.14", + "loadedMods": [ + "base", + "core", + "elevated-rails", + "quality", + "recycler", + "space-age" + ] + }, + "directions": { + "east": 4, + "eastnortheast": 3, + "eastsoutheast": 5, + "north": 0, + "northeast": 2, + "northnortheast": 1, + "northnorthwest": 15, + "northwest": 14, + "south": 8, + "southeast": 6, + "southsoutheast": 7, + "southsouthwest": 9, + "southwest": 10, + "west": 12, + "westnorthwest": 13, + "westsouthwest": 11 + }, + "entities": { + "beacon": { + "allowed_effects": [ + "consumption", + "speed", + "pollution" + ], + "beacon_counter": "same_type", + "collision_box": [ + [ + -1.2, + -1.2 + ], + [ + 1.2, + 1.2 + ] + ], + "distribution_effectivity": 1.5, + "distribution_effectivity_bonus_per_quality_level": 0.2, + "energy_usage": "480kW", + "module_slots": 2, + "prototypeType": "beacon", + "selection_box": [ + [ + -1.5, + -1.5 + ], + [ + 1.5, + 1.5 + ] + ], + "supply_area_distance": 3 + }, + "big-electric-pole": { + "collision_box": [ + [ + -0.65, + -0.65 + ], + [ + 0.65, + 0.65 + ] + ], + "maximum_wire_distance": 32, + "prototypeType": "electric-pole", + "selection_box": [ + [ + -1, + -1 + ], + [ + 1, + 1 + ] + ], + "supply_area_distance": 2 + }, + "heat-pipe": { + "collision_box": [ + [ + -0.3, + -0.3 + ], + [ + 0.3, + 0.3 + ] + ], + "prototypeType": "heat-pipe", + "selection_box": [ + [ + -0.5, + -0.5 + ], + [ + 0.5, + 0.5 + ] + ] + }, + "medium-electric-pole": { + "collision_box": [ + [ + -0.15, + -0.15 + ], + [ + 0.15, + 0.15 + ] + ], + "maximum_wire_distance": 9, + "prototypeType": "electric-pole", + "selection_box": [ + [ + -0.5, + -0.5 + ], + [ + 0.5, + 0.5 + ] + ], + "supply_area_distance": 3.5 + }, + "pipe": { + "collision_box": [ + [ + -0.29, + -0.29 + ], + [ + 0.29, + 0.29 + ] + ], + "fluid_box": { + "pipe_connections": [ + { + "direction": 0, + "position": [ + 0, + 0 + ] + }, + { + "direction": 4, + "position": [ + 0, + 0 + ] + }, + { + "direction": 8, + "position": [ + 0, + 0 + ] + }, + { + "direction": 12, + "position": [ + 0, + 0 + ] + } + ] + }, + "prototypeType": "pipe", + "selection_box": [ + [ + -0.5, + -0.5 + ], + [ + 0.5, + 0.5 + ] + ] + }, + "pipe-to-ground": { + "collision_box": [ + [ + -0.29, + -0.29 + ], + [ + 0.29, + 0.2 + ] + ], + "fluid_box": { + "pipe_connections": [ + { + "direction": 0, + "position": [ + 0, + 0 + ] + }, + { + "connection_type": "underground", + "direction": 8, + "max_underground_distance": 10, + "position": [ + 0, + 0 + ] + } + ] + }, + "prototypeType": "pipe-to-ground", + "selection_box": [ + [ + -0.5, + -0.5 + ], + [ + 0.5, + 0.5 + ] + ] + }, + "pumpjack": { + "allowed_effects": [ + "consumption", + "speed", + "productivity", + "pollution" + ], + "collision_box": [ + [ + -1.2, + -1.2 + ], + [ + 1.2, + 1.2 + ] + ], + "energy_usage": "90kW", + "module_slots": 2, + "output_fluid_box": { + "pipe_connections": [ + { + "direction": 0, + "flow_direction": "output", + "positions": [ + [ + 1, + -1 + ], + [ + 1, + 1 + ], + [ + -1, + 1 + ], + [ + -1, + -1 + ] + ] + } + ] + }, + "prototypeType": "mining-drill", + "selection_box": [ + [ + -1.5, + -1.5 + ], + [ + 1.5, + 1.5 + ] + ] + }, + "small-electric-pole": { + "collision_box": [ + [ + -0.15, + -0.15 + ], + [ + 0.15, + 0.15 + ] + ], + "maximum_wire_distance": 7.5, + "prototypeType": "electric-pole", + "selection_box": [ + [ + -0.4, + -0.4 + ], + [ + 0.4, + 0.4 + ] + ], + "supply_area_distance": 2.5 + }, + "stone-wall": { + "collision_box": [ + [ + -0.29, + -0.29 + ], + [ + 0.29, + 0.29 + ] + ], + "prototypeType": "wall", + "selection_box": [ + [ + -0.5, + -0.5 + ], + [ + 0.5, + 0.5 + ] + ] + }, + "substation": { + "collision_box": [ + [ + -0.7, + -0.7 + ], + [ + 0.7, + 0.7 + ] + ], + "maximum_wire_distance": 18, + "prototypeType": "electric-pole", + "selection_box": [ + [ + -1, + -1 + ], + [ + 1, + 1 + ] + ], + "supply_area_distance": 9 + } + }, + "modules": [ + "efficiency-module", + "efficiency-module-2", + "efficiency-module-3", + "productivity-module", + "productivity-module-2", + "productivity-module-3", + "quality-module", + "quality-module-2", + "quality-module-3", + "speed-module", + "speed-module-2", + "speed-module-3" + ], + "renames": { + "airborne-pollutant": { + "pollen": "spores" + }, + "asteroid-chunk": { + "interstellar-asteroid-chunk": "promethium-asteroid-chunk" + }, + "decorative": { + "puberty-decal": "cracked-mud-decal", + "rock-medium": "medium-rock", + "rock-small": "small-rock", + "rock-tiny": "tiny-rock", + "sand-rock-medium": "medium-sand-rock", + "sand-rock-small": "small-sand-rock" + }, + "entity": { + "big-interstellar-asteroid": "big-promethium-asteroid", + "bio-chemical-plant": "biochamber", + "biter-lab": "biolab", + "black-rock-big": "volcanic-rock-big", + "bulk-inserter": "stack-inserter", + "cargo-bay-extractor": "landing-pad-unloading-bay", + "cryogenic-vault": "cryogenic-archive", + "curve-a-rail-remnants": "curved-rail-a-remnants", + "curve-b-rail-remnants": "curved-rail-b-remnants", + "curved-rail-remnants": "legacy-curved-rail-remnants", + "drop-pod": "cargo-pod", + "drop-pod-container": "cargo-pod-container", + "effectivity-module-2-recycling": "efficiency-module-2-recycling", + "effectivity-module-3-recycling": "efficiency-module-3-recycling", + "effectivity-module-recycling": "efficiency-module-recycling", + "filter-inserter": "fast-inserter", + "fulgora-rock-big": "big-fulgora-rock", + "gleba-enemy-stomper": "small-stomper-pentapod", + "gleba-enemy-strafer": "small-strafer-pentapod", + "gleba-enemy-wriggler": "small-wriggler-pentapod", + "gleba-enemy-wriggler-premature": "small-wriggler-pentapod-premature", + "gumdew": "hairy-clubnub", + "hairy-clubnub": "funneltrunk", + "helicorn-plant": "jellystem", + "huge-interstellar-asteroid": "huge-promethium-asteroid", + "kink-rail-remnants": "half-diagonal-rail-remnants", + "logistic-chest-active-provider": "active-provider-chest", + "logistic-chest-buffer": "buffer-chest", + "logistic-chest-passive-provider": "passive-provider-chest", + "logistic-chest-requester": "requester-chest", + "logistic-chest-storage": "storage-chest", + "medium-interstellar-asteroid": "medium-promethium-asteroid", + "new-straight-rail-remnants": "straight-rail-remnants", + "oilycoil": "boompuff", + "onionspire": "lickmaw", + "platform-cargo-bay": "cargo-bay", + "rock-big": "big-rock", + "rock-huge": "huge-rock", + "rock-medium": "medium-rock", + "rocket-engine": "thruster", + "sand-rock-big": "big-sand-rock", + "scrap-heap": "fulgoran-ruin-big", + "small-interstellar-asteroid": "small-promethium-asteroid", + "stack-filter-inserter": "bulk-inserter", + "stack-inserter": "bulk-inserter", + "steampuft": "stingfrond", + "straight-rail-remnants": "legacy-straight-rail-remnants", + "tesla-tower": "tesla-turret", + "trash": "scrap", + "trash-heap": "scrap-heap", + "tungsten-loader": "turbo-loader", + "tungsten-splitter": "turbo-splitter", + "tungsten-transport-belt": "turbo-transport-belt", + "tungsten-underground-belt": "turbo-underground-belt", + "volcanic-rock-big": "big-volcanic-rock", + "volcanic-rock-huge": "huge-volcanic-rock" + }, + "equipment": { + "fusion-reactor-equipment": "fission-reactor-equipment" + }, + "fluid": { + "fuel": "thruster-fuel", + "oxidiser": "thruster-oxidizer" + }, + "item": { + "bio-chemical-plant": "biochamber", + "biter-lab": "biolab", + "bulk-inserter": "stack-inserter", + "cargo-bay-extractor": "landing-pad-unloading-bay", + "effectivity-module": "efficiency-module", + "effectivity-module-2": "efficiency-module-2", + "effectivity-module-3": "efficiency-module-3", + "empty-barrel": "barrel", + "filter-inserter": "fast-inserter", + "fusion-reactor-equipment": "fission-reactor-equipment", + "interstellar-asteroid-chunk": "promethium-asteroid-chunk", + "interstellar-science-pack": "promethium-science-pack", + "jelly-yum": "bioflux", + "jellynut-seedable-soil": "artificial-jellynut-soil", + "logistic-chest-active-provider": "active-provider-chest", + "logistic-chest-buffer": "buffer-chest", + "logistic-chest-passive-provider": "passive-provider-chest", + "logistic-chest-requester": "requester-chest", + "logistic-chest-storage": "storage-chest", + "platform-cargo-bay": "cargo-bay", + "rocket-engine": "thruster", + "rts-tool": "spidertron-remote", + "stack-filter-inserter": "bulk-inserter", + "stack-inserter": "bulk-inserter", + "tesla-tower": "tesla-turret", + "trash": "scrap", + "tungsten-loader": "turbo-loader", + "tungsten-splitter": "turbo-splitter", + "tungsten-transport-belt": "turbo-transport-belt", + "tungsten-underground-belt": "turbo-underground-belt", + "used-up-uranium-fuel-cell": "depleted-uranium-fuel-cell", + "yumako-seedable-soil": "artificial-yumako-soil" + }, + "optimized-decorative": { + "ash-sand-dune": "volcanic-dune", + "black-rock-medium": "volcanic-rock-medium", + "black-rock-small": "volcanic-rock-small", + "black-rock-tiny": "volcanic-rock-tiny", + "fulgora-rock-medium": "medium-fulgora-rock", + "fulgora-rock-small": "small-fulgora-rock", + "fulgora-rock-tiny": "tiny-fulgora-rock", + "sulfur-tiny-rock": "tiny-sulfur-rock", + "volcanic-rock-medium": "medium-volcanic-rock", + "volcanic-rock-small": "small-volcanic-rock", + "volcanic-rock-tiny": "tiny-volcanic-rock" + }, + "recipe": { + "advanced-fuel": "advanced-thruster-fuel", + "advanced-oxidiser": "advanced-thruster-oxidizer", + "bio-chemical-plant": "biochamber", + "biter-eggs": "biter-egg", + "biter-lab": "biolab", + "biter-lab-recycling": "biolab-recycling", + "bulk-inserter": "stack-inserter", + "cargo-bay-extractor": "landing-pad-unloading-bay", + "cargo-bay-extractor-recycling": "landing-pad-unloading-bay-recycling", + "dung-and-biter-eggs": "biter-eggs", + "effectivity-module": "efficiency-module", + "effectivity-module-2": "efficiency-module-2", + "effectivity-module-3": "efficiency-module-3", + "empty-barrel": "barrel", + "empty-barrel-recycling": "barrel-recycling", + "fill-crude-oil-barrel": "crude-oil-barrel", + "fill-fluoroketone-cold-barrel": "fluoroketone-cold-barrel", + "fill-fluoroketone-hot-barrel": "fluoroketone-hot-barrel", + "fill-heavy-oil-barrel": "heavy-oil-barrel", + "fill-light-oil-barrel": "light-oil-barrel", + "fill-lubricant-barrel": "lubricant-barrel", + "fill-petroleum-gas-barrel": "petroleum-gas-barrel", + "fill-sulfuric-acid-barrel": "sulfuric-acid-barrel", + "fill-water-barrel": "water-barrel", + "interstellar-science-pack": "promethium-science-pack", + "jelly-yum": "bioflux", + "jellynut-seedable-soil": "artificial-jellynut-soil", + "jellynut-seedable-soil-recycling": "artificial-jellynut-soil-recycling", + "lava-processing": "molten-iron-from-lava", + "logistic-chest-active-provider": "active-provider-chest", + "logistic-chest-buffer": "buffer-chest", + "logistic-chest-passive-provider": "passive-provider-chest", + "logistic-chest-requester": "requester-chest", + "logistic-chest-storage": "storage-chest", + "molten-copper": "copper-ore-melting", + "molten-iron": "iron-ore-melting", + "nutrients-from-jelly-yum": "nutrients-from-bioflux", + "platform-cargo-bay": "cargo-bay", + "rocket-engine": "thruster", + "stack-inserter": "bulk-inserter", + "tesla-tower": "tesla-turret", + "tesla-tower-recycling": "tesla-turret-recycling", + "trash-recycling": "scrap-recycling", + "tungsten-loader": "turbo-loader", + "tungsten-splitter": "turbo-splitter", + "tungsten-transport-belt": "turbo-transport-belt", + "tungsten-underground-belt": "turbo-underground-belt", + "wood-processing": "tree-seed", + "yumako-seedable-soil": "artificial-yumako-soil", + "yumako-seedable-soil-recycling": "artificial-yumako-soil-recycling" + }, + "space-location": { + "bacchus": "gleba", + "interstellar": "shattered-planet" + }, + "technology": { + "advanced-electronics": "advanced-circuit", + "advanced-electronics-2": "processing-unit", + "bio-chemical-plant": "biochamber", + "biter-lab": "biolab", + "bulk-inserter": "stack-inserter", + "cargo-bay-extractor": "landing-pad-unloading-bay", + "combat-robotics": "defender", + "combat-robotics-2": "distractor", + "combat-robotics-3": "destroyer", + "effectivity-module": "efficiency-module", + "effectivity-module-2": "efficiency-module-2", + "effectivity-module-3": "efficiency-module-3", + "energy-weapons-damage-1": "laser-weapons-damage-1", + "energy-weapons-damage-2": "laser-weapons-damage-2", + "energy-weapons-damage-3": "laser-weapons-damage-3", + "energy-weapons-damage-4": "laser-weapons-damage-4", + "energy-weapons-damage-5": "laser-weapons-damage-5", + "energy-weapons-damage-6": "laser-weapons-damage-6", + "energy-weapons-damage-7": "laser-weapons-damage-7", + "gates": "gate", + "interstellar-science-pack": "promethium-science-pack", + "jellynut-seedable-soil": "artificial-jellynut-soil", + "laser-turret-speed-1": "laser-shooting-speed-1", + "laser-turret-speed-2": "laser-shooting-speed-2", + "laser-turret-speed-3": "laser-shooting-speed-3", + "laser-turret-speed-4": "laser-shooting-speed-4", + "laser-turret-speed-5": "laser-shooting-speed-5", + "laser-turret-speed-6": "laser-shooting-speed-6", + "laser-turret-speed-7": "laser-shooting-speed-7", + "laser-turrets": "laser-turret", + "optics": "lamp", + "stack-inserter": "bulk-inserter", + "stone-walls": "stone-wall", + "tanks": "tank", + "trash-recycling-productivity": "scrap-recycling-productivity", + "tungsten-transport-belt": "turbo-transport-belt", + "turrets": "gun-turret", + "yumako-seedable-soil": "artificial-yumako-soil" + }, + "tile": { + "ash-sand": "volcanic-ash-light", + "ash-sand-2": "volcanic-ash-dark", + "cold-cracks": "volcanic-cracks", + "cold-lava": "volcanic-folds", + "cold-lava-flat": "volcanic-folds-flat", + "deep-jellynut-mud": "wetland-jellynut", + "deep-yumako-mud": "wetland-yumako", + "dirt-5-v": "volcanic-soil-dark", + "dirt-6-v": "volcanic-soil-light", + "dirt-7-v": "volcanic-ash-soil", + "dirt-slime": "lowland-cream-cauliflower", + "fulgora-cliff-rock": "fulgoran-rock", + "gleba-lake": "wetland-blue-slime", + "hot-lava": "lava", + "hot-magma": "lava-hot", + "jagged-ground": "volcanic-jagged-ground", + "jellynut-seedable-soil": "artificial-jellynut-soil", + "molten-rock": "volcanic-cracks-hot", + "plateau-dirt": "ruins-sand", + "pumice-stones": "volcanic-pumice-stones", + "ruins-paving": "fulgoran-paving", + "ruins-sand": "fulgoran-dunes", + "ruins-sand-flat": "fulgoran-sand", + "ruins-scrap": "fulgoran-conduit", + "ruins-scrap-2": "fulgoran-machinery", + "ruins-walls": "fulgoran-walls", + "sandy-cracks": "volcanic-ash-cracks", + "sinking-sand": "oil-ocean-shallow", + "sinking-sand-dark": "oil-ocean-deep", + "smooth-stone": "volcanic-smooth-stone", + "smooth-stone-warm": "volcanic-smooth-stone-warm", + "snow": "snow-flat", + "snow-2": "snow-crests", + "volcanic-soil-1": "volcanic-soil-dark", + "volcanic-soil-2": "volcanic-soil-light", + "volcanic-soil-3": "volcanic-ash-soil", + "warm-cracks": "volcanic-cracks-warm", + "warm-lava": "volcanic-folds-warm", + "water-mud-green": "wetland-red-tentacle", + "water-mud-grey": "wetland-green-slime", + "wetland-grass": "lowland-olive-blubber", + "wetland-purple-tentacle": "wetland-red-tentacle", + "yumako-seedable-soil": "artificial-yumako-soil" + }, + "virtual-signal": { + "signal-any-fluid": "signal-fluid-parameter", + "signal-any-fuel": "signal-fuel-parameter", + "signal-any-item": "signal-item-parameter", + "signal-any-signal": "signal-signal-parameter" + } + } +} diff --git a/tools/capture-factorio-oracle.sh b/tools/capture-factorio-oracle.sh new file mode 100755 index 00000000..55b97e4f --- /dev/null +++ b/tools/capture-factorio-oracle.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# +# Captures an "oracle" of Factorio prototype facts that the oil field planner depends on, +# and writes it to a small committed JSON fixture. +# +# Why this exists +# --------------- +# The planner hardcodes entity names, item names, direction values and entity geometry. +# When Factorio changes any of those, nothing in this repo notices: plans keep generating, +# they are just wrong. Factorio 2.0 renamed "effectivity-module-N" to "efficiency-module-N" +# and moved directions from an 8-way to a 16-way encoding, and both went unnoticed here. +# +# So instead of trusting memory or the wiki, this pulls the facts out of the game itself. +# The game is the only authority on what the game accepts. +# +# What it is NOT +# -------------- +# This is maintainer-only tooling. It is not part of the build and CI never runs it. +# CI reads the committed fixture, which is why the fixture is committed rather than +# generated on demand - CI machines have no Factorio install and never will. +# +# Requirements +# ------------ +# - A Factorio install (Steam or standalone). Only needed to re-capture, not to build. +# - python3, for JSON trimming. Bash cannot do this legibly. +# +# Usage +# ----- +# tools/capture-factorio-oracle.sh +# tools/capture-factorio-oracle.sh --factorio /path/to/factorio.app --out some/file.json +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="$REPO_ROOT/test/FactorioTools.Test/OilField/factorio-oracle.json" +FACTORIO_APP="" +USER_DATA_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --factorio) FACTORIO_APP="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + --user-data-dir) USER_DATA_DIR="$2"; shift 2 ;; + -h|--help) sed -n '2,32p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +# --------------------------------------------------------------------------- +# 1. Locate the game. +# --------------------------------------------------------------------------- +if [[ -z "$FACTORIO_APP" ]]; then + for candidate in \ + "$HOME/Library/Application Support/Steam/steamapps/common/Factorio/factorio.app" \ + "/Applications/factorio.app" \ + "$HOME/.steam/steam/steamapps/common/Factorio" \ + "/opt/factorio" + do + if [[ -e "$candidate" ]]; then FACTORIO_APP="$candidate"; break; fi + done +fi + +if [[ -z "$FACTORIO_APP" || ! -e "$FACTORIO_APP" ]]; then + echo "Could not find Factorio. Pass --factorio ." >&2 + exit 1 +fi + +# macOS ships it as an .app bundle; Linux as a plain directory. +if [[ -d "$FACTORIO_APP/Contents" ]]; then + BIN="$FACTORIO_APP/Contents/MacOS/factorio" + DATA_DIR="$FACTORIO_APP/Contents/data" + DOC_DIR="$FACTORIO_APP/Contents/doc-html" +elif [[ -x "$FACTORIO_APP" ]]; then + BIN="$FACTORIO_APP" + DATA_DIR="$(dirname "$FACTORIO_APP")/../data" + DOC_DIR="$(dirname "$FACTORIO_APP")/../doc-html" +else + BIN="$FACTORIO_APP/bin/x64/factorio" + DATA_DIR="$FACTORIO_APP/data" + DOC_DIR="$FACTORIO_APP/doc-html" +fi + +for required in "$BIN" "$DATA_DIR" "$DOC_DIR"; do + if [[ ! -e "$required" ]]; then + echo "Expected to find '$required' but it is missing. Is --factorio pointing at a real install?" >&2 + exit 1 + fi +done + +VERSION="$("$BIN" --version | head -1 | sed -E 's/^Version: ([0-9.]+).*/\1/')" +echo "Factorio $VERSION at $FACTORIO_APP" + +# --------------------------------------------------------------------------- +# 2. Dump data.raw with user mods disabled. +# +# This matters more than it looks. Running --dump-data with the default mod directory +# loads whatever the player happens to have installed, and mods freely rewrite prototypes. +# An oracle captured that way describes one person's modded game, not Factorio. Pointing +# --mod-directory at an empty directory leaves only core, base and the bundled DLC +# (elevated-rails, quality, recycler, space-age), which is what the planner targets. +# --------------------------------------------------------------------------- +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$WORK/mods" +printf '{"mods":[{"name":"base","enabled":true}]}\n' > "$WORK/mods/mod-list.json" + +echo "Dumping data.raw (mods disabled)..." +DUMP_LOG="$WORK/dump.log" +if ! "$BIN" --dump-data --mod-directory "$WORK/mods" > "$DUMP_LOG" 2>&1; then + echo "factorio --dump-data failed:" >&2 + tail -30 "$DUMP_LOG" >&2 + exit 1 +fi + +# Report which mods actually loaded, so a contaminated capture is visible rather than silent. +LOADED_MODS="$(grep -oE 'Loading mod [A-Za-z0-9_-]+' "$DUMP_LOG" | sed 's/Loading mod //' | sort -u | tr '\n' ' ')" +echo "Loaded: $LOADED_MODS" + +if [[ -z "$USER_DATA_DIR" ]]; then + for candidate in \ + "$HOME/Library/Application Support/factorio" \ + "$HOME/.factorio" \ + "$FACTORIO_APP" + do + if [[ -f "$candidate/script-output/data-raw-dump.json" ]]; then USER_DATA_DIR="$candidate"; break; fi + done +fi + +DUMP="$USER_DATA_DIR/script-output/data-raw-dump.json" +if [[ ! -f "$DUMP" ]]; then + echo "Could not find data-raw-dump.json. Pass --user-data-dir ." >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# 3. Trim ~28MB of prototypes down to the handful of facts the planner relies on. +# --------------------------------------------------------------------------- +echo "Trimming to fixture..." +DUMP="$DUMP" DATA_DIR="$DATA_DIR" DOC_DIR="$DOC_DIR" \ +VERSION="$VERSION" LOADED_MODS="$LOADED_MODS" OUT="$OUT" \ + python3 "$REPO_ROOT/tools/trim-factorio-oracle.py" + +echo "Wrote $OUT" diff --git a/tools/trim-factorio-oracle.py b/tools/trim-factorio-oracle.py new file mode 100644 index 00000000..39f5563e --- /dev/null +++ b/tools/trim-factorio-oracle.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +Trims a full Factorio data.raw dump down to the small set of facts the oil field +planner actually depends on, and merges in two other authoritative sources: +the machine-readable runtime API (for direction values) and the game's own +migration files (for renames). + +Invoked by tools/capture-factorio-oracle.sh, which supplies every input as an +environment variable. It is not meant to be run directly. + +Design note: this stores RAW prototype values, not values derived from them. + +It is tempting to store, say, "smallElectricPoleSupplyWidth: 5" so a test can +compare it to OilFieldOptions directly. Resist that. Factorio's rule for turning +supply_area_distance into a covered tile area is not a single formula - poles +come out as 2*distance while a beacon comes out as 2*distance + its own footprint, +and substation's collision box does not fit either reading. Encoding a guessed +formula here would produce a fixture that is confidently wrong and that drifts +in a way no test can see. Raw values have no such problem: if Factorio changes +supply_area_distance, the raw number changes and the diff is undeniable. +""" + +import json +import os +import sys +from pathlib import Path + +# Entities the planner names, places, or reasons about the size of. +# Keys are the prototype names as the game knows them. +WANTED_ENTITIES = [ + "pumpjack", + "pipe", + "pipe-to-ground", + "small-electric-pole", + "medium-electric-pole", + "big-electric-pole", + "substation", + "beacon", + "heat-pipe", + "stone-wall", +] + +# Prototype fields worth pinning. Anything absent on a given prototype is simply +# skipped, so this list can stay a single flat set rather than per-type tables. +WANTED_FIELDS = [ + "collision_box", + "selection_box", + "tile_width", + "tile_height", + "supply_area_distance", + "maximum_wire_distance", + "distribution_effectivity", + "distribution_effectivity_bonus_per_quality_level", + "module_slots", + "beacon_counter", + "allowed_effects", + "energy_usage", +] + +# Deliberately NOT captured: the beacon "profile" array, which is how Factorio 2.0 models +# diminishing returns as more beacons reach the same machine. It is ~100 floats carrying +# binary representation noise (0.44719999999999993), so it would be a third of this file +# and would churn the diff for no reason. +# +# It is left out because the planner does not model diminishing returns at all - it scores +# a plan by counting beacon effects, as though every beacon contributed equally. That is a +# real gap, but it is a planner gap, not a drift-detection gap: pinning a number nothing +# reads would not catch anything. If the planner ever learns to score beacon profiles, +# capture this then. + +# Fluid box keys worth keeping. Everything else on a pipe connection is graphics +# (pipe_covers alone is several hundred lines of sprite definitions per entity). +WANTED_CONNECTION_FIELDS = [ + "connection_type", + "direction", + "position", + "positions", + "flow_direction", + "max_underground_distance", +] + + +def fail(message): + print(f"trim-factorio-oracle: {message}", file=sys.stderr) + sys.exit(1) + + +def env(name): + value = os.environ.get(name) + if not value: + fail(f"missing required environment variable {name}") + return value + + +def find_prototype(data_raw, name): + """data.raw is keyed by prototype TYPE, not name, and the planner's entities are + scattered across types you would not guess (pumpjack is a mining-drill, stone-wall + is a wall). Searching every type is cheaper than maintaining a name->type table + that silently rots when Factorio reclassifies something. + + The catch is that most of these names exist TWICE: once as the placeable entity and + once as the item you carry. data.raw["item"]["pumpjack"] is a real prototype, it is + just the wrong one, and it is missing every geometry field we care about. Preferring + the candidate that has a collision_box picks the entity without needing a hardcoded + name->type table.""" + candidates = [ + (category, prototypes[name]) + for category, prototypes in data_raw.items() + if isinstance(prototypes, dict) and name in prototypes + ] + for category, prototype in candidates: + if isinstance(prototype, dict) and "collision_box" in prototype: + return category, prototype + return candidates[0] if candidates else (None, None) + + +def trim_connections(fluid_box): + connections = [] + for connection in fluid_box.get("pipe_connections", []): + connections.append({k: connection[k] for k in WANTED_CONNECTION_FIELDS if k in connection}) + return connections + + +def trim_entity(category, prototype): + trimmed = {"prototypeType": category} + for field in WANTED_FIELDS: + if field in prototype: + trimmed[field] = prototype[field] + + for box_name in ("fluid_box", "output_fluid_box", "input_fluid_box"): + box = prototype.get(box_name) + if isinstance(box, dict): + connections = trim_connections(box) + if connections: + trimmed[box_name] = {"pipe_connections": connections} + return trimmed + + +def collect_directions(doc_dir): + """defines.direction is the authoritative answer to 'what number means east'. + Factorio 2.0 widened this from 8 to 16 values, which silently reinterpreted every + direction in every blueprint.""" + api_path = Path(doc_dir) / "runtime-api.json" + if not api_path.is_file(): + fail(f"expected runtime API at {api_path}") + + api = json.loads(api_path.read_text(encoding="utf-8")) + for define in api.get("defines", []): + if define.get("name") == "direction": + return {v["name"]: v["order"] for v in define.get("values", [])} + fail("could not find defines.direction in runtime-api.json") + + +def collect_renames(data_dir): + """Every rename the game knows about, taken from its own migration files. + + This is the difference between 'I think effectivity-module was renamed' and + knowing it, along with every other rename shipped in the same window. + The .lua migrations are skipped: they are arbitrary code, not data. + """ + renames = {} + for migration in sorted(Path(data_dir).glob("*/migrations/*.json")): + try: + content = json.loads(migration.read_text(encoding="utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + if not isinstance(content, dict): + continue + for category, pairs in content.items(): + if not isinstance(pairs, list): + continue + for pair in pairs: + if isinstance(pair, list) and len(pair) == 2 and all(isinstance(x, str) for x in pair): + renames.setdefault(category, {})[pair[0]] = pair[1] + return {category: dict(sorted(pairs.items())) for category, pairs in sorted(renames.items())} + + +def main(): + dump_path = env("DUMP") + data_dir = env("DATA_DIR") + doc_dir = env("DOC_DIR") + out_path = Path(env("OUT")) + + data_raw = json.loads(Path(dump_path).read_text(encoding="utf-8")) + + entities = {} + missing = [] + for name in WANTED_ENTITIES: + category, prototype = find_prototype(data_raw, name) + if prototype is None: + missing.append(name) + continue + entities[name] = trim_entity(category, prototype) + + if missing: + # A planner entity that no longer exists is exactly the failure this tool is + # built to catch, so make it loud rather than writing a quietly incomplete file. + fail( + "these entities are named by the planner but do not exist in this Factorio " + f"version: {', '.join(missing)}. That is a real finding - fix the planner, " + "do not delete them from WANTED_ENTITIES." + ) + + modules = sorted(data_raw.get("module", {}).keys()) + + fixture = { + "_comment": ( + "Generated by tools/capture-factorio-oracle.sh. Do not hand-edit. " + "Raw Factorio prototype values that the oil field planner depends on. " + "Re-capture after a Factorio update and commit the diff." + ), + "captureInfo": { + "factorioVersion": env("VERSION"), + "loadedMods": sorted(env("LOADED_MODS").split()), + }, + "directions": collect_directions(doc_dir), + "entities": dict(sorted(entities.items())), + "modules": modules, + "renames": collect_renames(data_dir), + } + + out_path.parent.mkdir(parents=True, exist_ok=True) + # sort_keys plus a trailing newline keeps re-captures diffing cleanly instead of + # reshuffling on every run. + out_path.write_text(json.dumps(fixture, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() From bd65bedc9e31a4489d6a591d8709fc5c08c6bbd5 Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 16 Aug 2026 15:30:07 -0700 Subject: [PATCH 02/17] Spec: speaking Factorio 2.1 to a 2.1 game Design for the bugs reported since 2.1: mis-rotated pumpjacks and unrecognized renamed items. Both are one root cause - the planner still speaks 1.1. Covers the four defects the oracle from bfef7ba surfaced (module rename, direction converted on output but not input, dropped mirror flag, 1.1-encoded corpus), and records why the tests never caught any of it: the corpus is 1.1-encoded, so the parser is correct for the tests and wrong for users. Two design points worth keeping: The internal Direction enum does not change. It is the planner's logical four-way concept and is transpiled to Lua, so version knowledge lives at the serialization boundary instead. Re-normalizing the corpus and fixing the parser are inverse operations, so Score.HasExpectedScore.verified.txt should come out unchanged. That turns the 61-blueprint scoreboard from churn into a free end-to-end check. The uint64 layout of Blueprint.Version is unconfirmed - no blueprint in this repo carries a nonzero version - so confirming it against a real 2.1 export is written up as the first implementation step rather than guessed at. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-16-factorio-21-oracle-design.md | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md diff --git a/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md b/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md new file mode 100644 index 00000000..4143f70e --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md @@ -0,0 +1,164 @@ +# Speaking Factorio 2.1 to a 2.1 game + +Design for the bugs reported since Factorio 2.1: mis-rotated pumpjacks, and items the game does not recognize. + +## Problem + +Both symptoms have one root cause. The planner still speaks Factorio 1.1. + +Two things changed in Factorio 2.0 that this repo never followed: + +1. `effectivity-module-N` was renamed to `efficiency-module-N`. +2. Directions widened from 8 values to 16. North stayed 0, but east moved from 2 to 4, south from 4 to 8, and west from 6 to 12. + +The second one is the nastier of the pair, because the old values are still *legal*. Emitting `4` for south does not fail - the game reads it as east and rotates the pumpjack. There is no error anywhere, just a wrong blueprint. + +### Why the tests did not catch it + +The committed corpus is 1.1-encoded. Decoding every blueprint in `small-list.txt` and `big-list.txt` gives pumpjack direction values of `{0, 2, 4, 6}`, and both `2` and `6` are impossible for a 2.x pumpjack - they would mean northeast and southeast. Every blueprint also carries `version: 0`. + +So the parser is *correct for the corpus* and wrong for anything a user pastes. The tests and the bug report cannot both be satisfied by the current code, and the tests won. + +### Why an oracle, rather than reading the wiki + +This class of bug is invisible from inside the repo, so the fix has to include a way to notice it next time. Commit `bfef7ba` added that: `tools/capture-factorio-oracle.sh` pulls prototype facts out of the game itself into `test/FactorioTools.Test/OilField/factorio-oracle.json`. + +Pointing that fixture at the current constants reports: + +``` +ItemNames.EfficiencyModule3 "effectivity-module-3" -> efficiency-module-3 +ModuleSelect.vue effectivity-module{,-2,-3} -> efficiency-module* +Direction.Right = 2 2 now means northeast; east is 4 +Direction.Down = 4 4 now means east; south is 8 +Direction.Left = 6 6 now means southeast; west is 12 +``` + +The oracle also **ruled things out**, which narrows the work considerably. Every pole and beacon number still matches the game: supply distances 2.5/3.5/2/9, wire reach 7.5/9/32/18, beacon supply 3 and distribution effectivity 1.5. `PlanUndergroundPipes.MaxUnderground = 11` still agrees with the game's `max_underground_distance: 10` (which counts the gap, not the ends). None of the geometry drifted. + +## Defects in scope + +**D1 - Renamed module.** `src/FactorioTools/Data/ItemNames.cs:7` emits `effectivity-module-3`. `src/vue/src/components/ModuleSelect.vue:12-14` offers all three stale names. + +**D2 - Direction is converted on output but not on input.** `GridToBlueprintString.cs:38` multiplies by 2, which is right. `ParseBlueprint.cs` does nothing, so `InitializeContext.cs:250` reads a raw 2.x value into the 1.1-style enum. An east pumpjack (4) is read as `Direction.Down`; south (8) and west (12) are not valid enum members at all. + +**D3 - Pumpjack flips are dropped.** Changelog 2.1.7 added pumpjack flipping. `src/FactorioTools/Data/Entity.cs` has no `mirror` property, so the flag vanishes on parse. + +**D4 - The corpus encodes 1.1 directions.** Left alone, it would keep hiding D2. + +## Design + +### C1 - Oracle capture (landed in `bfef7ba`) + +Already done. Not repeated here. + +### C2 - Oracle assertion test + +`FactorioOracleTest` reads the **committed fixture**, never the game, so CI needs no Factorio install. It asserts: + +- every `EntityNames.Vanilla` value exists in `entities` +- every `ItemNames.Vanilla` module value exists in `modules` +- the `Direction` enum members match `directions` for north/east/south/west +- the pole and beacon raw values behind `OilFieldOptions` presets are unchanged + +Two carve-outs, both deliberate and both needing a comment saying why: + +- `EntityNames.AaiIndustry` is a mod entity. A vanilla capture will never contain it. Absence is expected, not drift. +- `ItemNames.Vanilla.Blueprint` is an item, not a module or entity, so it is checked against a different part of the fixture or excluded outright. + +This mirrors `PlannerDefaultsTest`, which is already the repo's "the test is the generator" pattern. When Factorio 2.2 lands, re-capture, and a changed fixture fails here with a diff. + +The failure message must name `tools/capture-factorio-oracle.sh`, so a red CI run says how to fix itself. + +Note: `factorio-oracle.json` will need a `` entry in `test/FactorioTools.Test/FactorioTools.Test.csproj`, alongside the existing `small-list.txt` entry. + +### C3 - Direction conversion + +**The internal `Direction` enum does not change.** It stays `Up=0, Right=2, Down=4, Left=6`. It is the planner's logical four-way concept, it is transpiled to Lua, and renumbering it would churn the whole core, every snapshot, and the Lua output for no benefit. All version knowledge lives at the serialization boundary. + +Output keeps `ToOutputDirection` (multiply by 2) and starts writing a real version stamp instead of 0. + +Input gains the inverse, in `FactorioTools.Serialization`: + +``` +ToInternalDirection(raw, version): + 16-way (version >= 2.0, or version missing/0) -> raw / 2 + 8-way (version below 2.0) -> raw + result not in {0, 2, 4, 6} -> throw, naming the entity and the raw value +``` + +Missing or zero defaults to 16-way, because that is what every user pastes today. That choice is what forces D4: the existing corpus has `version: 0` and 1.1 values, so it must be re-normalized in the same change or it will be read wrong. + +**Value-sniffing was considered and rejected.** A blueprint whose directions are all in `{0, 4}` is valid under both encodings with different meanings, so inference cannot always be correct. The version field is the only sound signal. + +#### Must verify before implementing + +`Blueprint.Version` is a `ulong` that this repo has never read. The layout is believed to be `major<<48 | minor<<32 | patch<<16 | dev`, which would make the 2.0 threshold `2<<48 = 562949953421312`, but **no blueprint in this repo carries a nonzero version**, so that is unconfirmed. + +Confirm it first, and do not write the threshold from memory: + +1. In Factorio 2.1, create any blueprint and export the string. +2. Base64-decode past the leading version byte, zlib-inflate, and read `blueprint.version`. +3. Check the value is >= the threshold and that a 1.1-era blueprint string falls below it. + +If the layout differs, only the threshold constant changes; the rest of the design holds, because all that is needed is an ordered comparison in which the major version dominates. + +### C4 - Rename, including persisted settings + +`ItemNames.Vanilla` moves to `efficiency-module-3` (and siblings if added). `ModuleSelect.vue` option values move to `efficiency-module{,-2,-3}`. Display labels already read "Efficiency module" and do not change. + +**A rename alone is not enough.** `src/vue/src/stores/OilFieldStore.ts` persists settings to `localStorage`. A user who ever picked an efficiency module has `effectivity-module-3` saved, and will keep sending that dead name after the fix ships. The store needs a one-time migration on load that rewrites any persisted `effectivity-*` value to `efficiency-*`. + +The fixture's `renames` table is the source for that mapping, so the migration should not hand-type the pairs where it can avoid it. + +### C5 - Mirror + +Add `mirror` to `Entity` as `bool?`. Parse it so it survives deserialization; ignore it when planning, because the planner re-chooses every pumpjack orientation anyway; do not emit it. + +This is the same treatment input direction already gets, and it is the minimum that stops a 2.1 blueprint from losing information silently. Honoring a flip is explicitly out of scope - see below. + +### C6 - Corpus re-normalization + +Extend `NormalizeBlueprints` to convert 1.1 direction values to 2.x and stamp a real version, then re-run `oil-field normalize` over both `small-list.txt` (61) and `big-list.txt` (1147). + +Both lists, not just the scored one. `big-list.txt` is not scored so it carries no snapshot risk, but leaving it on 1.1 means the two corpora disagree and the next person to look hits exactly the confusion this document exists to clear up. + +## The invariant that makes this safe + +Re-normalizing the corpus to 2.x and fixing the parser are inverse operations. Applied together, they should cancel exactly. + +**`Score.HasExpectedScore.verified.txt` should come out unchanged.** + +That turns the 61-blueprint scoreboard from churn to be rubber-stamped into a free end-to-end check. If the scoreboard moves, the change is wrong - most likely the conversion is not symmetric, or the corpus rewrite altered something beyond direction. Investigate before accepting any diff there. + +The same is expected of the per-blueprint plan snapshots. A moved snapshot is a signal, not a chore. + +## Testing + +- `FactorioOracleTest`, per C2. +- Direction round-trip unit tests: a 2.x blueprint parses east as `Right`; a 1.1 blueprint with an explicit sub-2.0 version parses east as `Right`; the ambiguous `{0, 4}` case resolves by version; a non-cardinal value throws with a useful message. +- A regression test pinning the actual reported bug: parse a 2.1-exported blueprint with a non-north pumpjack and assert the orientation survives a round trip. This is the test that would have caught the original report, and the corpus cannot provide it, so the blueprint string needs to come from the game. +- `Score.HasExpectedScore.verified.txt` unchanged, per the invariant above. +- Vue: a persistence test that a stored `effectivity-module-3` loads as `efficiency-module-3`. + +## Also required, because the core changes + +- Regenerate `src/lua` via `Invoke-LuaBuild.ps1` and commit it, or `transpile-lua` fails. +- Rebuild the WASM bundle (`npm run build-wasm` in `src/vue`). +- Build and test under `UseLuaSettings=true` as well as the default, per the repo's CI matrix. + +## Out of scope + +- **Honoring a pumpjack flip on output.** The planner picks orientations itself, so it is not clear what honoring an input flip would even mean, and answering that needs real investigation into whether a mirrored pumpjack changes valid terminal positions. C5 only stops the flag being lost. +- **Beacon diminishing returns.** Factorio 2.0 models this with a `profile` array; the planner scores beacons as though each contributes equally. A real gap, deliberately not captured in the fixture and not addressed here. Worth its own issue. +- **Quality-scaled beacon effects.** `distribution_effectivity_bonus_per_quality_level` is captured in the fixture but nothing reads it. +- **AAI Industry and other mod entities.** The oracle is vanilla by design. + +## Risks + +- **The version threshold is unconfirmed.** Mitigated by making confirmation the first implementation step, with a stated procedure. +- **The corpus rewrite is large.** 1208 blueprints across two files. Mitigated by the score invariant: a correct rewrite moves no scores. +- **A blueprint with a genuinely missing version stamp that really is 1.1** will now be read as 2.x and mis-rotated. This is a deliberate trade: it favors the many users pasting current blueprints over the few pasting decade-old ones. + + The error on non-cardinal values catches most of it, but not all, and it is worth being exact about which. Reading 1.1 values as 2.x halves them: east `2` becomes `1` and west `6` becomes `3`, neither of which is a cardinal, so both throw. South `4` becomes `2`, which is a valid `Direction.Right`. **A 1.1 south-facing pumpjack in an unstamped blueprint is therefore read as east, silently.** North is unaffected either way. + + So the guard is loud for two of the three non-north directions and silent for the third. Accepted, because an unstamped 1.1 blueprint is already a corner case and the alternative (defaulting to 1.1) mis-rotates the common case instead. If this proves to matter in practice, the fallback could be tightened by rejecting an unstamped blueprint whose directions are all even and non-zero, which is the signature of 1.1 content. From 975e344a05ef4e686bd47f8651e1668e2c6a496c Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 16 Aug 2026 15:41:14 -0700 Subject: [PATCH 03/17] Confirm the version, direction and mirror encodings from the game The spec left the uint64 layout of Blueprint.Version unconfirmed, because no blueprint in this repo carries a nonzero version. It is now confirmed against a real 2.1.14 export: raw 562954249306113 = 0x0002_0001_000e_0001 -> 2.1.14.1 So the layout is major<<48 | minor<<32 | patch<<16 | dev, and the 2.0 threshold is 2<<48 = 562949953421312. Direction and mirror were established the same way, by round-tripping a blueprint through the game rather than reading docs. A throwaway mod builds a blueprint with known entities and exports it headless via --create. What the exporter actually writes: north omitted entirely (not 0) east 4 south 8 west 12 a flipped pumpjack carries "mirror": true North being omitted rather than written as 0 matters: Entity.Direction is already nullable and defaults to Up, so that path is correct today. FFF #442 and changelog 2.1.7 announce pumpjack flipping but say nothing about how a flip is represented, which is why it was tested rather than assumed. Cross-checked the capture against wube/factorio-data at tag 2.1.14: the migrations directories are byte-identical and item.lua defines efficiency-module directly. Also worth recording so it is not re-raised - "effectivity" still appears 14 times in 2.1.14, but only as a property name (distribution_effectivity, vehicle effectivity), never an item name. Adds --check to the capture script, which reports drift and exits 1 without touching the fixture, following the convention in FactorioMapWebUI's scripts/sync-factorio-refs.sh. Verified both paths: clean run reports up to date, and a corrupted fixture prints the exact diff and exits 1. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 9 +++- .../2026-08-16-factorio-21-oracle-design.md | 50 +++++++++++++++---- tools/capture-factorio-oracle.sh | 38 ++++++++++++-- 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c4590689..9a3b3e98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,18 +134,25 @@ So don't trust memory or the wiki. The game is the only authority on what the ga ```bash tools/capture-factorio-oracle.sh # auto-detects a Steam or /Applications install +tools/capture-factorio-oracle.sh --check # report drift, change nothing, exit 1 on mismatch tools/capture-factorio-oracle.sh --factorio /path/to/factorio.app ``` Notes on using it: -- **Re-capture after every Factorio update and commit the diff.** A changed fixture is the signal that a hardcoded constant needs review. +- **Re-capture after every Factorio update and commit the diff.** A changed fixture is the signal that a hardcoded constant needs review. `--check` answers "has the game moved past what we committed?" without dirtying the tree. +- **The installed binary is the authority** on which version gets captured. Steam updates it without asking, so it decides and everything else follows. Same convention as `scripts/sync-factorio-refs.sh` in FactorioMapWebUI. - **It runs with user mods disabled** (`--mod-directory` pointed at an empty directory). Mods rewrite prototypes freely, so a capture that loads them describes one person's modded game rather than Factorio. The script prints which mods loaded; expect only `core base elevated-rails quality recycler space-age`. - **CI never runs the capture** and needs no Factorio install - it reads the committed fixture. That is why the fixture is committed rather than generated on demand. - Capture needs `python3` (for JSON trimming) and a Factorio install. Neither is needed to build or test. - `EntityNames.AaiIndustry` names come from a mod, so they are deliberately absent from a vanilla capture. That is expected, not drift. - Output is deterministic: two captures of the same install are byte-identical. +Two related sources, for when the game binary is not the easiest thing to reach: + +- **`wube/factorio-data`** (cloned at `~/GitHub/factorio-data`) is the official prototype source, tagged per version. Its `*/migrations/*.json` files are byte-identical to the installed game's, so renames can be checked with no Factorio install at all. Only the resolved geometry from `--dump-data` genuinely needs the binary. +- **A throwaway mod is the way to generate blueprint fixtures.** Docs describe prototypes, not what the blueprint exporter writes. A mod whose `on_init` calls `stack.set_blueprint_entities{...}` then `helpers.write_file(name, stack.export_stack())`, run headless via `factorio --create --mod-directory `, produces a real blueprint string stamped with the real game version. This is how the direction values and the `mirror` field were established rather than assumed. Check runtime API names against `doc-html/runtime-api.json` first - `game.write_file` became `helpers.write_file` in 2.0. + ## Testing notes - Tests use **xUnit v3 + Verify** (`xunit.v3` + `Verify.XunitV3`). Many tests assert against committed `*.verified.txt` snapshots under `test/FactorioTools.Test/OilField`. When behavior legitimately changes, update snapshots via Verify's accept workflow (received vs verified) rather than editing expected files by hand. A stale committed snapshot **does** fail on CI: `AutoVerify` is off there, so the test itself throws `VerifyException` with the diff (confirmed on a real runner, not just inferred - note that setting `CI`/`GITHUB_ACTIONS` locally does *not* reproduce the build-server detection, so local simulation of this is misleading). A "Check no Verify snapshots drifted" step backs that up by failing if `dotnet test` leaves any `*.verified.*` file dirty, in case the detection ever regresses. Commit regenerated snapshots with your change. diff --git a/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md b/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md index 4143f70e..d65ae77e 100644 --- a/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md +++ b/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md @@ -33,6 +33,22 @@ Direction.Down = 4 4 now means east; south is 8 Direction.Left = 6 6 now means southeast; west is 12 ``` +#### Cross-checked against wube's own repo + +The capture was validated against `wube/factorio-data` checked out at tag `2.1.14` (locally at `~/GitHub/factorio-data`). The `base/migrations` directories are byte-identical to the installed game's, and `base/prototypes/item.lua:2615` defines `efficiency-module` directly. Two independent sources, same answer. + +Worth noting so nobody re-raises it: `effectivity` still appears 14 times in 2.1.14 prototypes, but only ever as a **property** name (`distribution_effectivity`, and `effectivity` on vehicles). It is never an item name. The rename touched item, recipe and technology names only, exactly as the migration table says. + +`factorio-data` is also the no-install path to part of the oracle. Migrations and prototype source need no Factorio binary, so only the resolved geometry from `--dump-data` truly requires the game. + +#### Generating blueprints as test fixtures + +Two of the confirmations above came from a throwaway Factorio mod rather than from docs, and the technique is worth recording because it is the only way to get authoritative blueprint fixtures. + +A mod whose `on_init` calls `stack.set_blueprint_entities{...}` and `helpers.write_file(name, stack.export_stack())`, run headless via `factorio --create --mod-directory `, exports whatever blueprint you ask for, stamped with the real game version. That is how the direction table and the `mirror` field above were established. + +(`game.write_file` moved to `helpers.write_file` in 2.0 - a fitting instance of the same problem this document is about. Check names against `doc-html/runtime-api.json` rather than memory.) + The oracle also **ruled things out**, which narrows the work considerably. Every pole and beacon number still matches the game: supply distances 2.5/3.5/2/9, wire reach 7.5/9/32/18, beacon supply 3 and distribution effectivity 1.5. `PlanUndergroundPipes.MaxUnderground = 11` still agrees with the game's `max_underground_distance: 10` (which counts the gap, not the ends). None of the geometry drifted. ## Defects in scope @@ -41,7 +57,7 @@ The oracle also **ruled things out**, which narrows the work considerably. Every **D2 - Direction is converted on output but not on input.** `GridToBlueprintString.cs:38` multiplies by 2, which is right. `ParseBlueprint.cs` does nothing, so `InitializeContext.cs:250` reads a raw 2.x value into the 1.1-style enum. An east pumpjack (4) is read as `Direction.Down`; south (8) and west (12) are not valid enum members at all. -**D3 - Pumpjack flips are dropped.** Changelog 2.1.7 added pumpjack flipping. `src/FactorioTools/Data/Entity.cs` has no `mirror` property, so the flag vanishes on parse. +**D3 - Pumpjack flips are dropped.** Changelog 2.1.7 and [FFF #442](https://factorio.com/blog/post/fff-442) added pumpjack flipping. The FFF describes the feature but says nothing about how a flip is represented in a blueprint, so it was tested rather than assumed: a blueprint round-tripped through 2.1.14 writes `"mirror": true` alongside `direction`. `src/FactorioTools/Data/Entity.cs` has no `mirror` property, so the flag vanishes on parse. **D4 - The corpus encodes 1.1 directions.** Left alone, it would keep hiding D2. @@ -90,17 +106,30 @@ Missing or zero defaults to 16-way, because that is what every user pastes today **Value-sniffing was considered and rejected.** A blueprint whose directions are all in `{0, 4}` is valid under both encodings with different meanings, so inference cannot always be correct. The version field is the only sound signal. -#### Must verify before implementing +#### Version encoding (confirmed) + +`Blueprint.Version` is a `ulong` this repo has never read. The layout is confirmed against a real blueprint exported from Factorio 2.1.14: + +``` +raw 562954249306113 +hex 0x0002_0001_000e_0001 + major=2 minor=1 patch=14 dev=1 -> 2.1.14.1 +``` + +So the layout is `major<<48 | minor<<32 | patch<<16 | dev`, and the 2.0 threshold is `2<<48 = 562949953421312`. Only an ordered comparison is needed, and the major version dominates it. -`Blueprint.Version` is a `ulong` that this repo has never read. The layout is believed to be `major<<48 | minor<<32 | patch<<16 | dev`, which would make the 2.0 threshold `2<<48 = 562949953421312`, but **no blueprint in this repo carries a nonzero version**, so that is unconfirmed. +#### Direction encoding in blueprints (confirmed) -Confirm it first, and do not write the threshold from memory: +Confirmed by round-tripping a blueprint through the game rather than reading docs. Asking for each cardinal and reading back what the exporter wrote: -1. In Factorio 2.1, create any blueprint and export the string. -2. Base64-decode past the leading version byte, zlib-inflate, and read `blueprint.version`. -3. Check the value is >= the threshold and that a 1.1-era blueprint string falls below it. +| Requested | Field written | +| --- | --- | +| north | *omitted entirely* | +| east | `4` | +| south | `8` | +| west | `12` | -If the layout differs, only the threshold constant changes; the rest of the design holds, because all that is needed is an ordered comparison in which the major version dominates. +North being omitted rather than written as `0` matters: `Entity.Direction` is already nullable and `InitializeContext.cs:250` already defaults it to `Up`, so that path is correct today and must stay correct. ### C4 - Rename, including persisted settings @@ -136,7 +165,8 @@ The same is expected of the per-blueprint plan snapshots. A moved snapshot is a - `FactorioOracleTest`, per C2. - Direction round-trip unit tests: a 2.x blueprint parses east as `Right`; a 1.1 blueprint with an explicit sub-2.0 version parses east as `Right`; the ambiguous `{0, 4}` case resolves by version; a non-cardinal value throws with a useful message. -- A regression test pinning the actual reported bug: parse a 2.1-exported blueprint with a non-north pumpjack and assert the orientation survives a round trip. This is the test that would have caught the original report, and the corpus cannot provide it, so the blueprint string needs to come from the game. +- A regression test pinning the actual reported bug: parse a 2.1-exported blueprint with a non-north pumpjack and assert the orientation survives a round trip. This is the test that would have caught the original report, and the corpus cannot provide it. Generate the fixture with the probe-mod technique above, so it carries a genuine 2.1 version stamp and genuine `4`/`8`/`12` direction values rather than hand-written ones. +- A `mirror: true` blueprint parses without loss or error, per C5. The same probe generates it. - `Score.HasExpectedScore.verified.txt` unchanged, per the invariant above. - Vue: a persistence test that a stored `effectivity-module-3` loads as `efficiency-module-3`. @@ -155,7 +185,7 @@ The same is expected of the per-blueprint plan snapshots. A moved snapshot is a ## Risks -- **The version threshold is unconfirmed.** Mitigated by making confirmation the first implementation step, with a stated procedure. +- ~~The version threshold is unconfirmed.~~ **Resolved.** Confirmed against a real 2.1.14 export; see the version encoding section. - **The corpus rewrite is large.** 1208 blueprints across two files. Mitigated by the score invariant: a correct rewrite moves no scores. - **A blueprint with a genuinely missing version stamp that really is 1.1** will now be read as 2.x and mis-rotated. This is a deliberate trade: it favors the many users pasting current blueprints over the few pasting decade-old ones. diff --git a/tools/capture-factorio-oracle.sh b/tools/capture-factorio-oracle.sh index 55b97e4f..26d635d3 100755 --- a/tools/capture-factorio-oracle.sh +++ b/tools/capture-factorio-oracle.sh @@ -27,25 +27,44 @@ # Usage # ----- # tools/capture-factorio-oracle.sh +# tools/capture-factorio-oracle.sh --check # tools/capture-factorio-oracle.sh --factorio /path/to/factorio.app --out some/file.json # +# The installed binary is the authority on which version is captured. Steam updates it +# without asking, so it decides and everything else follows. This mirrors the convention +# in FactorioMapWebUI's scripts/sync-factorio-refs.sh. +# set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -OUT="$REPO_ROOT/test/FactorioTools.Test/OilField/factorio-oracle.json" +FIXTURE="$REPO_ROOT/test/FactorioTools.Test/OilField/factorio-oracle.json" +OUT="$FIXTURE" FACTORIO_APP="" USER_DATA_DIR="" +CHECK_ONLY=0 while [[ $# -gt 0 ]]; do case "$1" in --factorio) FACTORIO_APP="$2"; shift 2 ;; --out) OUT="$2"; shift 2 ;; --user-data-dir) USER_DATA_DIR="$2"; shift 2 ;; - -h|--help) sed -n '2,32p' "${BASH_SOURCE[0]}"; exit 0 ;; + --check) CHECK_ONLY=1; shift ;; + -h|--help) sed -n '2,38p' "${BASH_SOURCE[0]}"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done +# One scratch directory and one EXIT trap for the whole script. Registering a second +# trap on EXIT would silently replace the first and leak the earlier directory. +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# --check captures to a scratch file and reports drift instead of rewriting the fixture, +# so "has the game moved past what we committed?" is answerable without a dirty tree. +if [[ "$CHECK_ONLY" -eq 1 ]]; then + OUT="$WORK/factorio-oracle.json" +fi + # --------------------------------------------------------------------------- # 1. Locate the game. # --------------------------------------------------------------------------- @@ -99,8 +118,6 @@ echo "Factorio $VERSION at $FACTORIO_APP" # --mod-directory at an empty directory leaves only core, base and the bundled DLC # (elevated-rails, quality, recycler, space-age), which is what the planner targets. # --------------------------------------------------------------------------- -WORK="$(mktemp -d)" -trap 'rm -rf "$WORK"' EXIT mkdir -p "$WORK/mods" printf '{"mods":[{"name":"base","enabled":true}]}\n' > "$WORK/mods/mod-list.json" @@ -140,4 +157,17 @@ DUMP="$DUMP" DATA_DIR="$DATA_DIR" DOC_DIR="$DOC_DIR" \ VERSION="$VERSION" LOADED_MODS="$LOADED_MODS" OUT="$OUT" \ python3 "$REPO_ROOT/tools/trim-factorio-oracle.py" +if [[ "$CHECK_ONLY" -eq 1 ]]; then + if diff -u "$FIXTURE" "$OUT" > "$WORK/drift.diff" 2>&1; then + echo "Up to date: the committed fixture matches Factorio $VERSION." + exit 0 + fi + echo + echo "DRIFT: the committed fixture does not match Factorio $VERSION." + echo "Re-run without --check to update it, then review what moved." + echo + cat "$WORK/drift.diff" + exit 1 +fi + echo "Wrote $OUT" From f41c19a677645b319e967451bc67b8af6443fba3 Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 16 Aug 2026 16:20:01 -0700 Subject: [PATCH 04/17] Plan: implementation steps for the Factorio 2.1 fixes Seven tasks over the four defects in the spec, in an order that keeps the suite green at each commit except one deliberate exception. Two structural findings that shaped it. Task 1 lands the oracle assertion test BEFORE any fix, so the suite goes red on effectivity-module-3. That is the reported bug reproduced as a test rather than described in a commit message. Task 5 is atomic and says so. Fixing the parser alone misreads the 1.1-encoded corpus; re-normalizing the corpus alone produces 2.x values the unfixed parser misreads. Either half on its own turns the suite red, so they land together, with the unchanged score scoreboard as the check that they cancel. Also corrects three spec claims found while reading the code: FormatVersion and ParseVersion already exist and already match the confirmed layout, output is already version-stamped at GridToBlueprintString.cs:222, and the fixture needs no csproj entry because tests resolve paths through GetRepositoryRoot(). Self-review caught a bug in the plan's own first draft: it had CleanBlueprint carry the source version through, which would emit 2.x directions under a 1.1 stamp and halve already-converted values on reparse. Stamping moved to SerializeBlueprint, next to the code that doubles the directions, so the two cannot disagree. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-16-factorio-21-fixes.md | 859 ++++++++++++++++++ .../2026-08-16-factorio-21-oracle-design.md | 8 + 2 files changed, 867 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-16-factorio-21-fixes.md diff --git a/docs/superpowers/plans/2026-08-16-factorio-21-fixes.md b/docs/superpowers/plans/2026-08-16-factorio-21-fixes.md new file mode 100644 index 00000000..0b377833 --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-factorio-21-fixes.md @@ -0,0 +1,859 @@ +# Factorio 2.1 Compatibility Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the oil field planner speak Factorio 2.1 instead of 1.1, fixing mis-rotated pumpjacks and unrecognized renamed items. + +**Architecture:** The internal `Direction` enum stays 1.1-style four-way, because it is the planner's logical concept and is transpiled to Lua. All version knowledge lives at the serialization boundary. A committed oracle fixture, captured from the game, becomes the thing that fails when Factorio moves again. + +**Tech Stack:** C# / .NET 10, xUnit v3 + Verify, Vue 3 + Pinia + Vitest, CSharp.lua transpilation, .NET WASM. + +**Spec:** `docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md` + +## Global Constraints + +- Use hyphens, never em dashes or en dashes, in all files. +- Prose written for humans targets Flesch-Kincaid grade 12 max, aim 9-11. +- The core library `src/FactorioTools` must stay free of JSON/serialization dependencies. It is transpiled to Lua; `FactorioTools.Serialization` is not. +- In core library hot paths avoid `yield return`, LINQ, named tuples, try/catch, and struct dictionary keys. LINQ in the core is a runtime failure inside Factorio, not a build error, because `Linq.lua` is never shipped. +- Any change to the core requires regenerating `src/lua` via `src/lua/Invoke-LuaBuild.ps1` and committing it, or the `transpile-lua` CI job fails. +- Build and test under both default flags and `/p:UseLuaSettings=true`. +- Verify snapshots auto-accept locally but fail on CI. Commit regenerated snapshots. Setting `CI=1` locally does NOT reproduce CI behavior, so do not try to simulate it. +- Confirmed encodings, do not re-derive: blueprint version is `major<<48 | minor<<32 | patch<<16 | dev`; the 2.0 threshold is `562949953421312`. Blueprint directions are north omitted, east `4`, south `8`, west `12`. A flipped entity carries `"mirror": true`. + +## Corrections to the spec, found while planning + +Three spec statements were wrong. The plan below is correct; fix the spec in Task 1. + +1. **`FormatVersion` and `ParseVersion` already exist** at `GridToBlueprintString.cs:243` and `:233`, and they already match the confirmed layout. Do not write new ones. +2. **Output is already version-stamped.** `GridToBlueprintString.cs:222` emits `FormatVersion(2, 0, 32, 0)`. The spec's claim that output needs to start stamping a version is wrong. +3. **The fixture needs no `.csproj` change.** `small-list.txt` is `CopyToOutputDirectory: Never` and tests resolve paths through `BaseTest.GetRepositoryRoot()`. Follow that pattern. + +The corpus carries `version: 0` because `CleanBlueprint.cs:34` builds a `new Blueprint` without copying `Version`, and normalize serializes that object. + +--- + +### Task 1: Oracle assertion test + +Locks the oracle in before any behavior changes. Pure test addition. It should FAIL on the current code, which is the point: it reproduces the reported bugs as a test. + +**Files:** +- Create: `test/FactorioTools.Test/OilField/FactorioOracleTest.cs` +- Modify: `docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md` (apply the three corrections above) + +**Interfaces:** +- Consumes: `BaseTest.GetRepositoryRoot()` (public static, returns repo root), the committed fixture `test/FactorioTools.Test/OilField/factorio-oracle.json`. +- Produces: nothing other tasks consume. + +- [ ] **Step 1: Write the failing test** + +Create `test/FactorioTools.Test/OilField/FactorioOracleTest.cs`: + +```csharp +using System.Text.Json; +using Knapcode.FactorioTools.Data; + +namespace Knapcode.FactorioTools.OilField; + +/// +/// Asserts the planner's hardcoded Factorio facts against an oracle captured from the game +/// itself (tools/capture-factorio-oracle.sh). +/// +/// This reads the COMMITTED fixture, never the game, so CI needs no Factorio install. +/// Re-capture after a Factorio update and commit the diff; a changed fixture failing here +/// is the signal that a constant needs review. +/// +public class FactorioOracleTest : BaseTest +{ + private static readonly string FixturePath = Path.Combine( + GetRepositoryRoot(), "test", "FactorioTools.Test", "OilField", "factorio-oracle.json"); + + private const string ReCaptureHint = + "If Factorio changed, re-run tools/capture-factorio-oracle.sh and review the diff."; + + private static JsonElement Oracle() + { + return JsonDocument.Parse(File.ReadAllText(FixturePath)).RootElement; + } + + private static HashSet Names(JsonElement parent, string property) + { + var element = parent.GetProperty(property); + if (element.ValueKind == JsonValueKind.Object) + { + return element.EnumerateObject().Select(x => x.Name).ToHashSet(); + } + + return element.EnumerateArray().Select(x => x.GetString()!).ToHashSet(); + } + + [Fact] + public void EveryVanillaEntityNameExistsInFactorio() + { + var entities = Names(Oracle(), "entities"); + + var missing = typeof(EntityNames.Vanilla) + .GetFields() + .Select(f => (string)f.GetValue(null)!) + .Where(name => !entities.Contains(name)) + .ToList(); + + Assert.True(missing.Count == 0, $"Not in Factorio: {string.Join(", ", missing)}. {ReCaptureHint}"); + } + + /// + /// EntityNames.AaiIndustry is deliberately NOT checked. Those names come from the AAI + /// Industry mod, and the oracle is captured with mods disabled on purpose, so their + /// absence is expected rather than drift. + /// + [Fact] + public void EveryVanillaModuleNameExistsInFactorio() + { + var modules = Names(Oracle(), "modules"); + + var missing = typeof(ItemNames.Vanilla) + .GetFields() + .Select(f => (string)f.GetValue(null)!) + // "blueprint" is an item, not a module, so it is not in the module list. + .Where(name => name != ItemNames.Vanilla.Blueprint) + .Where(name => !modules.Contains(name)) + .ToList(); + + Assert.True(missing.Count == 0, $"Not a Factorio module: {string.Join(", ", missing)}. {ReCaptureHint}"); + } + + [Theory] + [InlineData(Direction.Up, "north")] + [InlineData(Direction.Right, "east")] + [InlineData(Direction.Down, "south")] + [InlineData(Direction.Left, "west")] + public void InternalDirectionDoublesToTheFactorioValue(Direction direction, string factorioName) + { + var expected = Oracle().GetProperty("directions").GetProperty(factorioName).GetInt32(); + + // The internal enum is deliberately 1.1-style four-way (N=0, E=2, S=4, W=6). + // Factorio 2.0 is 16-way, so the blueprint value is always exactly double. + Assert.Equal(expected, (int)direction * 2); + } + + [Theory] + [InlineData(EntityNames.Vanilla.SmallElectricPole, "supply_area_distance", 2.5)] + [InlineData(EntityNames.Vanilla.MediumElectricPole, "supply_area_distance", 3.5)] + [InlineData(EntityNames.Vanilla.BigElectricPole, "supply_area_distance", 2)] + [InlineData(EntityNames.Vanilla.Substation, "supply_area_distance", 9)] + [InlineData(EntityNames.Vanilla.SmallElectricPole, "maximum_wire_distance", 7.5)] + [InlineData(EntityNames.Vanilla.MediumElectricPole, "maximum_wire_distance", 9)] + [InlineData(EntityNames.Vanilla.BigElectricPole, "maximum_wire_distance", 32)] + [InlineData(EntityNames.Vanilla.Substation, "maximum_wire_distance", 18)] + [InlineData(EntityNames.Vanilla.Beacon, "supply_area_distance", 3)] + public void RawGeometryBehindTheOptionsPresetsIsUnchanged(string entity, string field, double expected) + { + var actual = Oracle().GetProperty("entities").GetProperty(entity).GetProperty(field).GetDouble(); + + Assert.True( + expected == actual, + $"{entity}.{field} moved from {expected} to {actual}, so the OilFieldOptions presets need review. {ReCaptureHint}"); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails for the right reason** + +Run: `dotnet test test/FactorioTools.Test/FactorioTools.Test.csproj --filter "FullyQualifiedName~FactorioOracleTest"` + +Expected: `EveryVanillaModuleNameExistsInFactorio` FAILS with `Not a Factorio module: effectivity-module-3`. The direction and geometry tests PASS. If the geometry tests fail, stop and investigate; the oracle says they should not. + +The failing module test IS the reported bug reproduced. Do not fix it here. + +- [ ] **Step 3: Apply the three spec corrections** + +In `docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md`: +- In the C3 section, replace the claim that output should "start writing a real version stamp" with: output already stamps `FormatVersion(2, 0, 32, 0)` at `GridToBlueprintString.cs:222`, and `ParseVersion`/`FormatVersion` already exist at `:233` and `:243` matching the confirmed layout. +- In the C2 section, delete the note claiming a `` csproj entry is needed. Replace with: tests resolve the fixture through `BaseTest.GetRepositoryRoot()`, matching `BasePlannerTest.SmallListFilePath`. +- In the C6 section, add: the corpus carries `version: 0` because `CleanBlueprint.cs:34` builds a `new Blueprint` without copying `Version`. + +- [ ] **Step 4: Commit** + +```bash +git add test/FactorioTools.Test/OilField/FactorioOracleTest.cs docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md +git commit -m "Assert planner constants against the captured Factorio oracle + +Reads the committed fixture, never the game, so CI needs no Factorio install. +EveryVanillaModuleNameExistsInFactorio fails on effectivity-module-3, which is +the reported bug reproduced as a test. Fixed in the next commit. + +Also corrects three spec claims found while planning: FormatVersion/ParseVersion +already exist, output is already version-stamped, and the fixture needs no +csproj entry." +``` + +The suite is RED after this commit, deliberately. If that is not acceptable in this repo, squash Task 1 and Task 2 together. + +--- + +### Task 2: Rename the module in C# + +**Files:** +- Modify: `src/FactorioTools/Data/ItemNames.cs:7` + +**Interfaces:** +- Consumes: `FactorioOracleTest.EveryVanillaModuleNameExistsInFactorio` from Task 1. +- Produces: `ItemNames.Vanilla.EfficiencyModule3 == "efficiency-module-3"`, consumed by Task 3's Vue values. + +- [ ] **Step 1: Make the minimal change** + +In `src/FactorioTools/Data/ItemNames.cs`, change line 7 from: + +```csharp + public const string EfficiencyModule3 = "effectivity-module-3"; +``` + +to: + +```csharp + // Renamed in Factorio 2.0. "effectivity-module-3" no longer exists, so the game + // silently rejects it. See base/migrations/2.0.0.json in the game data. + public const string EfficiencyModule3 = "efficiency-module-3"; +``` + +The C# identifier `EfficiencyModule3` was already correct; only the string value was stale. + +- [ ] **Step 2: Run the oracle test to verify it passes** + +Run: `dotnet test test/FactorioTools.Test/FactorioTools.Test.csproj --filter "FullyQualifiedName~FactorioOracleTest"` + +Expected: all PASS. + +- [ ] **Step 3: Run the full suite and accept any snapshot changes** + +Run: `dotnet test` + +Expected: snapshots that embed the module name change. Review each diff and confirm the only change is `effectivity-module-3` becoming `efficiency-module-3`. `Score.HasExpectedScore.verified.txt` must NOT change, because module names do not affect plan quality. If the score moves, stop and investigate. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "Emit efficiency-module-3, the name Factorio has used since 2.0 + +effectivity-module-3 has not existed since 2.0, so the game silently rejected +it. The C# identifier was already EfficiencyModule3; only the string was stale." +``` + +--- + +### Task 3: Rename in the Vue app, including persisted settings + +A rename alone is not enough. `OilFieldStore` persists to `localStorage`, so a user who ever picked an efficiency module has the dead name saved and would keep sending it forever. + +**Files:** +- Modify: `src/vue/src/components/ModuleSelect.vue:12-14` +- Modify: `src/vue/src/stores/OilFieldStore.ts` +- Test: `src/vue/src/stores/persistence.test.ts` + +**Interfaces:** +- Consumes: `ItemNames.Vanilla.EfficiencyModule3` value from Task 2. +- Produces: a `migrateModuleNames(state)` helper exported from `OilFieldStore.ts`, taking and returning the persisted state object. + +- [ ] **Step 1: Write the failing test** + +Add to `src/vue/src/stores/persistence.test.ts`: + +```typescript +import { migrateModuleNames } from "./OilFieldStore" + +describe("migrateModuleNames", () => { + it("rewrites module names Factorio renamed in 2.0", () => { + const migrated = migrateModuleNames({ + pumpjackModule: "effectivity-module-3", + beaconModule: "effectivity-module", + }) + + expect(migrated.pumpjackModule).toBe("efficiency-module-3") + expect(migrated.beaconModule).toBe("efficiency-module") + }) + + it("leaves current names alone", () => { + const migrated = migrateModuleNames({ + pumpjackModule: "productivity-module-3", + beaconModule: "speed-module-3", + }) + + expect(migrated.pumpjackModule).toBe("productivity-module-3") + expect(migrated.beaconModule).toBe("speed-module-3") + }) +}) +``` + +Adjust the property names to match the real store state shape. Read `OilFieldStore.ts` first and use its actual module property names. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd src/vue && npx vitest run src/stores/persistence.test.ts` + +Expected: FAIL with `migrateModuleNames is not a function`. + +- [ ] **Step 3: Implement the migration** + +In `src/vue/src/stores/OilFieldStore.ts`, add and export: + +```typescript +// Factorio 2.0 renamed the efficiency modules. Anyone who picked one before this fix +// has the dead name in localStorage and would keep sending it to the planner forever, +// so rewrite on load rather than only fixing the dropdown. +const RENAMED_MODULES: Record = { + "effectivity-module": "efficiency-module", + "effectivity-module-2": "efficiency-module-2", + "effectivity-module-3": "efficiency-module-3", +} + +export function migrateModuleNames>(state: T): T { + for (const key of Object.keys(state)) { + const value = state[key] + if (typeof value === "string" && value in RENAMED_MODULES) { + ;(state as Record)[key] = RENAMED_MODULES[value] + } + } + return state +} +``` + +Then call it from the store's persisted-state restore path. `pinia-plugin-persistedstate` exposes `afterHydrate`, so in the `persist` options object add: + +```typescript + afterHydrate: (ctx) => { + migrateModuleNames(ctx.store.$state as Record) + }, +``` + +Read the existing `persist: { ... }` block first and add the hook alongside what is already there. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd src/vue && npx vitest run src/stores/persistence.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Update the dropdown values** + +In `src/vue/src/components/ModuleSelect.vue`, change lines 12-14 from `value="effectivity-module"`, `value="effectivity-module-2"`, `value="effectivity-module-3"` to `value="efficiency-module"`, `value="efficiency-module-2"`, `value="efficiency-module-3"`. The visible labels already read "Efficiency module" and do not change. + +- [ ] **Step 6: Run the whole front-end suite** + +Run: `cd src/vue && npx vitest run && npm run type-check` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "Rename efficiency modules in the Vue app and migrate saved settings + +Changing the dropdown alone would leave every existing user sending +effectivity-module-3, which Factorio has not accepted since 2.0, because the +choice is persisted in localStorage. migrateModuleNames rewrites it on hydrate." +``` + +--- + +### Task 4: Preserve the mirror flag + +Factorio 2.1.7 added pumpjack flipping. Confirmed by round-tripping a blueprint through 2.1.14: a flipped entity carries `"mirror": true`. + +**Files:** +- Modify: `src/FactorioTools/Data/Entity.cs` +- Test: `test/FactorioTools.Test/OilField/ParseBlueprintTest.cs` (create) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `Entity.Mirror` as `bool?`, read by nothing else. Task 6's regression fixtures exercise it. + +- [ ] **Step 1: Write the failing test** + +Create `test/FactorioTools.Test/OilField/ParseBlueprintTest.cs`: + +```csharp +namespace Knapcode.FactorioTools.OilField; + +public class ParseBlueprintTest : BaseTest +{ + /// + /// Generated by a probe mod running inside Factorio 2.1.14: three pumpjacks, the + /// second and third mirrored. See CLAUDE.md for the probe-mod technique. + /// + private const string MirroredPumpjacks = "REPLACE_ME"; + + [Fact] + public void KeepsTheMirrorFlagOnAFlippedEntity() + { + var blueprint = ParseBlueprint.Execute(MirroredPumpjacks); + + Assert.Collection( + blueprint.Entities, + e => Assert.Null(e.Mirror), + e => Assert.True(e.Mirror), + e => Assert.True(e.Mirror)); + } +} +``` + +Generate the blueprint string with the probe-mod technique in CLAUDE.md, using exactly these entities: + +```lua +stack.set_blueprint_entities{ + {entity_number = 1, name = "pumpjack", position = {x = 0.5, y = 0.5}, direction = defines.direction.east}, + {entity_number = 2, name = "pumpjack", position = {x = 10.5, y = 0.5}, direction = defines.direction.east, mirror = true}, + {entity_number = 3, name = "pumpjack", position = {x = 20.5, y = 0.5}, direction = defines.direction.south, mirror = true}, +} +``` + +Paste the exported string in place of `REPLACE_ME`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test test/FactorioTools.Test/FactorioTools.Test.csproj --filter "FullyQualifiedName~ParseBlueprintTest"` + +Expected: FAIL to compile, `Entity` has no `Mirror`. + +- [ ] **Step 3: Add the property** + +In `src/FactorioTools/Data/Entity.cs`, after the `Direction` property: + +```csharp + // Factorio 2.1.7 added pumpjack and burner mining drill flipping, and a flipped entity + // carries "mirror": true (confirmed by round-tripping a blueprint through 2.1.14). + // Parsed so the flag is not silently lost. The planner re-chooses every pumpjack + // orientation itself, so nothing reads this and it is never emitted. + [JsonPropertyName("mirror")] + public bool? Mirror { get; set; } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test test/FactorioTools.Test/FactorioTools.Test.csproj --filter "FullyQualifiedName~ParseBlueprintTest"` + +Expected: PASS. + +- [ ] **Step 5: Run the full suite** + +Run: `dotnet test` + +Expected: PASS with no snapshot changes. `Mirror` is never emitted, so nothing serialized should move. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "Parse the mirror flag instead of dropping it + +Factorio 2.1.7 added pumpjack flipping, and a flipped entity carries +mirror: true. Confirmed by round-tripping a blueprint through 2.1.14, since +FFF #442 describes the feature but not its blueprint representation. + +Parsed only. The planner re-chooses every pumpjack orientation itself, so +honoring a flip is a separate question and is out of scope." +``` + +--- + +### Task 5: Version-gated direction parsing, and the corpus, together + +**This task is atomic and cannot be split.** Fixing the parser alone misreads the 1.1-encoded corpus. Re-normalizing the corpus alone produces 2.x values the unfixed parser misreads. Either half on its own turns the suite red, so they land in one commit. + +**Files:** +- Modify: `src/FactorioTools.Serialization/OilField/Steps/ParseBlueprint.cs` +- Modify: `src/FactorioTools.Serialization/OilField/Steps/GridToBlueprintString.cs` (`SerializeBlueprint`) +- Modify: `test/FactorioTools.Test/OilField/small-list.txt` (regenerated) +- Modify: `test/FactorioTools.Test/OilField/big-list.txt` (regenerated) +- Test: `test/FactorioTools.Test/OilField/ParseBlueprintTest.cs` (extend) + +**Do NOT change `CleanBlueprint.cs`.** Carrying the source version through looks right and is wrong: `ToOutputDirection` always emits 2.x values, so a 1.1 input would come out with 2.x directions under a 1.1 stamp, and re-parsing would halve values that were already converted. Stamping belongs next to the code that does the doubling, which is `SerializeBlueprint`. That also keeps the core library unchanged here, which is what the design wants. + +**Interfaces:** +- Consumes: `Entity.Mirror` from Task 4; `GridToBlueprintString.FormatVersion(ushort, ushort, ushort, ushort)` and `ParseVersion(ulong)` which already exist. +- Produces: `ParseBlueprint.ToInternalDirection(Direction raw, ulong version)` returning `Direction`. + +- [ ] **Step 1: Write the failing tests** + +Add to `test/FactorioTools.Test/OilField/ParseBlueprintTest.cs`: + +```csharp + private const ulong Version2_1_14 = 562954249306113UL; // confirmed against a real export + private const ulong Version1_1_0 = 281479271677952UL; // FormatVersion(1, 1, 0, 0) + + [Theory] + // Factorio 2.x blueprints: north omitted, east 4, south 8, west 12. + [InlineData(0, Version2_1_14, Direction.Up)] + [InlineData(4, Version2_1_14, Direction.Right)] + [InlineData(8, Version2_1_14, Direction.Down)] + [InlineData(12, Version2_1_14, Direction.Left)] + // An unstamped blueprint is assumed to be modern, because that is what users paste. + [InlineData(4, 0UL, Direction.Right)] + // Genuine 1.1 blueprints still parse, when they say so. + [InlineData(2, Version1_1_0, Direction.Right)] + [InlineData(6, Version1_1_0, Direction.Left)] + public void ConvertsBlueprintDirectionsToInternalOnes(int raw, ulong version, Direction expected) + { + Assert.Equal(expected, ParseBlueprint.ToInternalDirection((Direction)raw, version)); + } + + [Theory] + [InlineData(2)] // northeast in 2.x, not a cardinal + [InlineData(6)] // southeast in 2.x, not a cardinal + public void RejectsDirectionsAPumpjackCannotHave(int raw) + { + var ex = Assert.Throws( + () => ParseBlueprint.ToInternalDirection((Direction)raw, Version2_1_14)); + + Assert.Contains(raw.ToString(), ex.Message); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test test/FactorioTools.Test/FactorioTools.Test.csproj --filter "FullyQualifiedName~ParseBlueprintTest"` + +Expected: FAIL to compile, no `ToInternalDirection`. + +- [ ] **Step 3: Implement the conversion** + +In `src/FactorioTools.Serialization/OilField/Steps/ParseBlueprint.cs`, add to the class: + +```csharp + /// + /// The blueprint version at which Factorio widened directions from 8-way to 16-way. + /// GridToBlueprintString.FormatVersion(2, 0, 0, 0). Confirmed against a real 2.1.14 + /// export, whose version is 562954249306113 (2.1.14.1). + /// + private const ulong FirstSixteenWayVersion = 562949953421312UL; + + /// + /// Converts a blueprint's direction to the internal 1.1-style four-way + /// (N=0, E=2, S=4, W=6). + /// + /// Factorio 2.0 widened directions to 16-way (N=0, E=4, S=8, W=12). The old values are + /// still legal, so a 2.x east read as 1.1 is not an error, it is a silently rotated + /// pumpjack. Sniffing the values cannot resolve it either: a blueprint whose directions + /// are all in {0, 4} is valid under both readings with different meanings. The version + /// is the only sound signal. + /// + /// A missing or zero version is treated as modern, because that is what users paste + /// today. The trade-off is spelled out in the design doc. + /// + public static Direction ToInternalDirection(Direction direction, ulong version) + { + var raw = (int)direction; + var internalValue = version == 0 || version >= FirstSixteenWayVersion ? raw / 2 : raw; + + if (internalValue != (int)Direction.Up + && internalValue != (int)Direction.Right + && internalValue != (int)Direction.Down + && internalValue != (int)Direction.Left) + { + throw new FactorioToolsException( + $"Blueprint direction {raw} is not one of the four directions a pumpjack can face.", + badInput: true); + } + + return (Direction)internalValue; + } +``` + +- [ ] **Step 4: Run the unit tests to verify they pass** + +Run: `dotnet test test/FactorioTools.Test/FactorioTools.Test.csproj --filter "FullyQualifiedName~ParseBlueprintTest"` + +Expected: PASS. + +- [ ] **Step 5: Apply the conversion when parsing, and carry the version through** + +In `ParseBlueprint.Execute`, just before `return root.Blueprint;`, add: + +```csharp + for (var i = 0; i < root.Blueprint.Entities.Length; i++) + { + var entity = root.Blueprint.Entities[i]; + if (entity.Direction.HasValue) + { + entity.Direction = ToInternalDirection(entity.Direction.Value, root.Blueprint.Version); + } + } +``` + +Then, in `src/FactorioTools.Serialization/OilField/Steps/GridToBlueprintString.cs`, at the top of `SerializeBlueprint` (before the FBE offset block), add: + +```csharp + // ToOutputDirection always emits Factorio 2.0's 16-way values, so the stamp has to + // say 2.0 or later. Otherwise a normalized blueprint carries 2.x directions under + // whatever version the source had, and reparsing it halves values that were already + // converted. The planner's own path (see the Blueprint built above) already sets + // this; normalize reaches SerializeBlueprint with a cleaned blueprint that does not. + blueprint.Version = FormatVersion(2, 0, 32, 0); +``` + +`CleanBlueprint.cs` is deliberately left alone; see the note under Files. + +- [ ] **Step 6: Re-normalize both corpus files** + +The sequence matters. Directions are converted on parse now, and multiplied by 2 on serialize, so a re-normalize rewrites 1.1 values into 2.x values. But the corpus has `version: 0`, which the new parser treats as modern, so a straight re-normalize would halve values that are already 1.1. + +So stamp first, in one throwaway pass. Run from the repo root: + +```bash +python3 - <<'PY' +import base64, zlib, json, pathlib +# FormatVersion(1, 1, 0, 0) - say out loud what the corpus already is, so the parser +# stops guessing. After the re-normalize below they come back stamped 2.x. +V = (1 << 48) | (1 << 32) +for name in ["small-list.txt", "big-list.txt"]: + p = pathlib.Path("test/FactorioTools.Test/OilField") / name + out = [] + for line in p.read_text().splitlines(): + s = line.strip() + if not s or s.startswith("#"): + out.append(line) + continue + j = json.loads(zlib.decompress(base64.b64decode(s[1:]))) + j["blueprint"]["version"] = V + raw = json.dumps(j, separators=(",", ":")).encode() + out.append("0" + base64.b64encode(zlib.compress(raw, 9)).decode()) + p.write_text("\n".join(out) + "\n") + print(f"stamped {name}") +PY +``` + +Then re-normalize through the CLI, which reparses (now correctly, as 1.1) and re-emits as 2.x: + +```bash +dotnet run --project src/FactorioTools.Cli -- oil-field normalize +``` + +- [ ] **Step 7: Verify the corpus actually moved to 2.x** + +```bash +python3 - <<'PY' +import base64, zlib, json +for name in ["small-list.txt", "big-list.txt"]: + path = f"test/FactorioTools.Test/OilField/{name}" + dirs, vers = {}, set() + for line in open(path): + s = line.strip() + if not s or s.startswith("#"): + continue + bp = json.loads(zlib.decompress(base64.b64decode(s[1:])))["blueprint"] + vers.add(bp.get("version")) + for e in bp.get("entities", []): + if e.get("name") == "pumpjack": + dirs[e.get("direction")] = dirs.get(e.get("direction"), 0) + 1 + print(name, "directions:", dict(sorted(dirs.items(), key=lambda x: (x[0] is not None, x[0])))) + print(name, "versions:", vers) +PY +``` + +Expected: directions are now a subset of `{None, 4, 8, 12}` with NO `2` and NO `6`. Versions are all `562949953421312` or higher, none `0`. If any `2` or `6` survives, stop; the conversion is not symmetric. + +- [ ] **Step 8: Run the full suite and check the invariant** + +Run: `dotnet test` + +Expected: PASS, and **`Score.HasExpectedScore.verified.txt` unchanged**. Re-normalizing and fixing the parser are inverse operations, so they must cancel. Verify with: + +```bash +git diff --stat test/FactorioTools.Test/OilField/Score.HasExpectedScore.verified.txt +``` + +Expected: no output. If the scoreboard moved, the change is WRONG. Do not accept the diff. Investigate whether the conversion is asymmetric or the corpus rewrite altered something beyond direction. + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "Read blueprint directions using the blueprint's own version + +Output already multiplied directions by 2 for Factorio 2.0's 16-way encoding, +but input did nothing, so a 2.x east pumpjack (4) parsed as Direction.Down. +That is the reported mis-rotation. + +Sniffing the values cannot fix it: a blueprint whose directions are all in +{0, 4} is valid under both encodings with different meanings. Version is the +only sound signal, so ToInternalDirection gates on it, treating an unstamped +blueprint as modern because that is what users paste. + +The corpus was 1.1-encoded, which is exactly why no test caught this. Both +lists are re-normalized to 2.x. SerializeBlueprint now stamps the version next +to the code that doubles the directions, so the two can never disagree; the +normalize path previously emitted 2.x directions under version 0. + +Score.HasExpectedScore.verified.txt is unchanged, as it must be: the corpus +rewrite and the parser fix are inverse operations." +``` + +--- + +### Task 6: Regression test for the reported bug + +Task 5 proves the conversion is self-consistent. This proves it against a blueprint the game actually produced. + +**Files:** +- Modify: `test/FactorioTools.Test/OilField/ParseBlueprintTest.cs` + +**Interfaces:** +- Consumes: `ParseBlueprint.ToInternalDirection` from Task 5, `Entity.Mirror` from Task 4. +- Produces: nothing. + +- [ ] **Step 1: Generate the fixture from the game** + +Use the probe-mod technique in CLAUDE.md with exactly these entities: + +```lua +stack.set_blueprint_entities{ + {entity_number = 1, name = "pumpjack", position = {x = 0.5, y = 0.5}, direction = defines.direction.north}, + {entity_number = 2, name = "pumpjack", position = {x = 10.5, y = 0.5}, direction = defines.direction.east}, + {entity_number = 3, name = "pumpjack", position = {x = 20.5, y = 0.5}, direction = defines.direction.south}, + {entity_number = 4, name = "pumpjack", position = {x = 30.5, y = 0.5}, direction = defines.direction.west}, +} +``` + +- [ ] **Step 2: Write the test** + +```csharp + /// + /// Exported from Factorio 2.1.14: four pumpjacks facing north, east, south, west. + /// This is the case the corpus cannot cover, because the corpus is 1.1-encoded. + /// It is the test that would have caught the original bug report. + /// + private const string FourCardinalPumpjacks = "REPLACE_ME"; + + [Fact] + public void ReadsEveryCardinalFromARealFactorio21Blueprint() + { + var blueprint = ParseBlueprint.Execute(FourCardinalPumpjacks); + + Assert.Collection( + blueprint.Entities, + e => Assert.Equal(Direction.Up, e.Direction ?? Direction.Up), + e => Assert.Equal(Direction.Right, e.Direction), + e => Assert.Equal(Direction.Down, e.Direction), + e => Assert.Equal(Direction.Left, e.Direction)); + } +``` + +- [ ] **Step 3: Run to verify it passes** + +Run: `dotnet test test/FactorioTools.Test/FactorioTools.Test.csproj --filter "FullyQualifiedName~ParseBlueprintTest"` + +Expected: PASS. If east reads as `Direction.Down`, Task 5 did not take effect. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "Pin the reported bug with a blueprint the game actually produced + +The corpus is 1.1-encoded, so it cannot cover a real 2.x blueprint. This +fixture came out of Factorio 2.1.14 with all four cardinals, which is the case +that was silently rotating." +``` + +--- + +### Task 7: Regenerate Lua, rebuild WASM, verify the build matrix + +The core changed (`Entity.cs` in Task 4, `ItemNames.cs` in Task 2), so generated artifacts must follow or CI fails. `CleanBlueprint.cs` is deliberately untouched, so the core diff is small. + +**Files:** +- Modify: `src/lua/**` (regenerated) +- Modify: `src/vue/public/framework/**` (regenerated, gitignored) + +**Interfaces:** +- Consumes: every earlier task. +- Produces: nothing. + +- [ ] **Step 1: Regenerate the Lua** + +Run: `pwsh src/lua/Invoke-LuaBuild.ps1` + +- [ ] **Step 2: Syntax-check the generated Lua** + +Run (bash): `find src/lua -name '*.lua' -exec luac5.2 -p {} \;` + +Expected: no output. Any output is a syntax error. + +- [ ] **Step 3: Build and test under Lua settings** + +Run: `dotnet build /p:UseLuaSettings=true && dotnet test /p:UseLuaSettings=true` + +Expected: PASS. This catches core changes that break the Lua-safe configuration. + +- [ ] **Step 4: Rebuild the WASM bundle** + +Run: `cd src/vue && npm run build-wasm` + +Then confirm the bundle landed in the right shape, not flattened: + +```bash +test -f src/vue/public/framework/dotnet.js && echo "bundle shape ok" || echo "WRONG: dotnet.js is not in public/framework" +``` + +- [ ] **Step 5: Verify the front end still plans in a real browser** + +Run: `cd src/vue && npm run build && npm run preview` + +`npm run dev` cannot run the WASM planner; use build plus preview. Load the page, paste the four-cardinal blueprint from Task 6, and confirm the plan comes back with pumpjacks facing the right way. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "Regenerate the Lua for the 2.1 compatibility fixes + +The core changed (Entity, ItemNames), so the committed Lua has to follow or +transpile-lua fails. Syntax-checked with luac5.2 and tested under +UseLuaSettings=true." +``` + +- [ ] **Step 7: Open the pull request** + +```bash +git push -u origin fix/factorio-21-oracle +gh pr create --title "Speak Factorio 2.1 instead of 1.1" --body "$(cat <<'EOF' +Fixes mis-rotated pumpjacks and unrecognized renamed items reported since Factorio 2.1. + +Both symptoms are one root cause: the planner still spoke Factorio 1.1. + +## What was wrong + +- `effectivity-module-3` has not existed since 2.0, so the game silently rejected it +- directions were multiplied by 2 on output but never divided on input, so a 2.x east pumpjack (4) parsed as south +- the `mirror` flag added for pumpjack flipping in 2.1.7 was dropped on parse +- the test corpus is 1.1-encoded, which is why none of this ever failed a test + +## How it was found + +Rather than trusting the wiki, `tools/capture-factorio-oracle.sh` pulls prototype facts out of the game and commits them as a fixture. `FactorioOracleTest` asserts the planner's constants against it on every build, with no Factorio install needed on CI. Re-capture after a game update and a changed fixture fails the test with a diff. + +The version, direction and mirror encodings were confirmed by round-tripping blueprints through Factorio 2.1.14, and cross-checked against `wube/factorio-data` at tag 2.1.14. + +## The safety check + +Re-normalizing the corpus and fixing the parser are inverse operations, so `Score.HasExpectedScore.verified.txt` is **unchanged**. A moved scoreboard would have meant the change was wrong. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage.** C1 landed already. C2 is Task 1. C3 is Task 5. C4 is Tasks 2 and 3. C5 is Task 4. C6 is Task 5 step 6. The invariant is Task 5 step 8. Lua and WASM are Task 7. The manual-drift-check decision needs no task. Out-of-scope items correctly have no task. + +**Placeholder scan.** Two `REPLACE_ME` constants remain by design, in Tasks 4 and 6. They cannot be filled in ahead of time because the blueprint strings must come out of a running Factorio. Both carry the exact `set_blueprint_entities` call that generates them, so the step is mechanical. + +**Type consistency.** `ToInternalDirection(Direction, ulong)` is defined in Task 5 step 3 and used with that signature in Task 5 step 1 and Task 6. `Entity.Mirror` is `bool?` in Task 4 and asserted with `Assert.True(e.Mirror)` and `Assert.Null(e.Mirror)`, both valid for `bool?`. `migrateModuleNames` is generic over `T extends Record` in Task 3 and called both ways consistently. + +**Caught in self-review, already fixed above.** Task 5 originally had `CleanBlueprint` carry the source version through. That is wrong: `ToOutputDirection` always emits 2.x values, so a 1.1 input would come out with 2.x directions under a 1.1 stamp, and reparsing would halve values that were already converted. Stamping now happens in `SerializeBlueprint`, next to the doubling, so the two cannot disagree. + +**Known soft spots for the executor.** Task 3 guesses the store's module property names; read `OilFieldStore.ts` and use the real ones. Task 3 also assumes `pinia-plugin-persistedstate` exposes `afterHydrate`; confirm against the installed version and use its documented hook if the name differs. Task 5 step 6's stamping pass rewrites the corpus with Python before the CLI re-normalize; run it on a clean tree so `git checkout` can undo it if the sequence needs a retry. diff --git a/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md b/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md index d65ae77e..c12ea4c4 100644 --- a/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md +++ b/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md @@ -176,6 +176,14 @@ The same is expected of the per-blueprint plan snapshots. A moved snapshot is a - Rebuild the WASM bundle (`npm run build-wasm` in `src/vue`). - Build and test under `UseLuaSettings=true` as well as the default, per the repo's CI matrix. +## Decided: the drift check stays manual + +`tools/capture-factorio-oracle.sh --check` will not run in CI. CI has no Factorio install and never will, so it could not run there anyway. + +The axis that matters is covered: `FactorioOracleTest` (C2) compares the committed fixture against the C# constants on every build, with no game needed. `--check` covers a different axis - installed game versus committed fixture - and that is a human step after a Factorio update, documented in CLAUDE.md. + +The residual gap is that nothing automatically notices a new Factorio release. Accepted for now. + ## Out of scope - **Honoring a pumpjack flip on output.** The planner picks orientations itself, so it is not clear what honoring an input flip would even mean, and answering that needs real investigation into whether a mirrored pumpjack changes valid terminal positions. C5 only stops the flag being lost. From cae04b00e211c45fd09759441f260f711a0f8a88 Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 16 Aug 2026 16:22:05 -0700 Subject: [PATCH 05/17] Embed the generated blueprint fixtures in the plan Both were REPLACE_ME placeholders the executor would have had to fill by running Factorio. They are now literal strings exported from 2.1.14 and verified to decode to exactly what their tests assert, so every task in the plan is mechanical. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-16-factorio-21-fixes.md | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-08-16-factorio-21-fixes.md b/docs/superpowers/plans/2026-08-16-factorio-21-fixes.md index 0b377833..e55e4c76 100644 --- a/docs/superpowers/plans/2026-08-16-factorio-21-fixes.md +++ b/docs/superpowers/plans/2026-08-16-factorio-21-fixes.md @@ -382,7 +382,7 @@ public class ParseBlueprintTest : BaseTest /// Generated by a probe mod running inside Factorio 2.1.14: three pumpjacks, the /// second and third mirrored. See CLAUDE.md for the probe-mod technique. /// - private const string MirroredPumpjacks = "REPLACE_ME"; + private const string MirroredPumpjacks = "0eNqN0MkKgzAQBuB3mfNQNC5UX6WU4jKUaU0MSSwVybs36qFQK3gaZvm/w0xQdwNpw8pBOQE3vbJQXiawfFdVN89UJQlK0IPUj6p5gkdg1dIbythfEUg5dkxramnGmxpkTSYc4CaNoHsbAr2a7YBEpwxhXGqQWzbUrNvU4wYUB8B4X0SQbEwfIGcG+uMnB3yx659//PAddiSD9n0ywouMXc6zXBRZKtIiifI4Trz/ADGoghU="; [Fact] public void KeepsTheMirrorFlagOnAFlippedEntity() @@ -398,7 +398,15 @@ public class ParseBlueprintTest : BaseTest } ``` -Generate the blueprint string with the probe-mod technique in CLAUDE.md, using exactly these entities: +The string above is real output from Factorio 2.1.14, already generated, so use it as written. It decodes to version `562954249306113` and: + +``` +{entity_number: 1, direction: 4} +{entity_number: 2, direction: 4, mirror: True} +{entity_number: 3, direction: 8, mirror: True} +``` + +For reference, it came from the probe-mod technique in CLAUDE.md with these entities. Only regenerate if you need to change what is being tested: ```lua stack.set_blueprint_entities{ @@ -408,8 +416,6 @@ stack.set_blueprint_entities{ } ``` -Paste the exported string in place of `REPLACE_ME`. - - [ ] **Step 2: Run the test to verify it fails** Run: `dotnet test test/FactorioTools.Test/FactorioTools.Test.csproj --filter "FullyQualifiedName~ParseBlueprintTest"` @@ -702,9 +708,18 @@ Task 5 proves the conversion is self-consistent. This proves it against a bluepr - Consumes: `ParseBlueprint.ToInternalDirection` from Task 5, `Entity.Mirror` from Task 4. - Produces: nothing. -- [ ] **Step 1: Generate the fixture from the game** +- [ ] **Step 1: Confirm the fixture** + +The string in Step 2 is real output from Factorio 2.1.14, already generated, so use it as written. It decodes to version `562954249306113` and: + +``` +{entity_number: 1} <- north, field omitted entirely +{entity_number: 2, direction: 4} <- east +{entity_number: 3, direction: 8} <- south +{entity_number: 4, direction: 12} <- west +``` -Use the probe-mod technique in CLAUDE.md with exactly these entities: +For reference, it came from the probe-mod technique in CLAUDE.md with these entities. Only regenerate if you need to change what is being tested: ```lua stack.set_blueprint_entities{ @@ -723,7 +738,7 @@ stack.set_blueprint_entities{ /// This is the case the corpus cannot cover, because the corpus is 1.1-encoded. /// It is the test that would have caught the original bug report. /// - private const string FourCardinalPumpjacks = "REPLACE_ME"; + private const string FourCardinalPumpjacks = "0eNqN0csKwyAQBdB/mbWU+Eho/JVSSh5Dsa1G1JSG4L/XJIsWkkBWg3rvWTgj1K8erVMmgBxBNZ3xIC8jeHU31Wu6M5VGkGB7bR9V84RIQJkWPyBpvBJAE1RQuLTmw3Azva7RpQBZtQnYzqdCZyY7IdkpJzDMM0ayItgBgv4bBFrlsFmexYbID4hsVzxviOKAyHdFyqZvVAF1An7bIPBG5+dEXrAyF0yUPCso5TF+Abr9jgg="; [Fact] public void ReadsEveryCardinalFromARealFactorio21Blueprint() @@ -850,7 +865,7 @@ EOF **Spec coverage.** C1 landed already. C2 is Task 1. C3 is Task 5. C4 is Tasks 2 and 3. C5 is Task 4. C6 is Task 5 step 6. The invariant is Task 5 step 8. Lua and WASM are Task 7. The manual-drift-check decision needs no task. Out-of-scope items correctly have no task. -**Placeholder scan.** Two `REPLACE_ME` constants remain by design, in Tasks 4 and 6. They cannot be filled in ahead of time because the blueprint strings must come out of a running Factorio. Both carry the exact `set_blueprint_entities` call that generates them, so the step is mechanical. +**Placeholder scan.** None. The two blueprint fixtures in Tasks 4 and 6 were generated from Factorio 2.1.14 and are embedded as literal strings, verified to decode to exactly what their tests assert. Every task is mechanical. **Type consistency.** `ToInternalDirection(Direction, ulong)` is defined in Task 5 step 3 and used with that signature in Task 5 step 1 and Task 6. `Entity.Mirror` is `bool?` in Task 4 and asserted with `Assert.True(e.Mirror)` and `Assert.Null(e.Mirror)`, both valid for `bool?`. `migrateModuleNames` is generic over `T extends Record` in Task 3 and called both ways consistently. From 5f4993ec8e7a45dd4df6de8b71d774fc20d531ce Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 16 Aug 2026 16:22:40 -0700 Subject: [PATCH 06/17] Ignore the subagent-driven-development scratch directory Per-plan ledgers, task briefs and review packages are working state for an in-flight plan. Git history is the record, not this. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 6e4578c5..db6d581d 100644 --- a/.gitignore +++ b/.gitignore @@ -410,3 +410,7 @@ pump_2.2.0.zip # Wrangler local dev state (Cloudflare Pages/Workers CLI cache, incl. miniflare) .wrangler/ + +# Subagent-driven-development scratch: per-plan ledgers, task briefs, review +# packages. Working state for an in-flight plan, not a record - git history is. +.superpowers/ From 73a29a723958ed042956f9788099180876885b72 Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 16 Aug 2026 16:25:29 -0700 Subject: [PATCH 07/17] Assert planner constants against the captured Factorio oracle Reads the committed fixture, never the game, so CI needs no Factorio install. EveryVanillaModuleNameExistsInFactorio fails on effectivity-module-3, which is the reported bug reproduced as a test. Fixed in the next commit. Also corrects three spec claims found while planning: FormatVersion/ParseVersion already exist, output is already version-stamped, and the fixture needs no csproj entry. --- .../2026-08-16-factorio-21-oracle-design.md | 6 +- .../OilField/FactorioOracleTest.cs | 105 ++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 test/FactorioTools.Test/OilField/FactorioOracleTest.cs diff --git a/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md b/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md index c12ea4c4..426f6991 100644 --- a/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md +++ b/docs/superpowers/specs/2026-08-16-factorio-21-oracle-design.md @@ -85,13 +85,13 @@ This mirrors `PlannerDefaultsTest`, which is already the repo's "the test is the The failure message must name `tools/capture-factorio-oracle.sh`, so a red CI run says how to fix itself. -Note: `factorio-oracle.json` will need a `` entry in `test/FactorioTools.Test/FactorioTools.Test.csproj`, alongside the existing `small-list.txt` entry. +Note: tests resolve the fixture through `BaseTest.GetRepositoryRoot()`, matching `BasePlannerTest.SmallListFilePath`, so no csproj entry is needed. ### C3 - Direction conversion **The internal `Direction` enum does not change.** It stays `Up=0, Right=2, Down=4, Left=6`. It is the planner's logical four-way concept, it is transpiled to Lua, and renumbering it would churn the whole core, every snapshot, and the Lua output for no benefit. All version knowledge lives at the serialization boundary. -Output keeps `ToOutputDirection` (multiply by 2) and starts writing a real version stamp instead of 0. +Output keeps `ToOutputDirection` (multiply by 2). Output already stamps a real version: `GridToBlueprintString.cs:222` sets `Version = FormatVersion(2, 0, 32, 0)`, and `ParseVersion`/`FormatVersion` already exist at `:233` and `:243`, matching the confirmed layout below. Input gains the inverse, in `FactorioTools.Serialization`: @@ -151,6 +151,8 @@ Extend `NormalizeBlueprints` to convert 1.1 direction values to 2.x and stamp a Both lists, not just the scored one. `big-list.txt` is not scored so it carries no snapshot risk, but leaving it on 1.1 means the two corpora disagree and the next person to look hits exactly the confusion this document exists to clear up. +The corpus carries `version: 0` because `CleanBlueprint.cs:34` builds a `new Blueprint` without copying `Version` from the input. + ## The invariant that makes this safe Re-normalizing the corpus to 2.x and fixing the parser are inverse operations. Applied together, they should cancel exactly. diff --git a/test/FactorioTools.Test/OilField/FactorioOracleTest.cs b/test/FactorioTools.Test/OilField/FactorioOracleTest.cs new file mode 100644 index 00000000..cfb0af82 --- /dev/null +++ b/test/FactorioTools.Test/OilField/FactorioOracleTest.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using Knapcode.FactorioTools.Data; + +namespace Knapcode.FactorioTools.OilField; + +/// +/// Asserts the planner's hardcoded Factorio facts against an oracle captured from the game +/// itself (tools/capture-factorio-oracle.sh). +/// +/// This reads the COMMITTED fixture, never the game, so CI needs no Factorio install. +/// Re-capture after a Factorio update and commit the diff; a changed fixture failing here +/// is the signal that a constant needs review. +/// +public class FactorioOracleTest : BaseTest +{ + private static readonly string FixturePath = Path.Combine( + GetRepositoryRoot(), "test", "FactorioTools.Test", "OilField", "factorio-oracle.json"); + + private const string ReCaptureHint = + "If Factorio changed, re-run tools/capture-factorio-oracle.sh and review the diff."; + + private static JsonElement Oracle() + { + return JsonDocument.Parse(File.ReadAllText(FixturePath)).RootElement; + } + + private static HashSet Names(JsonElement parent, string property) + { + var element = parent.GetProperty(property); + if (element.ValueKind == JsonValueKind.Object) + { + return element.EnumerateObject().Select(x => x.Name).ToHashSet(); + } + + return element.EnumerateArray().Select(x => x.GetString()!).ToHashSet(); + } + + [Fact] + public void EveryVanillaEntityNameExistsInFactorio() + { + var entities = Names(Oracle(), "entities"); + + var missing = typeof(EntityNames.Vanilla) + .GetFields() + .Select(f => (string)f.GetValue(null)!) + .Where(name => !entities.Contains(name)) + .ToList(); + + Assert.True(missing.Count == 0, $"Not in Factorio: {string.Join(", ", missing)}. {ReCaptureHint}"); + } + + /// + /// EntityNames.AaiIndustry is deliberately NOT checked. Those names come from the AAI + /// Industry mod, and the oracle is captured with mods disabled on purpose, so their + /// absence is expected rather than drift. + /// + [Fact] + public void EveryVanillaModuleNameExistsInFactorio() + { + var modules = Names(Oracle(), "modules"); + + var missing = typeof(ItemNames.Vanilla) + .GetFields() + .Select(f => (string)f.GetValue(null)!) + // "blueprint" is an item, not a module, so it is not in the module list. + .Where(name => name != ItemNames.Vanilla.Blueprint) + .Where(name => !modules.Contains(name)) + .ToList(); + + Assert.True(missing.Count == 0, $"Not a Factorio module: {string.Join(", ", missing)}. {ReCaptureHint}"); + } + + [Theory] + [InlineData(Direction.Up, "north")] + [InlineData(Direction.Right, "east")] + [InlineData(Direction.Down, "south")] + [InlineData(Direction.Left, "west")] + public void InternalDirectionDoublesToTheFactorioValue(Direction direction, string factorioName) + { + var expected = Oracle().GetProperty("directions").GetProperty(factorioName).GetInt32(); + + // The internal enum is deliberately 1.1-style four-way (N=0, E=2, S=4, W=6). + // Factorio 2.0 is 16-way, so the blueprint value is always exactly double. + Assert.Equal(expected, (int)direction * 2); + } + + [Theory] + [InlineData(EntityNames.Vanilla.SmallElectricPole, "supply_area_distance", 2.5)] + [InlineData(EntityNames.Vanilla.MediumElectricPole, "supply_area_distance", 3.5)] + [InlineData(EntityNames.Vanilla.BigElectricPole, "supply_area_distance", 2)] + [InlineData(EntityNames.Vanilla.Substation, "supply_area_distance", 9)] + [InlineData(EntityNames.Vanilla.SmallElectricPole, "maximum_wire_distance", 7.5)] + [InlineData(EntityNames.Vanilla.MediumElectricPole, "maximum_wire_distance", 9)] + [InlineData(EntityNames.Vanilla.BigElectricPole, "maximum_wire_distance", 32)] + [InlineData(EntityNames.Vanilla.Substation, "maximum_wire_distance", 18)] + [InlineData(EntityNames.Vanilla.Beacon, "supply_area_distance", 3)] + public void RawGeometryBehindTheOptionsPresetsIsUnchanged(string entity, string field, double expected) + { + var actual = Oracle().GetProperty("entities").GetProperty(entity).GetProperty(field).GetDouble(); + + Assert.True( + expected == actual, + $"{entity}.{field} moved from {expected} to {actual}, so the OilFieldOptions presets need review. {ReCaptureHint}"); + } +} From 1ba6bec726bff6c5674a5c782c8579f7f2f0f841 Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 16 Aug 2026 16:29:52 -0700 Subject: [PATCH 08/17] Fix misplaced AaiIndustry carve-out comment in FactorioOracleTest The doc comment explaining why EntityNames.AaiIndustry is not checked was attached to EveryVanillaModuleNameExistsInFactorio, which reflects only ItemNames.Vanilla and never touches EntityNames. Moved it to EveryVanillaEntityNameExistsInFactorio, the method that actually excludes AaiIndustry, and reworded it to name that method's own reflection target. Per spec section C2, both carve-outs need a comment saying why; this restores that for the entity carve-out. --- test/FactorioTools.Test/OilField/FactorioOracleTest.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/FactorioTools.Test/OilField/FactorioOracleTest.cs b/test/FactorioTools.Test/OilField/FactorioOracleTest.cs index cfb0af82..2ecb068e 100644 --- a/test/FactorioTools.Test/OilField/FactorioOracleTest.cs +++ b/test/FactorioTools.Test/OilField/FactorioOracleTest.cs @@ -35,6 +35,11 @@ private static HashSet Names(JsonElement parent, string property) return element.EnumerateArray().Select(x => x.GetString()!).ToHashSet(); } + /// + /// This reflects EntityNames.Vanilla only, so EntityNames.AaiIndustry is deliberately NOT + /// checked. Those names come from the AAI Industry mod, and the oracle is captured with + /// mods disabled on purpose, so their absence is expected rather than drift. + /// [Fact] public void EveryVanillaEntityNameExistsInFactorio() { @@ -49,11 +54,6 @@ public void EveryVanillaEntityNameExistsInFactorio() Assert.True(missing.Count == 0, $"Not in Factorio: {string.Join(", ", missing)}. {ReCaptureHint}"); } - /// - /// EntityNames.AaiIndustry is deliberately NOT checked. Those names come from the AAI - /// Industry mod, and the oracle is captured with mods disabled on purpose, so their - /// absence is expected rather than drift. - /// [Fact] public void EveryVanillaModuleNameExistsInFactorio() { From 5f81e53f6373448a873086df543f861e836d0436 Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 16 Aug 2026 16:42:28 -0700 Subject: [PATCH 09/17] Emit efficiency-module-3, the name Factorio has used since 2.0 effectivity-module-3 has not existed since 2.0, so the game silently rejected it. The C# identifier was already EfficiencyModule3; only the string was stale. --- src/FactorioTools/Data/ItemNames.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/FactorioTools/Data/ItemNames.cs b/src/FactorioTools/Data/ItemNames.cs index 69f59a83..c1a98775 100644 --- a/src/FactorioTools/Data/ItemNames.cs +++ b/src/FactorioTools/Data/ItemNames.cs @@ -4,7 +4,9 @@ public static class ItemNames { public static class Vanilla { - public const string EfficiencyModule3 = "effectivity-module-3"; + // Renamed in Factorio 2.0. "effectivity-module-3" no longer exists, so the game + // silently rejects it. See base/migrations/2.0.0.json in the game data. + public const string EfficiencyModule3 = "efficiency-module-3"; public const string ProductivityModule3 = "productivity-module-3"; public const string SpeedModule3 = "speed-module-3"; From a7f0797bed6bce4b342c183411cc7a2ed973219b Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 16 Aug 2026 16:48:39 -0700 Subject: [PATCH 10/17] Rename efficiency modules in the Vue app and migrate saved settings Changing the dropdown alone would leave every existing user sending effectivity-module-3, which Factorio has not accepted since 2.0. The name is persisted in localStorage AND travels in shared query-string links (storeToQuery maps pumpjackModule/beaconModule into the URL), and only the localStorage path runs pinia-plugin-persistedstate's afterHydrate hook. So migrateModuleNames is called from both load sites: the persist afterHydrate hook in getStore(), and explicitly at the end of populateStoreFromQuery. --- src/vue/src/components/ModuleSelect.vue | 6 +-- src/vue/src/stores/OilFieldStore.ts | 31 +++++++++++ src/vue/src/stores/persistence.test.ts | 68 +++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/vue/src/components/ModuleSelect.vue b/src/vue/src/components/ModuleSelect.vue index ecd05ea3..eb9756f8 100644 --- a/src/vue/src/components/ModuleSelect.vue +++ b/src/vue/src/components/ModuleSelect.vue @@ -9,9 +9,9 @@ @update:modelValue="(newVal: string) => $emit('update:modelValue', newVal)" > - - - + + +