1use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14
15pub const CHUNK_SIZE: u32 = 32;
17
18pub const GROUND: u16 = b'.' as u16; pub const DIRT: u16 = b',' as u16; pub const SAND: u16 = b':' as u16; pub const MUD: u16 = b'`' as u16; pub const LIQUID: u16 = b'~' as u16; pub const DEEP_WATER: u16 = b'=' as u16; pub const ROCKY_GROUND: u16 = b'b' as u16; pub const GRASS: u16 = b';' as u16; pub const DEBRIS: u16 = b'\'' as u16; pub const WALL: u16 = b'#' as u16; pub const WALL_VERT: u16 = b'|' as u16; pub const WALL_HORIZ: u16 = b'_' as u16; pub const PILLAR: u16 = b'i' as u16; pub const DOOR_CLOSED: u16 = b'I' as u16; pub const DOOR_OPEN: u16 = b'*' as u16; pub const STAIRS_UP: u16 = b'^' as u16; pub const STAIRS_DOWN: u16 = b'v' as u16; pub const BRIDGE: u16 = b'{' as u16; pub const BOULDER: u16 = b'o' as u16; pub const CHEST: u16 = b'C' as u16; pub const CRATE: u16 = b'c' as u16; pub const SHRINE: u16 = b'S' as u16; pub const BED: u16 = b'B' as u16; pub const CONSUMABLE: u16 = b'!' as u16; pub const CURRENCY: u16 = b'$' as u16; pub const KEY_ITEM: u16 = b'K' as u16; pub const UNKNOWN_OBJ: u16 = b'?' as u16; pub const HAZARD: u16 = b'x' as u16; pub const SMOKE: u16 = b'%' as u16; pub const MAGIC_EFFECT: u16 = b'}' as u16; pub const STAR: u16 = b'@' as u16; pub const NEBULA: u16 = b'&' as u16; pub const VOID: u16 = b' ' as u16; pub const WARP_GATE: u16 = b'W' as u16; pub const STATION_GLYPH: u16 = b'H' as u16; pub const ASTEROID: u16 = b'a' as u16; pub const PLANET: u16 = b'O' as u16; pub const MOON_GLYPH: u16 = b'o' as u16; pub const TRADE_ROUTE: u16 = b'-' as u16; pub const ANOMALY: u16 = b'@' as u16; pub const MOUNTAIN: u16 = b'A' as u16; pub const FOREST: u16 = b'T' as u16; pub const RIVER: u16 = b'~' as u16; pub const ROAD: u16 = b'-' as u16; pub const CITY: u16 = b'@' as u16; pub const VILLAGE: u16 = b'h' as u16; pub const RUIN: u16 = b'R' as u16; pub const MINE: u16 = b'M' as u16; pub const FARMLAND: u16 = b'f' as u16; pub const HARBOR: u16 = b'H' as u16; pub const WORKBENCH: u16 = b'W' as u16; pub const ANVIL: u16 = b'A' as u16; pub const FURNACE: u16 = b'F' as u16; pub const LOOM: u16 = b'L' as u16; pub const ALTAR: u16 = b'a' as u16; pub const WELL: u16 = b'w' as u16; pub const SIGN: u16 = b's' as u16; pub const LEVER: u16 = b'l' as u16; pub const TRAP: u16 = b't' as u16; pub const PORTAL_GLYPH: u16 = b'P' as u16; pub const NPC_GLYPH: u16 = b'N' as u16; pub const MERCHANT_GLYPH: u16 = b'V' as u16; pub const QUEST_GLYPH: u16 = b'Q' as u16; pub const CAMPFIRE: u16 = b'+' as u16; pub const BARREL: u16 = b'U' as u16; pub const PLAYER_GLYPH: u16 = b'@' as u16; pub const ENEMY_GLYPH: u16 = b'E' as u16; pub const BOSS_GLYPH: u16 = b'D' as u16; pub const COMPANION_GLYPH: u16 = b'G' as u16; pub const MOUNT_GLYPH: u16 = b'm' as u16; pub const ADMIN_GLYPH: u16 = 128; pub const AGENT_GLYPH: u16 = 129; pub 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
125pub fn blocks_sight(glyph: u16) -> bool {
127 matches!(
128 glyph,
129 WALL | WALL_VERT | WALL_HORIZ | PILLAR | DOOR_CLOSED | BOULDER | SMOKE
130 )
131}
132
133pub 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
143pub 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
160pub fn chunk_coord(tile_pos: u32) -> u32 {
166 tile_pos / CHUNK_SIZE
167}
168
169pub fn local_coord(tile_pos: u32) -> u32 {
171 tile_pos % CHUNK_SIZE
172}
173
174pub fn chunk_id(area_id: &str, cx: u32, cy: u32) -> String {
176 format!("{}:{},{}", area_id, cx, cy)
177}
178
179pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct GlyphDisplay {
199 pub id: u16,
201 pub display_char: char,
203 pub name: String,
205 pub category: String,
207 pub layer: u8,
209 pub blocks_move: bool,
211 pub blocks_sight: bool,
213 pub move_cost: u32,
215}
216
217pub const MAX_TOPOLOGY_LAYER: u8 = 9;
219
220impl GlyphDisplay {
221 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#[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 pub fn new() -> Self {
257 Self {
258 entries: HashMap::new(),
259 }
260 }
261
262 pub fn with_defaults() -> Self {
264 let mut reg = Self::new();
265
266 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 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 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 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 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 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 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 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 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 pub fn register(&mut self, entry: GlyphDisplay) {
352 self.entries.insert(entry.id, entry);
353 }
354
355 pub fn get(&self, id: u16) -> Option<&GlyphDisplay> {
357 self.entries.get(&id)
358 }
359
360 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 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 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 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 pub fn len(&self) -> usize {
394 self.entries.len()
395 }
396
397 pub fn is_empty(&self) -> bool {
399 self.entries.is_empty()
400 }
401
402 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 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#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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}', 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 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 #[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}