refactor(world): empty-world — one canonical world-state skeleton - #176
Conversation
`make-world` was the only world constructor and it is fused to
`generate-floor`, so anything wanting a world without dungeon gen
hand-rolled the map literal instead. Four copies existed and they
disagreed: the property-test room set 7 keys, the scenario builder 19,
the feature-room generator 6, and a combat-prop fixture 6. Nothing tied
any of them to what `update-world` and friends actually read, so a new
world field would silently leave the generated worlds behind — property
tests would keep passing against a world missing the key under test.
`world/empty-world` is now the single place the construction-time shape
is written down. `generate-floor` builds on it and every hand-rolled
literal starts from it.
Notes:
- `generate-floor` merges `world-after-terrain` over the skeleton so the
terrain-advanced :seed lineage still wins; the skeleton contributes
:seed-input as the origin seed, which is what that key means. Both
callers reset :seed afterward as before.
- `:terrain` is nil, not a zero-size grid — a caller that forgets to
fill it should fail at the first tget rather than read garbage out of
a grid disagreeing with :width/:height.
- Plain map, not a defrecord. A record would catch missing keys harder,
but the whole codebase treats world as an ordinary map (assoc,
get-in, destructuring, canonical-bytes) and the churn is not worth it
for a shape one function now owns.
- `xsofy.test.invariant-test`'s sparse room is left alone on purpose: it
tests that world-invariants? tolerates a world missing optional keys,
so building it from the skeleton would remove the point.
Verified byte-identical world content: `make-world` for seeds 1/42/1000
and a d1→d2→d3→d2 floor-transition chain produce the same canonical-bytes
hash before and after, modulo the newly-present `:fire-ttl {}`. Test
suite 307/2803 green, smoke-lg OK, both scenariotest scenarios --check OK.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
A second review pass over this turned up things worth recording before merge. None of them change the diff as approved; they are all follow-up work.
(and (= entity-id :player) (some? (:fov world))) (:fov world)The test-side worlds previously had no The docstring overclaims.
Corrected the key counts in the body. The three test-side literals set 7 keys each with identical key sets, and scenariotest's Filed the |
`main.lg` assocs `:title`, `:scheme`, and `:quest-item` onto the world right after `make-world`. Descending dropped two of the three: both `change-floor`'s carry-over assoc and `restore-floor`'s 21-key literal hand-listed what survives, and only `:scheme` was on both lists. The visible symptom is a nameless tombstone — `render.lg` reads `:title` for the death screen, so anyone dying below depth 1 lost it. `:quest-item` has no reader yet, so its loss was silent and waiting. #176 unified world *construction* onto `empty-world` while leaving the transition path spelling the shape out twice by hand, which is how a field could be construction-correct and transition-lossy at once. This finishes that job: - `run-scoped-keys` names what belongs to the run rather than the floor, and `carry-run-state` is the one thing both transition paths call. - `restore-floor` builds on `empty-world` like every other constructor, assoc'ing the floor-scoped fields it reads back out of `:floors`. - `empty-world`'s docstring no longer claims to be the only place the shape is written down — it now names `run-scoped-keys` as the companion answer to "does this field survive a descent?". It also dropped a false claim that `:next-id` comes from entity spawning; `entities/next-id` is seed-derived and never writes that key. `:seed` is deliberately excluded from `run-scoped-keys`. It heads the RNG chain and both callers restore it as their final step, so that `spawn-runestones` in between consumes the floor lineage rather than the gameplay one. Carrying it with the rest would move that restore earlier and shift every subsequent roll. `generate-floor` now takes `:seed` across from the terrain-advanced world explicitly instead of merging it wholesale. Behavior is identical today — terrain gen threads only `:seed` — but the blanket merge would have silently handed it every other key the day it threads more. New `world_shape_test.lg` pins both halves: every skeleton key present after construction, after a descent, and after a revisit; and the run-scoped fields surviving both. Its four `:title`/`:quest-item` assertions fail on `main` and pass here. Determinism verified unchanged: a d1→d2→d3→d2→d1 chain produces identical `canonical-bytes` hashes and identical `:seed` before and after, once the newly-surviving `:title`/`:quest-item` are excluded. Suite 314/2818 green, smoke-lg OK, both scenariotest scenarios OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`entity-fov` decides whether the player's FOV is already cached by
asking whether `:fov` is present:
(and (= entity-id :player) (some? (:fov world))) (:fov world)
That read the world's shape as a signal. It worked only while worlds
that hadn't been through `update-fov` had no `:fov` key at all — which
stopped being true in #176, when `empty-world` began seeding it to
`#{}`. Any caller perceiving a skeleton-built world now gets the empty
set handed back as though it were a computed result, so "can the player
see anything" answers no.
Nothing hits this today: `percept` is reached only from `balance.lg`,
which runs on `make-world` output that `generate-floor` has already
FOV'd. It's the first percept-based property test that would have found
it, by watching every visibility assertion quietly answer false.
`(seq (:fov world))` is the honest test. `fov/compute-fov` seeds its
result with the origin cell, so a computed FOV always contains at least
the entity's own position and an empty one can only mean uncomputed.
New `percept_test.lg` covers both sides of the branch plus the NPC path;
its two player-side assertions fail against the current `percept.lg` and
pass with this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`entity-fov` decides whether the player's FOV is already cached by
asking whether `:fov` is present:
(and (= entity-id :player) (some? (:fov world))) (:fov world)
That read the world's shape as a signal. It worked only while worlds
that hadn't been through `update-fov` had no `:fov` key at all — which
stopped being true in #176, when `empty-world` began seeding it to
`#{}`. Any caller perceiving a skeleton-built world now gets the empty
set handed back as though it were a computed result, so "can the player
see anything" answers no.
Nothing hits this today: `percept` is reached only from `balance.lg`,
which runs on `make-world` output that `generate-floor` has already
FOV'd. It's the first percept-based property test that would have found
it, by watching every visibility assertion quietly answer false.
`(seq (:fov world))` is the honest test. `fov/compute-fov` seeds its
result with the origin cell, so a computed FOV always contains at least
the entity's own position and an empty one can only mean uncomputed.
New `percept_test.lg` covers both sides of the branch plus the NPC path;
its two player-side assertions fail against the current `percept.lg` and
pass with this change.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
refactor(world):
empty-world— one canonical world-state skeletonCloses #172. Follows up @nnunley's review comments on #170.
Why
xsofy.world/make-worldis the only world constructor and it's fused togenerate-floor, so it always runs full dungeon gen. Anything that wants a world without dungeon gen has no lighter constructor to call and hand-rolls the map literal instead. Four copies existed, in two shapes that disagree:check-gen/minimal-stone-roomcheck-gen/build-feature-roomcombat_prop'sfreshfixturetools/scenariotest/build.lg'sbase(added in #170)The three 7-key literals have identical key sets;
basesets 20. So the split is the one #172 named — a test-side shape and a scenario-side shape, 13 keys apart.Nothing tied any of them to what
update-worldand friends read. Add a field to the world map and the generated worlds silently fall behind: a property test keeps running green against a world missing the very key the code under test reads, covering less than it looks like it covers.gen-rich-worldwas already patching:depth,:floors, and:fire-ttlback on after the fact to close part of that gap.What
world/empty-worldis now the one place the construction-time world shape is written down.generate-floorbuilds on it, and each hand-rolled literal starts from it and assocs only what's specific to that caller:Adding a construction-time field is one edit here instead of an audit across four files. A field that also has to survive a floor transition still needs
restore-floorandchange-floorupdated by hand — see the follow-up note below.Decisions
Plain map, not a
defrecord. #172 raised the option. A record would catch the missing-key class harder than a docstring does, but the codebase treats world as an ordinary map throughout:assoc,get-in,:keysdestructuring,serialize/canonical-byteswalking it generically. The churn isn't worth it for a shape one function now owns. Happy to run with this or switch to a record if you'd rather have construction-time validation.:terrainis nil, not a degenerate grid. The other open question in #172. A zero-size grid would letempty-worldalone avoid crashing, at the price of a world whose:width/:heightdisagree with its terrain. Nil means a caller that forgets to fill it fails at the firsttgetinstead of reading plausible garbage.The skeleton covers construction-time shape only.
:rooms-cache,:next-id, and:schemebelong to the systems that add them later, so they stay out.invariant-test's sparse room is unchanged. The tests forworld-invariants?shouldn't be built from the production constructor, or a brokenempty-worldgoes invisible to exactly the predicate meant to catch it.generate-floormerge order. The skeleton goes down first, thenworld-after-terrainmerges over it so terrain gen's advanced:seedlineage still wins. The skeleton contributes:seed-inputas the origin seed, which is what that key means on both call paths. Both callers reset:seedafterward, same as before.Verification
World content is unchanged except for one added empty key.
make-worldat seeds 1, 42, and 1000, plus a d1→d2→d3→d2 floor-transition chain, produce the samecanonical-byteshash on this branch as onmainonce the newly-present:fire-ttl {}is removed. That key is new on every freshly generated world, so a whole-worldcheckpoint-hashdoes change; nothing persists or asserts against one (replay codes are(seed, action-log),dag/commit-idis:seedalone).make test— 307 tests, 2803 assertions, 0 failmake smoke-lg— OKlg tools/scenariotest.lg --checkon both scenarios — OK