fix(puzzle): an object steps once per movement phase; negated conversion is settled per object

Bug 1 (modules 08-10): a YOU object pulled by an earlier mover took a second step of its own. Level movement now keeps the ids that stepped in a committed plan this phase; such a YOU object starts no move, and Planner try_move answers no for it. Regression: test_08x_pulled_you_moves_once (Module 8 Expansion Drop).

Bug 2 (modules 09-10): convert honoured negation only per rule, so ALL IS ROCK beat BABA IS NOT ROCK. A target Y is now skipped for an object if any active negated rule IS NOT Y names it. Module 9 and 10 references (and their drop solutions) now carry ALL, the Module 7 Expansion Drop. Regression: test_09_negated_conversion_beats_group.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 09:18:00 +01:00
1 parent 638549161f
commit 8467c7929e
28 files changed
+293 -32

No files matched your search

@@ -141,6 +141,10 @@ export class Level {
* The `YOU` objects move one at a time, the one furthest ahead in `dir` first
* (oldest first on a tie); each one's move is planned in full, then committed,
* before the next is planned. Every move uses the rules in force before the first.
*
* An object steps at most once in a movement phase. A `YOU` object that an
* earlier move already carried along (pushed, or moved by any other response)
* doesn't start a move of its own, and no later move can make it step again.
*/
private movement(dir: Dir): void {
const [dx, dy] = delta(dir);
@@ -150,10 +154,15 @@ export class Level {
.filter((o) => this.hasProperty(o.id, YOU))
.sort((a, b) => ahead(b) - ahead(a) || a.id - b.id)
.map((o) => o.id);
const stepped = new Set<ObjectId>();
for (const id of you) {
if (this.board.find(id) === undefined) continue; // destroyed by an earlier move
const planner = new Planner(this, dir);
if (planner.tryMove(id)) this.commit(planner.result(), dir);
if (stepped.has(id)) continue; // already moved, by an earlier move
const planner = new Planner(this, dir, stepped);
if (!planner.tryMove(id)) continue;
const plan = planner.result();
this.commit(plan, dir);
for (const moved of plan.moving) if (!plan.destroyed.has(moved)) stepped.add(moved);
}
}
@@ -169,6 +178,10 @@ export class Level {
* this. An object named by `X IS NOT X` is deleted, and still makes its `Y`s,
* as new objects: the negation cancelled `X IS X`.
*
* Negation is settled per object, as in `hasProperty`: an object doesn't become
* a `Y` if any active `... IS NOT Y` names it, whatever noun gave it the
* conversion (`ALL IS Y` with `X IS NOT Y` leaves every X an X).
*
* Every object is decided from the board as movement left it, before any
* changes, so `X IS Y` and `Y IS X` swap the Xs and the Ys.
*/
@@ -182,6 +195,7 @@ export class Level {
if (!locked) {
for (const r of aboutIt) {
if (r.negated || !isConversion(r)) continue;
if (aboutIt.some((n) => n.negated && n.complement === r.complement)) continue;
const kind = this.lexicon.thingNamed(r.complement);
if (kind === undefined) continue;
const same = (k: Kind) => Kind.format(k) === Kind.format(kind);