feat: module 08 push chains
Movement planned in full, then committed: a Planner builds a plan (moving and destroyed ids) by running content's movement responses (OPEN/SHUT, PUSH, STOP) for every step, rolling back failed branches. Edge of level blocks; YOU objects move furthest-ahead first; rules are reparsed once after movement. Harness: move/move_dir(Dir), posOf, kindsAt. Expansion Drop: PULL as one word plus one content response. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d993457772
commit
bd80cefbef
64 files changed
+6933
No files matched your search
@@ -0,0 +1,121 @@
|
||||
// Movement: validate, then commit.
|
||||
//
|
||||
// A move is planned before anything on the board changes. The `Planner` asks
|
||||
// whether one object can step one tile; to answer, it runs every movement
|
||||
// response content provides for that step, and a response may ask the planner
|
||||
// to move other objects too (a push), or to destroy objects. If any response
|
||||
// says no, the planner forgets everything that step added to the plan, so a
|
||||
// failed push leaves no trace. Only a plan that succeeded is committed.
|
||||
//
|
||||
// The engine knows the board, its edge, and the order of the steps. What each
|
||||
// property does when something moves (`PUSH`, `STOP`, `OPEN`/`SHUT`, ...) is a
|
||||
// response in content, run in the order content lists them.
|
||||
|
||||
import type { Board, ObjectId } from "./grid";
|
||||
|
||||
/** One of the four directions a move can take. */
|
||||
export type Dir = "up" | "down" | "left" | "right";
|
||||
|
||||
export type Pos = readonly [number, number];
|
||||
|
||||
/** One tile in direction `dir`, as `[dx, dy]`; `y` grows down. */
|
||||
export function delta(dir: Dir): Pos {
|
||||
switch (dir) {
|
||||
case "up":
|
||||
return [0, -1];
|
||||
case "down":
|
||||
return [0, 1];
|
||||
case "left":
|
||||
return [-1, 0];
|
||||
case "right":
|
||||
return [1, 0];
|
||||
}
|
||||
}
|
||||
|
||||
/** One object stepping one tile, from `from` to `to`, in direction `dir`. */
|
||||
export interface Step {
|
||||
mover: ObjectId;
|
||||
from: Pos;
|
||||
to: Pos;
|
||||
dir: Dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* A movement response: content's rule for what happens when an object steps.
|
||||
* It returns `false` to block the step. It may plan other moves or destroy
|
||||
* objects through the planner.
|
||||
*/
|
||||
export type Response = (p: Planner, s: Step) => boolean;
|
||||
|
||||
/**
|
||||
* What a move will do: the objects that step one tile, and the objects destroyed.
|
||||
* A destroyed object doesn't step, even if it was pushed.
|
||||
*/
|
||||
export interface Plan {
|
||||
moving: Set<ObjectId>;
|
||||
destroyed: Set<ObjectId>;
|
||||
}
|
||||
|
||||
/** What the planner reads: the board, the properties in force, the responses. */
|
||||
export interface MoveContext {
|
||||
readonly board: Board;
|
||||
hasProperty(id: ObjectId, property: string): boolean;
|
||||
responses(): readonly Response[];
|
||||
}
|
||||
|
||||
/** Plans one move on a board it only reads. */
|
||||
export class Planner {
|
||||
private plan: Plan = { moving: new Set(), destroyed: new Set() };
|
||||
|
||||
constructor(
|
||||
private readonly level: MoveContext,
|
||||
private readonly dir: Dir,
|
||||
) {}
|
||||
|
||||
/** Does object `id` have `property`, under the rules in force before the move? */
|
||||
has(id: ObjectId, property: string): boolean {
|
||||
return this.level.hasProperty(id, property);
|
||||
}
|
||||
|
||||
/**
|
||||
* The objects that will still be on tile `at` when the plan so far is
|
||||
* committed: those there now, less those planned to move away or be destroyed.
|
||||
* Oldest first.
|
||||
*/
|
||||
stayingAt([x, y]: Pos): ObjectId[] {
|
||||
return this.level.board
|
||||
.at(x, y)
|
||||
.map((o) => o.id)
|
||||
.filter((id) => !this.plan.moving.has(id) && !this.plan.destroyed.has(id));
|
||||
}
|
||||
|
||||
/** Plan object `id`'s destruction. */
|
||||
destroy(id: ObjectId): void {
|
||||
this.plan.destroyed.add(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan object `id` stepping one tile in the move's direction, with whatever
|
||||
* the responses add. Returns whether it can. If it can't, the plan is left as
|
||||
* it was before the call. An object already planned to move, or to be
|
||||
* destroyed, is out of the way: the answer is yes, and nothing is added.
|
||||
*/
|
||||
tryMove(id: ObjectId): boolean {
|
||||
if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true;
|
||||
const o = this.level.board.get(id);
|
||||
const [dx, dy] = delta(this.dir);
|
||||
const to: Pos = [o.x + dx, o.y + dy];
|
||||
// The edge of the level blocks every step (the course's choice; see the lesson).
|
||||
if (!this.level.board.inBounds(...to)) return false;
|
||||
const before: Plan = { moving: new Set(this.plan.moving), destroyed: new Set(this.plan.destroyed) };
|
||||
this.plan.moving.add(id);
|
||||
const step: Step = { mover: id, from: [o.x, o.y], to, dir: this.dir };
|
||||
const allowed = this.level.responses().every((respond) => respond(this, step));
|
||||
if (!allowed) this.plan = before;
|
||||
return allowed;
|
||||
}
|
||||
|
||||
result(): Plan {
|
||||
return this.plan;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user