From 05c9fdbde95ebdbd63913adf3b08d0abaf99004d Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sat, 6 Jun 2026 01:37:19 -0500 Subject: [PATCH] Solidity --- CLAUDE.md | 23 ++-- kiln-core/src/game.rs | 122 ++++++++++++++--- kiln-core/src/map_file.rs | 272 ++++++++++++++++++++++++++++++++++---- maps/start.toml | 9 +- 4 files changed, 368 insertions(+), 58 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 412e282..a033b4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,12 +46,13 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`. **`kiln-core/src/game.rs`** — all core game types: - `Glyph` (`Copy`, `Eq`, `Hash`) — per-cell visual: `tile: u32` (tilesheet index), `fg/bg: Color32`. `Glyph::player()` is a `const fn`; all other glyphs come from the map file. Derives `Hash` so boards can deduplicate glyphs into a palette. - `FontSpec` — optional per-board bitmap font override: `path: String`, `tile_w: u32`, `tile_h: u32`. When `None`, the app default font is used. -- `Behavior` — plain data struct of runtime behavioral properties: `passable: bool`, `opaque: bool`. Returned by `Archetype::behavior()`; new properties added here require no match arms elsewhere. +- `Behavior` — plain data struct of runtime behavioral properties: `solid: bool` (blocks/participates in movement — inverse of the old `passable`), `opaque: bool`, `pushable: bool` (all archetypes `false` for now; the push mechanic is future work). Returned by `Archetype::behavior()`; new properties added here require no match arms elsewhere. +- `Solid<'a>` — the single solid occupant of a cell, returned by `Board::solid_at`: `Cell(Archetype)` (a solid grid archetype like a wall) or `Object(&ObjectDef)` (a solid object). At most one solid may occupy a cell — enforced at load time. - `Archetype` (`Copy`, `PartialEq`) — enum of named element types: `Empty`, `Wall`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()` (used by the editor when stamping a cell). `ErrorBlock` is a sentinel for unknown archetype names — renders as yellow `?` on red. - `ALL_ARCHETYPES: &[Archetype]` — ordered list of valid editor choices (excludes `ErrorBlock`). -- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `player: Player`, `objects: Vec`, `portals: Vec`, `font: Option`. `cells` is `pub(crate)`. `is_passable(x, y)` checks objects before the grid cell: an impassable object blocks even over a passable floor tile. +- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `player: Player`, `objects: Vec`, `portals: Vec`, `font: Option`. `cells` is `pub(crate)`. `solid_at(x, y) -> Option` returns the cell's single solid occupant (object checked before grid archetype); `is_passable(x, y)` is the convenience inverse (`solid_at(...).is_none()`). - `Player` — `x: i32, y: i32` -- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `passable: bool`, `opaque: bool`, `script_name: Option`. `passable` defaults `false` and `opaque` defaults `true` in map files. Its `init()`/`tick(dt)` hooks are run by the scripting runtime (see `script.rs`). +- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `solid: bool`, `opaque: bool`, `pushable: bool`, `script_name: Option`. `solid` and `opaque` default `true`, `pushable` defaults `false` in map files. Its `init()`/`tick(dt)` hooks are run by the scripting runtime (see `script.rs`). - `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired - `GameState` — holds `board: Rc>`, the message `log: Vec`, and a `ScriptHost`. The board sits behind `Rc>` as a sibling of the `ScriptHost` so script host functions can hold a shared handle to it without aliasing the borrow that's running the engine. Front-ends/logic reach the board through `board() -> Ref` / `board_mut() -> RefMut` (there is no `pub board` field). `try_move` moves the player; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` runs object `tick(dt)` hooks every frame. After each script batch, `apply_commands()` drains the script command queue: `Log`/`Error` → `log`, `SetTile` → the source object's glyph, `Move(dir)` → `move_object` (bounds + `is_passable`). This deferred apply (writes after the batch) is what keeps script execution borrow-safe. @@ -177,17 +178,20 @@ tile_h = 16 [grid] content = """ ############################################################ -# # +# G # ############################################################ """ [[objects]] # optional -x = 10 -y = 5 +# Placement: either `x`/`y`, OR a `palette` char that appears in the grid. +# Convention: object palette chars are uppercase letters (here `G`). The char +# must appear exactly once in the grid, be unique across objects, and not be a +# [palette] key; the cell under it loads as Empty. +palette = "G" tile = "#" fg = "#aa3333" bg = "#000000" -passable = true +solid = false # blocks movement? defaults true. (pushable defaults false) script_name = "greeter" # references a key in [scripts] [[portals]] # optional; parsed but not yet runtime-wired @@ -207,11 +211,12 @@ fn tick(dt) { move(North); } # optional, run every frame; dt = elapsed s """ ``` -Colors are `"#RRGGBB"` hex strings. `player_start` is a header field — the player is not a board cell. The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning. Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`/`tick(dt)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`) and **write** via host functions `move(dir)` (`dir` ∈ `North`/`South`/`East`/`West`), `set_tile(n)`, and `log(s)`. +Colors are `"#RRGGBB"` hex strings. `player_start` is a header field — the player is not a board cell. The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning, as does any grid character that is neither a `[palette]` key nor an object's `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention); placement and one-solid-per-cell violations are logged and the offending object is dropped (the rest of the board still loads). Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`/`tick(dt)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`) and **write** via host functions `move(dir)` (`dir` ∈ `North`/`South`/`East`/`West`), `set_tile(n)`, and `log(s)`. ### Key design decisions -- **`Behavior` and `Archetype` are separate types** — `Archetype` is the named class of a thing (`Wall`, `Empty`, `Object`); `Behavior` is its runtime properties (`passable`, `opaque`). Adding a new property means adding a field to `Behavior`, not a match arm at every call site. +- **`Behavior` and `Archetype` are separate types** — `Archetype` is the named class of a thing (`Wall`, `Empty`, `Object`); `Behavior` is its runtime properties (`solid`, `opaque`, `pushable`). Adding a new property means adding a field to `Behavior`, not a match arm at every call site. +- **One solid per cell** — at most one solid entity (a solid grid archetype *or* a solid object) may occupy a cell. The map loader enforces this: a solid object landing on an already-solid cell is logged and dropped. `Board::solid_at` relies on this invariant. - **`cells: Vec<(Glyph, Archetype)>`** — each cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection. `Archetype` is `Copy` so this is efficient. - **Archetypes are referenced by name in map files** — so `ALL_ARCHETYPES` can be reordered or extended without breaking saved games. - **`Board` is the complete unit** — grid, player, objects, and portals all live on `Board`, matching how ZZT treats a "board". No separate wrapper struct. diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index ab1f94a..4ae393f 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -73,17 +73,22 @@ pub struct FontSpec { /// /// `Behavior` is a plain data struct returned by [`Archetype::behavior`]. It /// contains the properties the engine needs to simulate a cell — currently -/// passability and opacity. Future properties (pushable, shootable, etc.) can +/// solidity, opacity, and pushability. Future properties (shootable, etc.) can /// be added here without changing call sites. /// -/// For scripted objects, passability and opacity are stored directly on +/// For scripted objects, solidity and opacity are stored directly on /// [`ObjectDef`] and will eventually be overridable by Rhai scripts at runtime. #[derive(Copy, Clone, Debug)] pub struct Behavior { - /// Whether an entity can walk through this cell. - pub passable: bool, + /// Whether this cell blocks / participates in movement. A solid cell stops a + /// mover (and is the only kind of cell that can later be pushed or receive a + /// collision event). This is the inverse of the old `passable` flag. + pub solid: bool, /// Whether this cell blocks line of sight (reserved for future rendering). pub opaque: bool, + /// Whether a mover can shove this cell. Unused for now — every archetype is + /// `false`; the push mechanic is future work. + pub pushable: bool, } /// A class of board cell, encoding its default behavior and appearance. @@ -121,16 +126,19 @@ impl Archetype { pub fn behavior(&self) -> Behavior { match self { Archetype::Empty => Behavior { - passable: true, + solid: false, opaque: false, + pushable: false, }, Archetype::Wall => Behavior { - passable: false, + solid: true, opaque: true, + pushable: false, }, Archetype::ErrorBlock => Behavior { - passable: false, + solid: true, opaque: true, + pushable: false, }, } } @@ -222,10 +230,15 @@ pub struct ObjectDef { /// Visual representation of this object. Owned by the object (not derived /// from the grid cell), so scripts can change tile, fg, and bg at runtime. pub glyph: Glyph, - /// Whether the object blocks movement, of players or other objects - pub passable: bool, + /// Whether the object blocks / participates in movement. A solid object stops + /// a mover walking into it; at most one solid (object or grid archetype) may + /// occupy a cell. Inverse of the old `passable` flag. + pub solid: bool, /// Whether the object blocks line of sight / FOV for the player pub opaque: bool, + /// Whether the object can be shoved by a mover. Unused for now (no push + /// mechanic yet); defaults to `false`. + pub pushable: bool, /// Name of the Rhai script in [`Board::scripts`] that drives this object. /// `None` means this object has no script yet. pub script_name: Option, @@ -244,20 +257,34 @@ impl ObjectDef { /// Creates a new object at `(x, y)` with default glyph and blocking behavior. /// - /// Defaults: `passable = false`, `opaque = true`, no script. These match - /// the serde defaults in the map file format so new objects round-trip correctly. + /// Defaults: `solid = true`, `opaque = true`, `pushable = false`, no script. + /// These match the serde defaults in the map file format so new objects + /// round-trip correctly. pub fn new(x: usize, y: usize) -> Self { Self { x, y, glyph: Self::default_glyph(), - passable: false, + solid: true, opaque: true, + pushable: false, script_name: None, } } } +/// The single solid occupant of a board cell, returned by [`Board::solid_at`]. +/// +/// At most one solid — a grid [`Archetype`] *or* an [`ObjectDef`] — may occupy a +/// cell (the invariant enforced at load time), so this represents the one thing a +/// mover would collide with there. +pub enum Solid<'a> { + /// The cell's grid archetype is itself solid (e.g. [`Archetype::Wall`]). + Cell(Archetype), + /// A solid [`ObjectDef`] occupies the cell. + Object(&'a ObjectDef), +} + /// A portal that teleports the player to a named entry point on another board. /// /// Portals are loaded from `[[portals]]` entries in `.toml` map files and @@ -367,19 +394,33 @@ impl Board { &self.cells } - /// Returns `true` if the cell at `(x, y)` allows entities to pass through. + /// Returns the single solid entity occupying `(x, y)`, if any. /// - /// Objects block movement before the grid cell archetype is consulted. + /// Checks objects first, then the grid archetype. Because at most one solid + /// may occupy a cell (an invariant enforced when the board is loaded — see + /// [`crate::map_file`]), this returns that one occupant or `None`. + /// Panics if `x` or `y` are out of bounds. + pub fn solid_at(&self, x: usize, y: usize) -> Option> { + // A solid object shadows the grid cell it sits on. + if let Some(obj) = self.object_at(x, y) + && obj.solid + { + return Some(Solid::Object(obj)); + } + // Otherwise the grid archetype itself may be solid (e.g. a wall). + let arch = self.get(x, y).1; + if arch.behavior().solid { + return Some(Solid::Cell(arch)); + } + None + } + + /// Returns `true` if a mover can enter `(x, y)` — i.e. no solid occupies it. + /// + /// Convenience inverse of [`solid_at`](Board::solid_at). /// Panics if `x` or `y` are out of bounds. pub fn is_passable(&self, x: usize, y: usize) -> bool { - // An object on this cell blocks if it's unpassable - if let Some(obj) = self.object_at(x, y) - && !obj.passable - { - false - } else { - self.get(x, y).1.behavior().passable - } + self.solid_at(x, y).is_none() } /// Returns the index into [`Board::objects`] of the object at `(x, y)`, if any. @@ -595,6 +636,43 @@ mod tests { .collect() } + #[test] + fn solid_at_reports_wall_object_and_empty() { + // A 3×1 board: empty floor, a wall, and an empty cell holding one object. + let mut board = open_board(3, 1, (0, 0), vec![], &[]); + board.cells[1] = (Archetype::Wall.default_glyph(), Archetype::Wall); + // A solid object on the otherwise-empty cell (2, 0). + board.objects.push(ObjectDef::new(2, 0)); // solid by default + + // Empty floor: nothing solid. + assert!(board.solid_at(0, 0).is_none()); + assert!(board.is_passable(0, 0)); + + // Wall cell: the grid archetype is the solid. + match board.solid_at(1, 0) { + Some(Solid::Cell(Archetype::Wall)) => {} + other => panic!("expected Solid::Cell(Wall), got {:?}", other.is_some()), + } + assert!(!board.is_passable(1, 0)); + + // Object cell: the solid object shadows the empty floor under it. + match board.solid_at(2, 0) { + Some(Solid::Object(o)) => assert_eq!((o.x, o.y), (2, 0)), + _ => panic!("expected Solid::Object"), + } + assert!(!board.is_passable(2, 0)); + } + + #[test] + fn non_solid_object_does_not_block() { + // A non-solid object sits on empty floor: the cell stays passable. + let mut obj = ObjectDef::new(1, 0); + obj.solid = false; + let board = open_board(3, 1, (0, 0), vec![obj], &[]); + assert!(board.solid_at(1, 0).is_none()); + assert!(board.is_passable(1, 0)); + } + #[test] fn init_runs_only_on_run_init_not_at_construction() { let board = board_with_object( diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index dd102a9..6711619 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -131,22 +131,35 @@ pub struct GridData { /// [`From`] impls below. #[derive(Deserialize, Serialize)] pub(crate) struct MapFileObjectEntry { - /// Column of this object on the board (0-indexed). - x: usize, - /// Row of this object on the board (0-indexed). - y: usize, + /// Column of this object on the board (0-indexed). Mutually exclusive with + /// `palette`: provide either `x`/`y` or `palette`, not both. + #[serde(default, skip_serializing_if = "Option::is_none")] + x: Option, + /// Row of this object on the board (0-indexed). See `x`. + #[serde(default, skip_serializing_if = "Option::is_none")] + y: Option, + /// Single grid character marking where this object sits, as an alternative to + /// `x`/`y`. By convention an uppercase letter. The char must appear exactly + /// once in the grid, be unique across objects, and not collide with a + /// `[palette]` key; the cell under it is loaded as `Empty`. + #[serde(default, skip_serializing_if = "Option::is_none")] + palette: Option, /// Tile index into the board's bitmap font. Accepts integer or single-char string. tile: TileIndex, /// Foreground color as an `"#RRGGBB"` hex string. fg: String, /// Background color as an `"#RRGGBB"` hex string. bg: String, - /// Whether the object blocks player / object movement - #[serde(default)] - passable: bool, + /// Whether the object blocks / participates in movement. Defaults to `true` + /// (blocking). Inverse of the old `passable` field. + #[serde(default = "default_true")] + solid: bool, /// Whether the object blocks player line of sight / FOV #[serde(default = "default_true")] opaque: bool, + /// Whether the object can be shoved by a mover. Unused for now; defaults `false`. + #[serde(default)] + pushable: bool, /// Name of the Rhai script in the `[scripts]` table, if any. #[serde(default, skip_serializing_if = "Option::is_none")] script_name: Option, @@ -252,39 +265,152 @@ impl TryFrom for Board { } } - // Pass 2: walk the validated grid and build cells. + // Validate object `palette` references before the grid pass, since the + // grid pass needs to know which chars are object placeholders. + // `skip[i]` drops object `i`; `palette_char_owner` maps a valid object + // palette char to the single object that owns it. + let mut skip = vec![false; mf.objects.len()]; + let mut palette_char_owner: HashMap = HashMap::new(); + for (i, e) in mf.objects.iter().enumerate() { + let Some(p) = e.palette.as_deref() else { + continue; + }; + // `palette` is mutually exclusive with explicit `x`/`y`. + if e.x.is_some() || e.y.is_some() { + eprintln!("object {i}: specify either x/y or palette, not both; skipping object"); + skip[i] = true; + continue; + } + // It must be exactly one character. + let mut chs = p.chars(); + let (Some(ch), None) = (chs.next(), chs.next()) else { + eprintln!( + "object {i}: palette must be a single character (got {p:?}); skipping object" + ); + skip[i] = true; + continue; + }; + // It can't reuse a terrain palette key. + if palette.contains_key(&ch) { + eprintln!( + "object {i}: palette char '{ch}' is already a [palette] key; skipping object" + ); + skip[i] = true; + continue; + } + // It must be unique across objects — a clash drops both owners. + use std::collections::hash_map::Entry; + match palette_char_owner.entry(ch) { + Entry::Occupied(o) => { + eprintln!( + "object palette char '{ch}' is used by more than one object; skipping both" + ); + skip[i] = true; + skip[*o.get()] = true; + } + Entry::Vacant(v) => { + v.insert(i); + } + } + } + + // Pass 2: walk the validated grid and build cells. Object palette chars + // become `Empty` floor (with their position remembered); truly unknown + // chars become a visible `ErrorBlock` so the cell count stays correct. + let canonical_empty = (Archetype::Empty.default_glyph(), Archetype::Empty); let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(w * h); - for line in &rows { - for ch in line.chars() { + let mut obj_palette_pos: HashMap = HashMap::new(); + let mut obj_palette_count: HashMap = HashMap::new(); + for (y, line) in rows.iter().enumerate() { + for (x, ch) in line.chars().enumerate() { if let Some(&cell) = palette.get(&ch) { cells.push(cell); + } else if palette_char_owner.contains_key(&ch) { + cells.push(canonical_empty); + obj_palette_pos.insert(ch, (x, y)); + *obj_palette_count.entry(ch).or_insert(0) += 1; + } else { + eprintln!("unknown grid character '{ch}' at ({x}, {y}); using error block"); + cells.push((Archetype::ErrorBlock.default_glyph(), Archetype::ErrorBlock)); } } } + // Each surviving object palette char must appear in the grid exactly once. + for (&ch, &owner) in &palette_char_owner { + if skip[owner] { + continue; + } + let n = obj_palette_count.get(&ch).copied().unwrap_or(0); + if n != 1 { + eprintln!( + "object palette char '{ch}' appears {n} times in the grid \ + (must be exactly once); skipping object" + ); + skip[owner] = true; + } + } + let font = mf.font.map(|f| FontSpec { path: f.path, tile_w: f.tile_w, tile_h: f.tile_h, }); - // Convert MapFileObjectEntry → ObjectDef, resolving tile index and color strings. - let objects = mf - .objects - .into_iter() - .map(|e| ObjectDef { - x: e.x, - y: e.y, + // Convert MapFileObjectEntry → ObjectDef, enforcing one solid per cell. + // `solid_occupied` is seeded from the grid (solid archetypes); each solid + // object then claims its cell, and a second solid on a cell is dropped. + let mut solid_occupied: Vec = cells.iter().map(|(_, a)| a.behavior().solid).collect(); + let mut objects: Vec = Vec::new(); + for (i, e) in mf.objects.into_iter().enumerate() { + if skip[i] { + continue; + } + // Resolve the object's cell from either its palette char or x/y. + let (x, y) = if let Some(p) = e.palette.as_deref() { + // Single-char and uniqueness already validated; position recorded. + obj_palette_pos[&p.chars().next().unwrap()] + } else { + match (e.x, e.y) { + (Some(x), Some(y)) if x < w && y < h => (x, y), + (Some(x), Some(y)) => { + eprintln!("object {i}: x/y ({x}, {y}) out of bounds; skipping object"); + continue; + } + _ => { + eprintln!( + "object {i}: must specify both x and y (or a palette char); skipping object" + ); + continue; + } + } + }; + let obj = ObjectDef { + x, + y, glyph: Glyph { tile: e.tile.into_u32(), fg: parse_color(&e.fg), bg: parse_color(&e.bg), }, - passable: e.passable, + solid: e.solid, opaque: e.opaque, + pushable: e.pushable, script_name: e.script_name, - }) - .collect(); + }; + // A solid object may not share a cell with another solid. + if obj.solid { + let idx = y * w + x; + if solid_occupied[idx] { + eprintln!( + "object {i}: solid object at ({x}, {y}) conflicts with an existing solid; skipping object" + ); + continue; + } + solid_occupied[idx] = true; + } + objects.push(obj); + } Ok(Board { name: mf.map.name, @@ -387,13 +513,17 @@ impl From<&Board> for MapFile { .objects .iter() .map(|o| MapFileObjectEntry { - x: o.x, - y: o.y, + // Saving always emits concrete coordinates; the palette form is a + // load-time convenience only. + x: Some(o.x), + y: Some(o.y), + palette: None, tile: TileIndex::Num(o.glyph.tile), fg: color_to_hex(o.glyph.fg), bg: color_to_hex(o.glyph.bg), - passable: o.passable, + solid: o.solid, opaque: o.opaque, + pushable: o.pushable, script_name: o.script_name.clone(), }) .collect(); @@ -453,6 +583,102 @@ pub fn save(board: &Board, path: &Path) -> Result<(), Box #[cfg(test)] mod tests { use super::*; + use crate::game::Archetype; + + /// Parses `toml` and converts it to a [`Board`], panicking on any hard error. + /// (Object/placement validation failures are non-fatal `eprintln`s, so the + /// board still loads — these tests assert on the resulting objects/cells.) + fn load_board(toml: &str) -> Board { + let mf: MapFile = toml::from_str(toml).expect("parse toml"); + Board::try_from(mf).expect("convert to board") + } + + /// A 3×1 map (`empty` palette `.`) whose grid is `grid`, plus the given + /// `[[objects]]` TOML appended. Keeps placement tests compact. + fn map_3x1(grid: &str, objects: &str) -> String { + format!( + r##" +[map] +name = "Test" +width = 3 +height = 1 +player_start = [0, 0] + +[palette] +"." = {{ archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }} +"#" = {{ archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }} + +[grid] +content = """ +{grid} +""" +{objects} +"## + ) + } + + /// A standard `[[objects]]` block placed by palette char `ch`. + fn obj_palette(ch: &str, extra: &str) -> String { + format!( + "[[objects]]\npalette = \"{ch}\"\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n{extra}" + ) + } + + #[test] + fn palette_placement_puts_object_on_empty_floor() { + // `G` appears once, isn't a palette key → object lands there, cell is Empty. + let board = load_board(&map_3x1("G..", &obj_palette("G", ""))); + assert_eq!(board.objects.len(), 1); + assert_eq!((board.objects[0].x, board.objects[0].y), (0, 0)); + // The cell under the object is canonical Empty floor. + assert_eq!(board.get(0, 0).1, Archetype::Empty); + } + + #[test] + fn palette_char_absent_from_grid_drops_object() { + let board = load_board(&map_3x1("...", &obj_palette("G", ""))); + assert!(board.objects.is_empty()); + } + + #[test] + fn palette_char_appearing_twice_drops_object() { + let board = load_board(&map_3x1("G.G", &obj_palette("G", ""))); + assert!(board.objects.is_empty()); + } + + #[test] + fn palette_char_reused_by_two_objects_drops_both() { + let two = format!("{}\n{}", obj_palette("G", ""), obj_palette("G", "")); + let board = load_board(&map_3x1("G..", &two)); + assert!(board.objects.is_empty()); + } + + #[test] + fn palette_char_colliding_with_palette_key_drops_object() { + // `#` is a terrain palette key, so it can't be an object placeholder. + let board = load_board(&map_3x1("#..", &obj_palette("#", ""))); + assert!(board.objects.is_empty()); + } + + #[test] + fn solid_object_on_wall_is_dropped_but_non_solid_is_kept() { + // Solid object at the wall cell (0,0): dropped for conflicting with the wall. + let solid = "[[objects]]\nx = 0\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; + let board = load_board(&map_3x1("#..", solid)); + assert!(board.objects.is_empty()); + + // Same placement but non-solid: kept (it doesn't claim the cell's solidity). + let nonsolid = format!("{solid}solid = false\n"); + let board = load_board(&map_3x1("#..", &nonsolid)); + assert_eq!(board.objects.len(), 1); + } + + #[test] + fn second_solid_object_on_a_cell_is_dropped() { + let obj = "[[objects]]\nx = 1\ny = 0\ntile = 64\nfg = \"#00FFFF\"\nbg = \"#000000\"\n"; + let board = load_board(&map_3x1("...", &format!("{obj}{obj}"))); + assert_eq!(board.objects.len(), 1); + } /// Minimal valid TOML with a configurable grid, used as a base for dimension tests. fn minimal_toml(width: usize, height: usize, grid_rows: &[&str]) -> String { diff --git a/maps/start.toml b/maps/start.toml index b9f78dc..ef68b6f 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -23,7 +23,7 @@ content = """ # # # # # # # -# # +# G # # # # # # # @@ -38,13 +38,14 @@ content = """ ############################################################ """ +# Placed by its grid character `G` (uppercase-letter convention for objects) +# rather than x/y; the cell under it loads as Empty. [[objects]] fg = "#aa3333" bg = "#000000" -x = 10 -y = 10 +palette = "G" tile = "#" -passable = true +solid = false script_name = "greeter" [scripts]