Skip to main content

dreamwell_engine/
tile.rs

1// Tile system — u16 tile IDs with separate display mapping.
2//
3// Tile IDs are numeric identifiers (u16), NOT characters. IDs 0-127 map to
4// ASCII characters for backward compatibility. IDs 128-255 are reserved.
5// IDs 256+ are user-defined via Waymark glyph.json packs.
6//
7// The display layer (GlyphRegistry) maps tile IDs to Unicode characters,
8// names, and metadata. This separation keeps the spatial engine, hash chains,
9// and storage format independent of visual representation.
10
11use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14
15/// Tiles per chunk edge.
16pub const CHUNK_SIZE: u32 = 32;
17
18// =============================================================================
19// TILE ID CONSTANTS (u16)
20// =============================================================================
21// IDs 0-127: ASCII-compatible. The numeric value equals the ASCII byte.
22
23// -- Ground layer --
24pub const GROUND: u16 = b'.' as u16; // 0x2E
25pub const DIRT: u16 = b',' as u16; // 0x2C
26pub const SAND: u16 = b':' as u16; // 0x3A
27pub const MUD: u16 = b'`' as u16; // 0x60
28pub const LIQUID: u16 = b'~' as u16; // 0x7E
29pub const DEEP_WATER: u16 = b'=' as u16; // 0x3D
30pub const ROCKY_GROUND: u16 = b'b' as u16; // 0x62
31pub const GRASS: u16 = b';' as u16; // 0x3B
32pub const DEBRIS: u16 = b'\'' as u16; // 0x27
33
34// -- Structure layer --
35pub const WALL: u16 = b'#' as u16; // 0x23
36pub const WALL_VERT: u16 = b'|' as u16; // 0x7C
37pub const WALL_HORIZ: u16 = b'_' as u16; // 0x5F
38pub const PILLAR: u16 = b'i' as u16; // 0x69
39pub const DOOR_CLOSED: u16 = b'I' as u16; // 0x49
40pub const DOOR_OPEN: u16 = b'*' as u16; // 0x2A
41pub const STAIRS_UP: u16 = b'^' as u16; // 0x5E
42pub const STAIRS_DOWN: u16 = b'v' as u16; // 0x76
43pub const BRIDGE: u16 = b'{' as u16; // 0x7B
44
45// -- Object layer --
46pub const BOULDER: u16 = b'o' as u16; // 0x6F
47pub const CHEST: u16 = b'C' as u16; // 0x43
48pub const CRATE: u16 = b'c' as u16; // 0x63
49pub const SHRINE: u16 = b'S' as u16; // 0x53
50pub const BED: u16 = b'B' as u16; // 0x42
51pub const CONSUMABLE: u16 = b'!' as u16; // 0x21
52pub const CURRENCY: u16 = b'$' as u16; // 0x24
53pub const KEY_ITEM: u16 = b'K' as u16; // 0x4B
54pub const UNKNOWN_OBJ: u16 = b'?' as u16; // 0x3F
55
56// -- Effect layer --
57pub const HAZARD: u16 = b'x' as u16; // 0x78
58pub const SMOKE: u16 = b'%' as u16; // 0x25
59pub const MAGIC_EFFECT: u16 = b'}' as u16; // 0x7D
60
61// -- Cosmic layer (Universe/Galaxy/Sector view) --
62pub const STAR: u16 = b'@' as u16; // 0x40
63pub const NEBULA: u16 = b'&' as u16; // 0x26
64pub const VOID: u16 = b' ' as u16; // 0x20
65pub const WARP_GATE: u16 = b'W' as u16; // 0x57
66pub const STATION_GLYPH: u16 = b'H' as u16; // 0x48
67pub const ASTEROID: u16 = b'a' as u16; // 0x61
68pub const PLANET: u16 = b'O' as u16; // 0x4F
69pub const MOON_GLYPH: u16 = b'o' as u16; // 0x6F (shared with BOULDER)
70pub const TRADE_ROUTE: u16 = b'-' as u16; // 0x2D
71pub const ANOMALY: u16 = b'@' as u16; // 0x40 (shared with STAR)
72
73// -- World layer (overworld/planet surface view) --
74pub const MOUNTAIN: u16 = b'A' as u16; // 0x41
75pub const FOREST: u16 = b'T' as u16; // 0x54
76pub const RIVER: u16 = b'~' as u16; // 0x7E (shared with LIQUID)
77pub const ROAD: u16 = b'-' as u16; // 0x2D (shared with TRADE_ROUTE)
78pub const CITY: u16 = b'@' as u16; // 0x40 (shared with STAR)
79pub const VILLAGE: u16 = b'h' as u16; // 0x68
80pub const RUIN: u16 = b'R' as u16; // 0x52
81pub const MINE: u16 = b'M' as u16; // 0x4D
82pub const FARMLAND: u16 = b'f' as u16; // 0x66
83pub const HARBOR: u16 = b'H' as u16; // 0x48 (shared with STATION_GLYPH)
84
85// -- Prefab interaction glyphs (POI/AOI primitives) --
86pub const WORKBENCH: u16 = b'W' as u16; // 0x57 (shared with WARP_GATE)
87pub const ANVIL: u16 = b'A' as u16; // 0x41 (shared with MOUNTAIN)
88pub const FURNACE: u16 = b'F' as u16; // 0x46
89pub const LOOM: u16 = b'L' as u16; // 0x4C
90pub const ALTAR: u16 = b'a' as u16; // 0x61 (shared with ASTEROID)
91pub const WELL: u16 = b'w' as u16; // 0x77
92pub const SIGN: u16 = b's' as u16; // 0x73
93pub const LEVER: u16 = b'l' as u16; // 0x6C
94pub const TRAP: u16 = b't' as u16; // 0x74
95pub const PORTAL_GLYPH: u16 = b'P' as u16; // 0x50
96pub const NPC_GLYPH: u16 = b'N' as u16; // 0x4E
97pub const MERCHANT_GLYPH: u16 = b'V' as u16; // 0x56
98pub const QUEST_GLYPH: u16 = b'Q' as u16; // 0x51
99pub const CAMPFIRE: u16 = b'+' as u16; // 0x2B
100pub const BARREL: u16 = b'U' as u16; // 0x55
101
102// -- Entity glyphs (player/creature representation) --
103pub const PLAYER_GLYPH: u16 = b'@' as u16; // 0x40 (shared with STAR)
104pub const ENEMY_GLYPH: u16 = b'E' as u16; // 0x45
105pub const BOSS_GLYPH: u16 = b'D' as u16; // 0x44
106pub const COMPANION_GLYPH: u16 = b'G' as u16; // 0x47
107pub const MOUNT_GLYPH: u16 = b'm' as u16; // 0x6D
108
109// -- Semantic entity IDs (128+ range, no ASCII collision) --
110pub const ADMIN_GLYPH: u16 = 128; // Display: '@', admin entity
111pub const AGENT_GLYPH: u16 = 129; // Display: 'a', autonomous agent
112
113// =============================================================================
114// COLLISION & LAYER FUNCTIONS
115// =============================================================================
116
117/// Whether the glyph blocks entity movement.
118pub fn blocks_move(glyph: u16) -> bool {
119    matches!(
120        glyph,
121        WALL | WALL_VERT | WALL_HORIZ | PILLAR | DOOR_CLOSED | DEEP_WATER | BOULDER | CHEST | CRATE | BED | UNKNOWN_OBJ
122    )
123}
124
125/// Whether the glyph blocks line of sight.
126pub fn blocks_sight(glyph: u16) -> bool {
127    matches!(
128        glyph,
129        WALL | WALL_VERT | WALL_HORIZ | PILLAR | DOOR_CLOSED | BOULDER | SMOKE
130    )
131}
132
133/// Movement cost for pathfinding. 999 = impassable.
134pub fn move_cost(glyph: u16) -> u32 {
135    match glyph {
136        GROUND | DIRT | DOOR_OPEN | STAIRS_UP | STAIRS_DOWN | BRIDGE | SHRINE | CONSUMABLE | CURRENCY | KEY_ITEM => 1,
137        SAND | MUD | GRASS | DEBRIS | ROCKY_GROUND | HAZARD | SMOKE | MAGIC_EFFECT => 2,
138        LIQUID => 3,
139        _ => 999,
140    }
141}
142
143/// Topology layer index for a glyph set.
144///
145/// Some IDs are reused across layers (e.g., `STAR`/`CITY`/`PLAYER_GLYPH` = 0x40).
146/// Ambiguity is resolved by returning the broadest-scope (lowest index) layer.
147pub fn glyph_layer(glyph: u16) -> u8 {
148    match glyph {
149        STAR | NEBULA | VOID | WARP_GATE => 0,
150        PLANET | MOON_GLYPH | ASTEROID | STATION_GLYPH | TRADE_ROUTE => 2,
151        MOUNTAIN | FOREST | RIVER | VILLAGE | RUIN | MINE | FARMLAND => 3,
152        WALL | WALL_VERT | WALL_HORIZ | DOOR_CLOSED | DOOR_OPEN | STAIRS_UP | STAIRS_DOWN | BRIDGE => 6,
153        FURNACE | LOOM | WELL | LEVER | TRAP | PORTAL_GLYPH => 9,
154        NPC_GLYPH | MERCHANT_GLYPH | QUEST_GLYPH | CAMPFIRE | BARREL | SIGN => 9,
155        ENEMY_GLYPH | BOSS_GLYPH | COMPANION_GLYPH | MOUNT_GLYPH => 9,
156        _ => 7,
157    }
158}
159
160// =============================================================================
161// CHUNK GEOMETRY
162// =============================================================================
163
164/// Compute chunk coordinate from tile coordinate.
165pub fn chunk_coord(tile_pos: u32) -> u32 {
166    tile_pos / CHUNK_SIZE
167}
168
169/// Compute local coordinate within a chunk.
170pub fn local_coord(tile_pos: u32) -> u32 {
171    tile_pos % CHUNK_SIZE
172}
173
174/// Build a chunk ID string from area ID and chunk coordinates.
175pub fn chunk_id(area_id: &str, cx: u32, cy: u32) -> String {
176    format!("{}:{},{}", area_id, cx, cy)
177}
178
179/// Read one tile ID from a flat chunk tile array at local (lx, ly).
180/// Returns 0 if the coordinates fall outside the slice.
181pub fn read_glyph(tiles: &[u16], lx: u32, ly: u32) -> u16 {
182    if lx >= CHUNK_SIZE || ly >= CHUNK_SIZE {
183        return 0;
184    }
185    let idx = (ly as usize) * (CHUNK_SIZE as usize) + (lx as usize);
186    if idx >= tiles.len() {
187        return 0;
188    }
189    tiles[idx]
190}
191
192// =============================================================================
193// DISPLAY MAPPING — Presentation Layer
194// =============================================================================
195
196/// Display information for a single tile glyph.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct GlyphDisplay {
199    /// Tile ID (matches the u16 constants above for built-in glyphs).
200    pub id: u16,
201    /// Unicode character used for rendering.
202    pub display_char: char,
203    /// Human-readable name.
204    pub name: String,
205    /// Category for UI grouping (ground, structure, object, effect, cosmic, world, prefab, entity).
206    pub category: String,
207    /// Topology layer (0=Universe, 9=Point).
208    pub layer: u8,
209    /// Whether this tile blocks movement (display-side mirror of engine rules).
210    pub blocks_move: bool,
211    /// Whether this tile blocks line of sight.
212    pub blocks_sight: bool,
213    /// Movement cost for pathfinding (999 = impassable).
214    pub move_cost: u32,
215}
216
217/// Maximum valid topology layer index (matches the 9-layer topology: 0=Universe through 9=Point).
218pub const MAX_TOPOLOGY_LAYER: u8 = 9;
219
220impl GlyphDisplay {
221    /// Validate that this glyph display entry has a valid layer index.
222    ///
223    /// Layer must be 0-9, matching the 9-layer topology
224    /// (Universe, Galaxy, Sector, World, Realm, Region, Area, Location, Room, Point).
225    pub fn validate(&self) -> Result<(), String> {
226        if self.layer > MAX_TOPOLOGY_LAYER {
227            return Err(format!(
228                "glyph_invalid_layer: id={}, layer={} exceeds max {}",
229                self.id, self.layer, MAX_TOPOLOGY_LAYER
230            ));
231        }
232        Ok(())
233    }
234}
235
236/// Registry mapping tile IDs (u16) to display information.
237///
238/// Provides the presentation layer for tile rendering. The engine operates
239/// on tile IDs only; the registry translates IDs to visual characters.
240///
241/// Built-in glyphs (IDs 0-127) use ASCII characters. Custom glyphs (256+)
242/// can use any Unicode character and are loaded from Waymark glyph.json packs.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct GlyphRegistry {
245    entries: HashMap<u16, GlyphDisplay>,
246}
247
248impl Default for GlyphRegistry {
249    fn default() -> Self {
250        Self::with_defaults()
251    }
252}
253
254impl GlyphRegistry {
255    /// Create an empty registry.
256    pub fn new() -> Self {
257        Self {
258            entries: HashMap::new(),
259        }
260    }
261
262    /// Create a registry pre-populated with all built-in ASCII glyphs.
263    pub fn with_defaults() -> Self {
264        let mut reg = Self::new();
265
266        // Ground layer
267        reg.add(GROUND, '.', "Ground", "ground", 7, false, false, 1);
268        reg.add(DIRT, ',', "Dirt", "ground", 7, false, false, 1);
269        reg.add(SAND, ':', "Sand", "ground", 7, false, false, 2);
270        reg.add(MUD, '`', "Mud", "ground", 7, false, false, 2);
271        reg.add(LIQUID, '~', "Liquid", "ground", 7, false, false, 3);
272        reg.add(DEEP_WATER, '=', "Deep Water", "ground", 7, true, false, 999);
273        reg.add(ROCKY_GROUND, 'b', "Rocky Ground", "ground", 7, false, false, 2);
274        reg.add(GRASS, ';', "Grass", "ground", 7, false, false, 2);
275        reg.add(DEBRIS, '\'', "Debris", "ground", 7, false, false, 2);
276
277        // Structure layer
278        reg.add(WALL, '#', "Wall", "structure", 6, true, true, 999);
279        reg.add(WALL_VERT, '|', "Wall Vert", "structure", 6, true, true, 999);
280        reg.add(WALL_HORIZ, '_', "Wall Horiz", "structure", 6, true, true, 999);
281        reg.add(PILLAR, 'i', "Pillar", "structure", 6, true, true, 999);
282        reg.add(DOOR_CLOSED, 'I', "Door Closed", "structure", 6, true, true, 999);
283        reg.add(DOOR_OPEN, '*', "Door Open", "structure", 6, false, false, 1);
284        reg.add(STAIRS_UP, '^', "Stairs Up", "structure", 6, false, false, 1);
285        reg.add(STAIRS_DOWN, 'v', "Stairs Down", "structure", 6, false, false, 1);
286        reg.add(BRIDGE, '{', "Bridge", "structure", 6, false, false, 1);
287
288        // Object layer
289        reg.add(BOULDER, 'o', "Boulder", "object", 7, true, true, 999);
290        reg.add(CHEST, 'C', "Chest", "object", 7, true, false, 999);
291        reg.add(CRATE, 'c', "Crate", "object", 7, true, false, 999);
292        reg.add(SHRINE, 'S', "Shrine", "object", 7, false, false, 1);
293        reg.add(BED, 'B', "Bed", "object", 7, true, false, 999);
294        reg.add(CONSUMABLE, '!', "Consumable", "object", 7, false, false, 1);
295        reg.add(CURRENCY, '$', "Currency", "object", 7, false, false, 1);
296        reg.add(KEY_ITEM, 'K', "Key Item", "object", 7, false, false, 1);
297        reg.add(UNKNOWN_OBJ, '?', "Unknown", "object", 7, true, false, 999);
298
299        // Effect layer
300        reg.add(HAZARD, 'x', "Hazard", "effect", 7, false, false, 2);
301        reg.add(SMOKE, '%', "Smoke", "effect", 7, false, true, 2);
302        reg.add(MAGIC_EFFECT, '}', "Magic Effect", "effect", 7, false, false, 2);
303
304        // Cosmic layer
305        reg.add(STAR, '@', "Star", "cosmic", 0, false, false, 999);
306        reg.add(NEBULA, '&', "Nebula", "cosmic", 0, false, false, 999);
307        reg.add(VOID, ' ', "Void", "cosmic", 0, false, false, 999);
308        reg.add(WARP_GATE, 'W', "Warp Gate", "cosmic", 0, false, false, 1);
309        reg.add(STATION_GLYPH, 'H', "Station", "cosmic", 2, false, false, 1);
310        reg.add(ASTEROID, 'a', "Asteroid", "cosmic", 2, false, false, 999);
311        reg.add(PLANET, 'O', "Planet", "cosmic", 2, false, false, 999);
312        reg.add(TRADE_ROUTE, '-', "Trade Route", "cosmic", 2, false, false, 1);
313
314        // World layer
315        reg.add(MOUNTAIN, 'A', "Mountain", "world", 3, true, true, 999);
316        reg.add(FOREST, 'T', "Forest", "world", 3, false, false, 2);
317        reg.add(VILLAGE, 'h', "Village", "world", 3, false, false, 1);
318        reg.add(RUIN, 'R', "Ruin", "world", 3, false, false, 2);
319        reg.add(MINE, 'M', "Mine", "world", 3, false, false, 1);
320        reg.add(FARMLAND, 'f', "Farmland", "world", 3, false, false, 1);
321
322        // Prefab layer
323        reg.add(FURNACE, 'F', "Furnace", "prefab", 9, false, false, 1);
324        reg.add(LOOM, 'L', "Loom", "prefab", 9, false, false, 1);
325        reg.add(WELL, 'w', "Well", "prefab", 9, false, false, 1);
326        reg.add(SIGN, 's', "Sign", "prefab", 9, false, false, 1);
327        reg.add(LEVER, 'l', "Lever", "prefab", 9, false, false, 1);
328        reg.add(TRAP, 't', "Trap", "prefab", 9, false, false, 1);
329        reg.add(PORTAL_GLYPH, 'P', "Portal", "prefab", 9, false, false, 1);
330        reg.add(NPC_GLYPH, 'N', "NPC", "prefab", 9, false, false, 1);
331        reg.add(MERCHANT_GLYPH, 'V', "Merchant", "prefab", 9, false, false, 1);
332        reg.add(QUEST_GLYPH, 'Q', "Quest", "prefab", 9, false, false, 1);
333        reg.add(CAMPFIRE, '+', "Campfire", "prefab", 9, false, false, 1);
334        reg.add(BARREL, 'U', "Barrel", "prefab", 9, false, false, 1);
335
336        // Entity layer
337        reg.add(PLAYER_GLYPH, '@', "Player", "entity", 9, false, false, 1);
338        reg.add(ENEMY_GLYPH, 'E', "Enemy", "entity", 9, false, false, 1);
339        reg.add(BOSS_GLYPH, 'D', "Boss", "entity", 9, false, false, 1);
340        reg.add(COMPANION_GLYPH, 'G', "Companion", "entity", 9, false, false, 1);
341        reg.add(MOUNT_GLYPH, 'm', "Mount", "entity", 9, false, false, 1);
342
343        // Semantic entity IDs (128+ range)
344        reg.add(ADMIN_GLYPH, '@', "Admin", "entity", 9, false, false, 1);
345        reg.add(AGENT_GLYPH, 'a', "Agent", "entity", 9, false, false, 1);
346
347        reg
348    }
349
350    /// Register a glyph entry.
351    pub fn register(&mut self, entry: GlyphDisplay) {
352        self.entries.insert(entry.id, entry);
353    }
354
355    /// Look up display info for a tile ID.
356    pub fn get(&self, id: u16) -> Option<&GlyphDisplay> {
357        self.entries.get(&id)
358    }
359
360    /// Get the display character for a tile ID.
361    /// Falls back to the ASCII character if the ID is in range 0-127,
362    /// or '?' for unknown IDs.
363    pub fn display_char(&self, id: u16) -> char {
364        if let Some(entry) = self.entries.get(&id) {
365            entry.display_char
366        } else if id < 128 {
367            id as u8 as char
368        } else {
369            '?'
370        }
371    }
372
373    /// Get the display name for a tile ID.
374    pub fn display_name(&self, id: u16) -> &str {
375        self.entries.get(&id).map(|e| e.name.as_str()).unwrap_or("Unknown")
376    }
377
378    /// Merge another registry into this one. Existing entries are overwritten.
379    pub fn merge(&mut self, other: &GlyphRegistry) {
380        for (id, entry) in &other.entries {
381            self.entries.insert(*id, entry.clone());
382        }
383    }
384
385    /// Return all entries as a sorted slice (by ID).
386    pub fn entries_sorted(&self) -> Vec<&GlyphDisplay> {
387        let mut entries: Vec<_> = self.entries.values().collect();
388        entries.sort_by_key(|e| e.id);
389        entries
390    }
391
392    /// Number of registered glyphs.
393    pub fn len(&self) -> usize {
394        self.entries.len()
395    }
396
397    /// Whether the registry is empty.
398    pub fn is_empty(&self) -> bool {
399        self.entries.is_empty()
400    }
401
402    /// Load custom glyphs from a glyph.json string and merge into this registry.
403    ///
404    /// Expected JSON format:
405    /// ```json
406    /// {
407    ///   "glyphs": [
408    ///     { "id": 256, "display_char": "🌊", "name": "Ocean", "category": "ground",
409    ///       "layer": 3, "blocks_move": true, "blocks_sight": false, "move_cost": 999 }
410    ///   ]
411    /// }
412    /// ```
413    pub fn load_json(&mut self, json: &str) -> Result<usize, String> {
414        #[derive(Deserialize)]
415        struct GlyphFile {
416            glyphs: Vec<GlyphJsonEntry>,
417        }
418        #[derive(Deserialize)]
419        struct GlyphJsonEntry {
420            id: u16,
421            display_char: String,
422            name: String,
423            #[serde(default = "default_category")]
424            category: String,
425            #[serde(default = "default_layer")]
426            layer: u8,
427            #[serde(default)]
428            blocks_move: bool,
429            #[serde(default)]
430            blocks_sight: bool,
431            #[serde(default = "default_move_cost")]
432            move_cost: u32,
433        }
434        fn default_category() -> String {
435            "custom".to_string()
436        }
437        fn default_layer() -> u8 {
438            7
439        }
440        fn default_move_cost() -> u32 {
441            1
442        }
443
444        let file: GlyphFile = serde_json::from_str(json).map_err(|e| format!("glyph_json_parse:{}", e))?;
445        let count = file.glyphs.len();
446        for g in file.glyphs {
447            let display_char = g.display_char.chars().next().unwrap_or('?');
448            self.register(GlyphDisplay {
449                id: g.id,
450                display_char,
451                name: g.name,
452                category: g.category,
453                layer: g.layer,
454                blocks_move: g.blocks_move,
455                blocks_sight: g.blocks_sight,
456                move_cost: g.move_cost,
457            });
458        }
459        Ok(count)
460    }
461
462    // Internal helper to add a default glyph entry.
463    fn add(
464        &mut self,
465        id: u16,
466        display_char: char,
467        name: &str,
468        category: &str,
469        layer: u8,
470        blocks_move: bool,
471        blocks_sight: bool,
472        move_cost: u32,
473    ) {
474        self.entries.insert(
475            id,
476            GlyphDisplay {
477                id,
478                display_char,
479                name: name.to_string(),
480                category: category.to_string(),
481                layer,
482                blocks_move,
483                blocks_sight,
484                move_cost,
485            },
486        );
487    }
488}
489
490// =============================================================================
491// TESTS
492// =============================================================================
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    // ---- blocks_move ----
499
500    #[test]
501    fn blocks_move_walls_and_barriers() {
502        assert!(blocks_move(WALL));
503        assert!(blocks_move(WALL_VERT));
504        assert!(blocks_move(WALL_HORIZ));
505        assert!(blocks_move(DOOR_CLOSED));
506        assert!(blocks_move(DEEP_WATER));
507        assert!(blocks_move(BOULDER));
508        assert!(blocks_move(CHEST));
509        assert!(blocks_move(CRATE));
510        assert!(blocks_move(BED));
511        assert!(blocks_move(UNKNOWN_OBJ));
512        assert!(blocks_move(PILLAR));
513    }
514
515    #[test]
516    fn blocks_move_passable_ground() {
517        for &g in &[GROUND, DIRT, SAND, MUD, GRASS, DEBRIS, ROCKY_GROUND] {
518            assert!(!blocks_move(g), "ground glyph {g} should not block move");
519        }
520    }
521
522    #[test]
523    fn blocks_move_passable_liquids() {
524        assert!(!blocks_move(LIQUID));
525    }
526
527    #[test]
528    fn blocks_move_passable_doors_and_stairs() {
529        assert!(!blocks_move(DOOR_OPEN));
530        assert!(!blocks_move(STAIRS_UP));
531        assert!(!blocks_move(STAIRS_DOWN));
532        assert!(!blocks_move(BRIDGE));
533    }
534
535    #[test]
536    fn blocks_move_passable_objects() {
537        for &g in &[SHRINE, CONSUMABLE, CURRENCY, KEY_ITEM] {
538            assert!(!blocks_move(g), "object glyph {g} should not block move");
539        }
540    }
541
542    #[test]
543    fn blocks_move_passable_effects() {
544        assert!(!blocks_move(HAZARD));
545        assert!(!blocks_move(SMOKE));
546        assert!(!blocks_move(MAGIC_EFFECT));
547    }
548
549    // ---- blocks_sight ----
550
551    #[test]
552    fn blocks_sight_opaque() {
553        assert!(blocks_sight(WALL));
554        assert!(blocks_sight(WALL_VERT));
555        assert!(blocks_sight(WALL_HORIZ));
556        assert!(blocks_sight(DOOR_CLOSED));
557        assert!(blocks_sight(BOULDER));
558        assert!(blocks_sight(SMOKE));
559        assert!(blocks_sight(PILLAR));
560    }
561
562    #[test]
563    fn blocks_sight_transparent_ground() {
564        for &g in &[GROUND, DIRT, SAND, MUD, LIQUID, DEEP_WATER, ROCKY_GROUND, GRASS, DEBRIS] {
565            assert!(!blocks_sight(g), "ground glyph {g} should not block sight");
566        }
567    }
568
569    #[test]
570    fn blocks_sight_transparent_structures() {
571        assert!(!blocks_sight(DOOR_OPEN));
572        assert!(!blocks_sight(STAIRS_UP));
573        assert!(!blocks_sight(STAIRS_DOWN));
574        assert!(!blocks_sight(BRIDGE));
575    }
576
577    #[test]
578    fn blocks_sight_transparent_objects() {
579        for &g in &[CHEST, CRATE, SHRINE, BED, CONSUMABLE, CURRENCY, KEY_ITEM, UNKNOWN_OBJ] {
580            assert!(!blocks_sight(g), "object glyph {g} should not block sight");
581        }
582    }
583
584    #[test]
585    fn blocks_sight_transparent_effects() {
586        assert!(!blocks_sight(HAZARD));
587        assert!(!blocks_sight(MAGIC_EFFECT));
588    }
589
590    // ---- move_cost ----
591
592    #[test]
593    fn move_cost_one() {
594        for &g in &[
595            GROUND,
596            DIRT,
597            DOOR_OPEN,
598            STAIRS_UP,
599            STAIRS_DOWN,
600            BRIDGE,
601            SHRINE,
602            CONSUMABLE,
603            CURRENCY,
604            KEY_ITEM,
605        ] {
606            assert_eq!(move_cost(g), 1, "glyph {g} should cost 1");
607        }
608    }
609
610    #[test]
611    fn move_cost_two() {
612        for &g in &[SAND, MUD, GRASS, DEBRIS, ROCKY_GROUND, HAZARD, SMOKE] {
613            assert_eq!(move_cost(g), 2, "glyph {g} should cost 2");
614        }
615    }
616
617    #[test]
618    fn move_cost_three() {
619        assert_eq!(move_cost(LIQUID), 3);
620    }
621
622    #[test]
623    fn move_cost_impassable() {
624        for &g in &[
625            WALL,
626            WALL_VERT,
627            WALL_HORIZ,
628            DOOR_CLOSED,
629            DEEP_WATER,
630            BOULDER,
631            CHEST,
632            CRATE,
633            BED,
634            UNKNOWN_OBJ,
635        ] {
636            assert_eq!(move_cost(g), 999, "glyph {g} should be impassable (999)");
637        }
638    }
639
640    #[test]
641    fn move_cost_unknown_id() {
642        assert_eq!(move_cost(0), 999);
643        assert_eq!(move_cost(65535), 999);
644    }
645
646    // ---- chunk_coord / local_coord round-trip ----
647
648    #[test]
649    fn chunk_and_local_round_trip() {
650        for tile_pos in [0, 1, 31, 32, 33, 63, 64, 100, 1023, 1024] {
651            let c = chunk_coord(tile_pos);
652            let l = local_coord(tile_pos);
653            assert_eq!(
654                c * CHUNK_SIZE + l,
655                tile_pos,
656                "round-trip failed for tile_pos={tile_pos}"
657            );
658        }
659    }
660
661    #[test]
662    fn chunk_coord_boundary() {
663        assert_eq!(chunk_coord(0), 0);
664        assert_eq!(chunk_coord(CHUNK_SIZE - 1), 0);
665        assert_eq!(chunk_coord(CHUNK_SIZE), 1);
666    }
667
668    #[test]
669    fn local_coord_boundary() {
670        assert_eq!(local_coord(0), 0);
671        assert_eq!(local_coord(CHUNK_SIZE - 1), CHUNK_SIZE - 1);
672        assert_eq!(local_coord(CHUNK_SIZE), 0);
673        assert_eq!(local_coord(CHUNK_SIZE + 1), 1);
674    }
675
676    // ---- chunk_id ----
677
678    #[test]
679    fn chunk_id_format() {
680        assert_eq!(chunk_id("area_1", 0, 0), "area_1:0,0");
681        assert_eq!(chunk_id("dungeon-42", 3, 7), "dungeon-42:3,7");
682        assert_eq!(chunk_id("", 10, 20), ":10,20");
683    }
684
685    // ---- read_glyph ----
686
687    #[test]
688    fn read_glyph_within_bounds() {
689        let size = (CHUNK_SIZE * CHUNK_SIZE) as usize;
690        let mut tiles = vec![GROUND; size];
691        tiles[(3 * CHUNK_SIZE + 5) as usize] = WALL;
692        assert_eq!(read_glyph(&tiles, 5, 3), WALL);
693        assert_eq!(read_glyph(&tiles, 0, 0), GROUND);
694        assert_eq!(read_glyph(&tiles, CHUNK_SIZE - 1, CHUNK_SIZE - 1), GROUND);
695    }
696
697    #[test]
698    fn read_glyph_out_of_bounds_x() {
699        let tiles = vec![GROUND; (CHUNK_SIZE * CHUNK_SIZE) as usize];
700        assert_eq!(read_glyph(&tiles, CHUNK_SIZE, 0), 0);
701        assert_eq!(read_glyph(&tiles, u32::MAX, 0), 0);
702    }
703
704    #[test]
705    fn read_glyph_out_of_bounds_y() {
706        let tiles = vec![GROUND; (CHUNK_SIZE * CHUNK_SIZE) as usize];
707        assert_eq!(read_glyph(&tiles, 0, CHUNK_SIZE), 0);
708        assert_eq!(read_glyph(&tiles, 0, u32::MAX), 0);
709    }
710
711    #[test]
712    fn read_glyph_short_slice() {
713        let tiles = vec![GROUND; 10];
714        assert_eq!(read_glyph(&tiles, 15, 0), 0);
715    }
716
717    #[test]
718    fn read_glyph_empty_slice() {
719        let tiles: &[u16] = &[];
720        assert_eq!(read_glyph(tiles, 0, 0), 0);
721    }
722
723    // ---- Glyph constant uniqueness (base layer) ----
724
725    #[test]
726    fn glyph_constant_all_unique() {
727        use std::collections::HashMap;
728
729        let constants: &[(&str, u16)] = &[
730            ("GROUND", GROUND),
731            ("DIRT", DIRT),
732            ("SAND", SAND),
733            ("MUD", MUD),
734            ("LIQUID", LIQUID),
735            ("DEEP_WATER", DEEP_WATER),
736            ("ROCKY_GROUND", ROCKY_GROUND),
737            ("GRASS", GRASS),
738            ("DEBRIS", DEBRIS),
739            ("WALL", WALL),
740            ("WALL_VERT", WALL_VERT),
741            ("WALL_HORIZ", WALL_HORIZ),
742            ("PILLAR", PILLAR),
743            ("DOOR_CLOSED", DOOR_CLOSED),
744            ("DOOR_OPEN", DOOR_OPEN),
745            ("STAIRS_UP", STAIRS_UP),
746            ("STAIRS_DOWN", STAIRS_DOWN),
747            ("BRIDGE", BRIDGE),
748            ("BOULDER", BOULDER),
749            ("CHEST", CHEST),
750            ("CRATE", CRATE),
751            ("SHRINE", SHRINE),
752            ("BED", BED),
753            ("CONSUMABLE", CONSUMABLE),
754            ("CURRENCY", CURRENCY),
755            ("KEY_ITEM", KEY_ITEM),
756            ("UNKNOWN_OBJ", UNKNOWN_OBJ),
757            ("HAZARD", HAZARD),
758            ("SMOKE", SMOKE),
759            ("MAGIC_EFFECT", MAGIC_EFFECT),
760        ];
761
762        let mut seen: HashMap<u16, Vec<&str>> = HashMap::new();
763        for &(name, val) in constants {
764            seen.entry(val).or_default().push(name);
765        }
766
767        let mut collisions: Vec<String> = Vec::new();
768        for (val, names) in &seen {
769            if names.len() > 1 {
770                collisions.push(format!("{} collide on ID 0x{:04X}", names.join(" and "), val,));
771            }
772        }
773
774        assert!(
775            collisions.is_empty(),
776            "glyph collisions found:\n{}",
777            collisions.join("\n"),
778        );
779    }
780
781    // ---- u16 backward compatibility ----
782
783    #[test]
784    fn u16_ids_match_ascii_bytes() {
785        assert_eq!(GROUND, b'.' as u16);
786        assert_eq!(WALL, b'#' as u16);
787        assert_eq!(PLAYER_GLYPH, b'@' as u16);
788        assert_eq!(CHEST, b'C' as u16);
789        assert_eq!(LIQUID, b'~' as u16);
790        assert_eq!(STAIRS_UP, b'^' as u16);
791    }
792
793    // ---- GlyphRegistry ----
794
795    #[test]
796    fn default_registry_has_all_base_glyphs() {
797        let reg = GlyphRegistry::with_defaults();
798        assert!(
799            reg.len() >= 50,
800            "expected at least 50 default glyphs, got {}",
801            reg.len()
802        );
803        assert_eq!(reg.display_char(WALL), '#');
804        assert_eq!(reg.display_char(GROUND), '.');
805        assert_eq!(reg.display_char(PLAYER_GLYPH), '@');
806        assert_eq!(reg.display_name(WALL), "Wall");
807    }
808
809    #[test]
810    fn registry_display_char_fallback_ascii() {
811        let reg = GlyphRegistry::new();
812        // Empty registry, but ID < 128 falls back to ASCII.
813        assert_eq!(reg.display_char(b'Z' as u16), 'Z');
814    }
815
816    #[test]
817    fn registry_display_char_fallback_unknown() {
818        let reg = GlyphRegistry::new();
819        // ID >= 128 with no entry returns '?'.
820        assert_eq!(reg.display_char(300), '?');
821    }
822
823    #[test]
824    fn registry_merge() {
825        let mut base = GlyphRegistry::with_defaults();
826        let mut custom = GlyphRegistry::new();
827        custom.register(GlyphDisplay {
828            id: 256,
829            display_char: '\u{1F30A}', // 🌊
830            name: "Ocean".to_string(),
831            category: "custom".to_string(),
832            layer: 3,
833            blocks_move: true,
834            blocks_sight: false,
835            move_cost: 999,
836        });
837        base.merge(&custom);
838        assert_eq!(base.display_char(256), '\u{1F30A}');
839        assert_eq!(base.display_name(256), "Ocean");
840        // Existing entries preserved.
841        assert_eq!(base.display_char(WALL), '#');
842    }
843
844    #[test]
845    fn registry_entries_sorted() {
846        let reg = GlyphRegistry::with_defaults();
847        let sorted = reg.entries_sorted();
848        for i in 1..sorted.len() {
849            assert!(sorted[i].id >= sorted[i - 1].id);
850        }
851    }
852
853    // ---- GlyphDisplay::validate ----
854
855    #[test]
856    fn validate_layer_within_range() {
857        let glyph = GlyphDisplay {
858            id: 100,
859            display_char: '.',
860            name: "Test".to_string(),
861            category: "test".to_string(),
862            layer: 9,
863            blocks_move: false,
864            blocks_sight: false,
865            move_cost: 1,
866        };
867        assert!(glyph.validate().is_ok());
868    }
869
870    #[test]
871    fn validate_layer_zero() {
872        let glyph = GlyphDisplay {
873            id: 100,
874            display_char: '.',
875            name: "Test".to_string(),
876            category: "test".to_string(),
877            layer: 0,
878            blocks_move: false,
879            blocks_sight: false,
880            move_cost: 1,
881        };
882        assert!(glyph.validate().is_ok());
883    }
884
885    #[test]
886    fn validate_layer_exceeds_max() {
887        let glyph = GlyphDisplay {
888            id: 100,
889            display_char: '.',
890            name: "Test".to_string(),
891            category: "test".to_string(),
892            layer: 10,
893            blocks_move: false,
894            blocks_sight: false,
895            move_cost: 1,
896        };
897        let result = glyph.validate();
898        assert!(result.is_err());
899        assert!(result.unwrap_err().contains("glyph_invalid_layer"));
900    }
901
902    #[test]
903    fn validate_layer_max_u8() {
904        let glyph = GlyphDisplay {
905            id: 100,
906            display_char: '.',
907            name: "Test".to_string(),
908            category: "test".to_string(),
909            layer: 255,
910            blocks_move: false,
911            blocks_sight: false,
912            move_cost: 1,
913        };
914        assert!(glyph.validate().is_err());
915    }
916
917    #[test]
918    fn validate_all_defaults_valid() {
919        let reg = GlyphRegistry::with_defaults();
920        for entry in reg.entries_sorted() {
921            assert!(
922                entry.validate().is_ok(),
923                "default glyph id={} has invalid layer {}",
924                entry.id,
925                entry.layer
926            );
927        }
928    }
929}