feat: module 10 spellgrid
SpellGrid capstone: Part I's card engine and Part II's grid engine joined at three seams (rules -> aura data, hazard victims -> one destroy Sequence, Sequence end -> write text, remove the dead, reparse). Three levels with scripted solutions, a naive-timing test via a harness-only hook that reverses the END_OF_PHASE list, CLIs in both tracks, the BATTLECRY IS DOUBLE Expansion Drop, SpellGrid cards and words in course/cards and the catalog, level schema cards/hand, and tools staging/copying of module levels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1f3359cfe2
commit
a0cf9be214
96 files changed
+10454
-8
No files matched your search
@@ -0,0 +1,293 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
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, WordDef};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Level {
|
||||
board: Board,
|
||||
lexicon: Lexicon,
|
||||
responses: Vec<Response>,
|
||||
hazards: Vec<Hazard>,
|
||||
rules: Vec<Rule>,
|
||||
active: Vec<Rule>,
|
||||
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, and content's
|
||||
/// movement responses and hazards, each in the order they are to run.
|
||||
pub fn new(
|
||||
def: &LevelDef,
|
||||
words: &[WordDef],
|
||||
responses: Vec<Response>,
|
||||
hazards: Vec<Hazard>,
|
||||
) -> Level {
|
||||
let mut level = Level {
|
||||
board: Board::new(def.width, def.height),
|
||||
lexicon: Lexicon::new(words),
|
||||
responses,
|
||||
hazards,
|
||||
rules: vec![],
|
||||
active: vec![],
|
||||
won: false,
|
||||
};
|
||||
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.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 gives it to a noun
|
||||
/// that names the object and no active negated rule takes it from one. A
|
||||
/// destroyed object has no properties.
|
||||
pub fn has_property(&self, id: ObjectId, property: &str) -> bool {
|
||||
let Some(object) = self.board.find(id) else {
|
||||
return false;
|
||||
};
|
||||
let kind = &object.kind;
|
||||
let about_it: Vec<&Rule> = self
|
||||
.active
|
||||
.iter()
|
||||
.filter(|r| r.complement == property && self.lexicon.names(&r.subject, kind))
|
||||
.collect();
|
||||
about_it.iter().any(|r| !r.negated) && !about_it.iter().any(|r| r.negated)
|
||||
}
|
||||
|
||||
/// 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.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) {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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();
|
||||
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 planner.try_move(id) {
|
||||
let plan = planner.into_plan();
|
||||
self.commit(&plan, dir, destroy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
///
|
||||
/// 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) {
|
||||
let mut changes = vec![];
|
||||
for o in self.board.objects() {
|
||||
let about_it: Vec<&Rule> = self
|
||||
.active
|
||||
.iter()
|
||||
.filter(|r| self.lexicon.names(&r.subject, &o.kind))
|
||||
.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()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user