Files
systemic-game-design-course/course/11-conditions/reference/rust/src/engine/puzzle/level.rs
T
Lucas WintherandClaude Opus 5.5 34c655c1a4 feat: Module 11, Conditions (NOUN ON NOUN IS ..., checked per object at use; NEAR as the Expansion Drop)
Adds course/11-conditions: lesson, scenarios, nine test_11 tests in both
tracks, the cumulative reference (Module 10's plus conditions) and the NEAR
drop. The engine learns a condition role, rules carry conditions, and
Level.hasProperty and conversion evaluate content's condition checks against
the board whenever a rule is used. Resolution pairs only rules with the same
conditions. Catalog, harness contract, research notes and the diff-tracks
generator follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:00:26 +01:00

383 lines
14 KiB
Rust

//! A level in play: the board, the words content provides, and the rules compiled
//! from them. Every change to the board recompiles the rules from scratch: parse,
//! then resolve. Every question about what an object is goes through `has_property`.
//!
//! A move (Module 8) is planned for each `YOU` object, then committed; the rules are
//! reparsed once, after all of them.
//!
//! A turn (Module 9) runs the phases of `TURN` in order: movement, parse,
//! conversion, parse, content's hazards, the win check, parse.
//!
//! Undo (Module 9): before each move or turn the level saves the board (every
//! object's id, kind and position, oldest first, and the id counter) and `won`.
//! The rules are not saved: undo restores the board and reparses.
//!
//! Module 10: what happens to the objects a turn destroys is a destroy function the
//! caller may pass in (`step_with`, `move_you_with`). The default, `remove_all`,
//! removes them. SpellGrid passes one that sends minions to Part I's Death Creation
//! Step instead. SpellGrid doesn't offer undo: Part I has state the level's
//! snapshot doesn't cover.
//!
//! Module 11: a rule may carry conditions (`X ON Z IS P`). They are not
//! decided when the rules are compiled. Every question about an object
//! (`has_property`, conversion) asks content's `ConditionCheck`s against the board
//! as it is at that moment, and uses only the rules whose conditions hold for that
//! object.
use std::collections::BTreeSet;
use super::condition::ConditionChecks;
use super::grid::{Board, Kind, LevelDef, ObjectId};
use super::movement::{Dir, Plan, Planner, Response};
use super::parse::{parse, Rule};
use super::resolve::resolve;
use super::turn::{Hazard, Phase, TURN};
use super::word::{Lexicon, Role, WordDef};
#[derive(Debug, Clone)]
pub struct Level {
board: Board,
lexicon: Lexicon,
responses: Vec<Response>,
hazards: Vec<Hazard>,
conditions: ConditionChecks,
rules: Vec<Rule>,
active: Vec<Rule>,
won: bool,
/// What `undo` puts back: one entry per move or turn, the latest last (Module 9).
history: Vec<Snapshot>,
}
/// The level before a move or a turn: the whole board (its objects, oldest first,
/// and its id counter) and `won`.
#[derive(Debug, Clone)]
struct Snapshot {
board: Board,
won: bool,
}
/// The property that makes an object move when the player moves.
pub const YOU: &str = "YOU";
/// The property that wins the level when a `YOU` object is on it, or is it.
pub const WIN: &str = "WIN";
/// The property that puts an object on the other float level: only objects on
/// the same level share a tile for hazards and for winning.
pub const FLOAT: &str = "FLOAT";
/// What to do with objects a move or a turn destroys (Module 10). It may change the
/// board, and reparse, before it returns. An id may appear more than once.
pub type Destroy<'a> = dyn FnMut(&mut Level, &[ObjectId]) + 'a;
/// The default destroy function: take every object off the board.
pub fn remove_all(level: &mut Level, ids: &[ObjectId]) {
for &id in ids {
level.board.remove(id);
}
}
impl Level {
/// A level from its definition, the words content provides, content's movement
/// responses and hazards, each in the order they are to run, and content's
/// condition checks, by word. Panics if a word with role `Condition` has no check.
pub fn new(
def: &LevelDef,
words: &[WordDef],
responses: Vec<Response>,
hazards: Vec<Hazard>,
conditions: ConditionChecks,
) -> Level {
for w in words {
if w.role == Role::Condition && !conditions.contains_key(&w.name.to_uppercase()) {
panic!("the condition word {} has no condition check", w.name);
}
}
let mut level = Level {
board: Board::new(def.width, def.height),
lexicon: Lexicon::new(words),
responses,
hazards,
conditions,
rules: vec![],
active: vec![],
won: false,
history: vec![],
};
for o in &def.objects {
level.board.place(Kind::parse(&o.kind), o.x, o.y);
}
level.reparse();
level
}
pub fn place(&mut self, kind: &str, x: i32, y: i32) -> ObjectId {
let id = self.board.place(Kind::parse(kind), x, y);
self.history.clear();
self.reparse();
id
}
pub fn board(&self) -> &Board {
&self.board
}
/// The board, to change directly (Module 10: SpellGrid writes text and removes
/// minions). Nothing is reparsed until `reparse` is called.
pub fn board_mut(&mut self) -> &mut Board {
&mut self.board
}
/// The movement responses, in the order they run.
pub fn responses(&self) -> &[Response] {
&self.responses
}
/// Every rule the text spells, as parsed: negated rules and duplicates included.
pub fn rules(&self) -> &[Rule] {
&self.rules
}
/// The rules in force after the resolution pass.
pub fn active_rules(&self) -> &[Rule] {
&self.active
}
/// Does object `id` have `property`? Yes if an active rule that applies to it
/// (`applies_to`) gives it the property and no active negated rule that applies
/// to it takes it away. A destroyed object has no properties.
pub fn has_property(&self, id: ObjectId, property: &str) -> bool {
if self.board.find(id).is_none() {
return false;
}
let about_it: Vec<&Rule> = self
.active
.iter()
.filter(|r| r.complement == property && self.applies_to(r, id))
.collect();
about_it.iter().any(|r| !r.negated) && !about_it.iter().any(|r| r.negated)
}
/// Does the noun `word`, as the subject of a rule, name object `id`?
pub fn names(&self, word: &str, id: ObjectId) -> bool {
self.lexicon.names(word, &self.board.get(id).kind)
}
/// Does `rule` apply to object `id` now? Its subject names the object, and each
/// of its conditions holds for the object on the board as it is (Module 11).
fn applies_to(&self, rule: &Rule, id: ObjectId) -> bool {
self.names(&rule.subject, id)
&& rule
.conditions
.iter()
.all(|c| (self.conditions[&c.word])(self, id, &c.nouns))
}
/// The other objects on object `id`'s tile that are on its float level (both
/// `FLOAT` or both not), oldest first. Hazards and the win check ask this.
pub fn sharing(&self, id: ObjectId) -> Vec<ObjectId> {
let o = self.board.get(id);
let floats = self.has_property(id, FLOAT);
self.board
.at(o.x, o.y)
.filter(|other| other.id != id && self.has_property(other.id, FLOAT) == floats)
.map(|other| other.id)
.collect()
}
/// Has a turn's win check found a `YOU` object on a `WIN` one? Stays true.
pub fn won(&self) -> bool {
self.won
}
/// Is there no `YOU` object left?
pub fn lost(&self) -> bool {
!self.board.objects().any(|o| self.has_property(o.id, YOU))
}
/// Move every `YOU` object one tile in `dir`, then reparse (Module 8). Movement
/// only: no conversion, hazard or win.
pub fn move_you(&mut self, dir: Dir) {
self.move_you_with(dir, &mut remove_all);
}
/// `move_you`, with destroyed objects going to `destroy` (Module 10).
pub fn move_you_with(&mut self, dir: Dir, destroy: &mut Destroy) {
self.save();
self.movement(dir, destroy);
self.reparse();
}
/// Play one turn: the phases of `TURN`, in order.
pub fn step(&mut self, dir: Dir) {
self.step_with(dir, &mut remove_all);
}
/// `step`, with destroyed objects going to `destroy` (Module 10).
pub fn step_with(&mut self, dir: Dir, destroy: &mut Destroy) {
self.save();
for phase in TURN {
match phase {
Phase::Movement => self.movement(dir, destroy),
Phase::Parse => self.reparse(),
Phase::Conversion => self.convert(destroy),
Phase::Hazards => self.run_hazards(destroy),
Phase::Win => self.check_win(),
}
}
}
/// Put the level back as it was before the last move or turn (Module 9), and
/// reparse. False, and no change, if there is none; `place` forgets them all.
pub fn undo(&mut self) -> bool {
let Some(last) = self.history.pop() else {
return false;
};
self.board = last.board;
self.won = last.won;
self.reparse();
true
}
fn save(&mut self) {
let snapshot = Snapshot {
board: self.board.clone(),
won: self.won,
};
self.history.push(snapshot);
}
/// 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.
fn movement(&mut self, dir: Dir, destroy: &mut Destroy) {
let (dx, dy) = dir.delta();
let mut you: Vec<_> = self
.board
.objects()
.filter(|o| self.has_property(o.id, YOU))
.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
}
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));
}
}
}
/// Destroy, then move what is still on the board (a destroy function may remove
/// more than it was given).
fn commit(&mut self, plan: &Plan, dir: Dir, destroy: &mut Destroy) {
if !plan.destroyed.is_empty() {
let ids: Vec<ObjectId> = plan.destroyed.iter().copied().collect();
destroy(self, &ids);
}
for &id in plan.moving.difference(&plan.destroyed) {
if self.board.find(id).is_some() {
self.board.shift(id, dir.delta());
}
}
}
/// Every object named by a conversion `X IS Y` (`Y` a noun) becomes a `Y`
/// where it stands, keeping its id; with several `Y`s it becomes the first,
/// and each other `Y` is a new object on its tile. An `X IS X` naming the
/// 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. Only the rules that
/// apply to the object are used: their conditions are checked on that board too.
fn convert(&mut self, destroy: &mut Destroy) {
let mut changes = vec![];
for o in self.board.objects() {
let about_it: Vec<&Rule> = self
.active
.iter()
.filter(|r| self.applies_to(r, o.id))
.collect();
let deleted = about_it.iter().any(|r| r.negated && r.is_identity());
let locked = about_it.iter().any(|r| !r.negated && r.is_identity());
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),
_ => {}
}
}
}
if deleted || !into.is_empty() {
changes.push((o.id, (o.x, o.y), deleted, into));
}
}
let mut deleted_ids = vec![];
for (id, (x, y), deleted, into) in changes {
let mut into = into.into_iter();
if deleted {
deleted_ids.push(id);
} else if let Some(first) = into.next() {
self.board.set_kind(id, first);
}
for kind in into {
self.board.place(kind, x, y);
}
}
if !deleted_ids.is_empty() {
destroy(self, &deleted_ids);
}
}
/// Run content's hazards in order. Each one's victims are destroyed together
/// before the next runs; no reparse until the turn's last phase.
fn run_hazards(&mut self, destroy: &mut Destroy) {
for i in 0..self.hazards.len() {
let hazard = self.hazards[i];
let victims = hazard(self);
if !victims.is_empty() {
destroy(self, &victims);
}
}
}
/// The level is won if any `YOU` object is `WIN`, or shares its tile with a
/// `WIN` object on its float level.
fn check_win(&mut self) {
let wins = |id: ObjectId| self.has_property(id, WIN);
let won = self
.board
.objects()
.filter(|o| self.has_property(o.id, YOU))
.any(|o| wins(o.id) || self.sharing(o.id).into_iter().any(wins));
self.won |= won;
}
/// Recompile the rules from the board: parse, then resolve.
pub fn reparse(&mut self) {
self.rules = parse(&self.board, &self.lexicon);
self.active = resolve(&self.rules);
}
}