Merge branch 'worktree-agent-ab151ac84f067999f' into revise/edge-cases-and-sources

This commit is contained in:
Lucas Winther committed 2026-11-11 09:19:32 +01:00
commit d5d742450c
28 files changed
+293 -32

No files matched your search

@@ -8,6 +8,7 @@
{ "name": "DOOR", "role": "noun" },
{ "name": "SKULL", "role": "noun" },
{ "name": "WATER", "role": "noun" },
{ "name": "ALL", "role": "noun", "selects": "things" },
{ "name": "KEG", "role": "noun" },
{ "name": "BRAMBLE", "role": "noun" },
{ "name": "HEDGE", "role": "noun" },
@@ -8,6 +8,7 @@
{ "name": "DOOR", "role": "noun" },
{ "name": "SKULL", "role": "noun" },
{ "name": "WATER", "role": "noun" },
{ "name": "ALL", "role": "noun", "selects": "things" },
{ "name": "KEG", "role": "noun" },
{ "name": "BRAMBLE", "role": "noun" },
{ "name": "HEDGE", "role": "noun" },
@@ -13,6 +13,8 @@
//! removes them. SpellGrid passes one that sends minions to Part I's Death Creation
//! Step instead.
use std::collections::BTreeSet;
use super::grid::{Board, Kind, LevelDef, ObjectId};
use super::movement::{Dir, Plan, Planner, Response};
use super::parse::{parse, Rule};
@@ -178,6 +180,10 @@ impl Level {
/// (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.
fn movement(&mut self, dir: Dir, destroy: &mut Destroy) {
let (dx, dy) = dir.delta();
let mut you: Vec<_> = self
@@ -187,14 +193,19 @@ impl Level {
.map(|o| (-(o.x * dx + o.y * dy), o.id))
.collect();
you.sort();
let mut stepped = BTreeSet::new();
for (_, id) in you {
if self.board.find(id).is_none() {
continue; // destroyed by an earlier move
}
let mut planner = Planner::new(self, dir);
if stepped.contains(&id) {
continue; // already moved, by an earlier move
}
let mut planner = Planner::new(self, dir, &stepped);
if planner.try_move(id) {
let plan = planner.into_plan();
self.commit(&plan, dir, destroy);
stepped.extend(plan.moving.difference(&plan.destroyed));
}
}
}
@@ -219,6 +230,10 @@ impl Level {
/// object blocks 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 `has_property`: 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.
fn convert(&mut self, destroy: &mut Destroy) {
@@ -234,6 +249,10 @@ impl Level {
let mut into: Vec<Kind> = vec![];
if !locked {
for r in about_it.iter().filter(|r| !r.negated && r.is_conversion()) {
let negated = |n: &&Rule| n.negated && n.complement == r.complement;
if about_it.iter().any(negated) {
continue;
}
match self.lexicon.thing_named(&r.complement) {
Some(kind) if kind != o.kind && !into.contains(&kind) => into.push(kind),
_ => {}
@@ -59,18 +59,22 @@ pub struct Plan {
pub destroyed: BTreeSet<ObjectId>,
}
/// Plans one move on a board it only reads.
/// Plans one move on a board it only reads. `stepped` holds the objects that have
/// already stepped in this movement phase, in an earlier committed plan: an object
/// steps at most once per phase, so none of them can step again.
pub struct Planner<'a> {
level: &'a Level,
dir: Dir,
stepped: &'a BTreeSet<ObjectId>,
plan: Plan,
}
impl<'a> Planner<'a> {
pub fn new(level: &'a Level, dir: Dir) -> Planner<'a> {
pub fn new(level: &'a Level, dir: Dir, stepped: &'a BTreeSet<ObjectId>) -> Planner<'a> {
Planner {
level,
dir,
stepped,
plan: Plan::default(),
}
}
@@ -100,11 +104,16 @@ impl<'a> Planner<'a> {
/// 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.
/// destroyed, is out of the way: the answer is yes, and nothing is added. An
/// object that has already stepped in this movement phase can't step again: the
/// answer is no.
pub fn try_move(&mut self, id: ObjectId) -> bool {
if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) {
return true;
}
if self.stepped.contains(&id) {
return false;
}
let level = self.level;
let o = level.board().get(id);
let (dx, dy) = self.dir.delta();
@@ -8,6 +8,7 @@
{ "name": "DOOR", "role": "noun" },
{ "name": "SKULL", "role": "noun" },
{ "name": "WATER", "role": "noun" },
{ "name": "ALL", "role": "noun", "selects": "things" },
{ "name": "KEG", "role": "noun" },
{ "name": "BRAMBLE", "role": "noun" },
{ "name": "HEDGE", "role": "noun" },
@@ -156,6 +156,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, destroy: Destroy): void {
const [dx, dy] = delta(dir);
@@ -165,10 +169,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, destroy);
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, destroy);
for (const moved of plan.moving) if (!plan.destroyed.has(moved)) stepped.add(moved);
}
}
@@ -187,6 +196,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.
*/
@@ -200,6 +213,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);
@@ -63,13 +63,18 @@ export interface MoveContext {
responses(): readonly Response[];
}
/** Plans one move on a board it only reads. */
/**
* Plans one move on a board it only reads. `stepped` holds the objects that have
* already stepped in this movement phase, in an earlier committed plan: an object
* steps at most once per phase, so none of them can step again.
*/
export class Planner {
private plan: Plan = { moving: new Set(), destroyed: new Set() };
constructor(
private readonly level: MoveContext,
private readonly dir: Dir,
private readonly stepped: ReadonlySet<ObjectId>,
) {}
/** Does object `id` have `property`, under the rules in force before the move? */
@@ -98,10 +103,13 @@ export class Planner {
* 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.
* destroyed, is out of the way: the answer is yes, and nothing is added. An
* object that has already stepped in this movement phase can't step again: the
* answer is no.
*/
tryMove(id: ObjectId): boolean {
if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true;
if (this.stepped.has(id)) return false;
const o = this.level.board.get(id);
const [dx, dy] = delta(this.dir);
const to: Pos = [o.x + dx, o.y + dy];