use std::collections::HashMap;
use serde::{Deserialize, Serialize};
pub const CHUNK_SIZE: u32 = 32;
pub 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 {
matches!(
glyph,
WALL | WALL_VERT | WALL_HORIZ | PILLAR | DOOR_CLOSED | DEEP_WATER | BOULDER | CHEST | CRATE | BED | UNKNOWN_OBJ
)
}
pub fn blocks_sight(glyph: u16) -> bool {
matches!(
glyph,
WALL | WALL_VERT | WALL_HORIZ | PILLAR | DOOR_CLOSED | BOULDER | SMOKE
)
}
pub fn move_cost(glyph: u16) -> u32 {
match glyph {
GROUND | DIRT | DOOR_OPEN | STAIRS_UP | STAIRS_DOWN | BRIDGE | SHRINE | CONSUMABLE | CURRENCY | KEY_ITEM => 1,
SAND | MUD | GRASS | DEBRIS | ROCKY_GROUND | HAZARD | SMOKE | MAGIC_EFFECT => 2,
LIQUID => 3,
_ => 999,
}
}
pub fn glyph_layer(glyph: u16) -> u8 {
match glyph {
STAR | NEBULA | VOID | WARP_GATE => 0,
PLANET | MOON_GLYPH | ASTEROID | STATION_GLYPH | TRADE_ROUTE => 2,
MOUNTAIN | FOREST | RIVER | VILLAGE | RUIN | MINE | FARMLAND => 3,
WALL | WALL_VERT | WALL_HORIZ | DOOR_CLOSED | DOOR_OPEN | STAIRS_UP | STAIRS_DOWN | BRIDGE => 6,
FURNACE | LOOM | WELL | LEVER | TRAP | PORTAL_GLYPH => 9,
NPC_GLYPH | MERCHANT_GLYPH | QUEST_GLYPH | CAMPFIRE | BARREL | SIGN => 9,
ENEMY_GLYPH | BOSS_GLYPH | COMPANION_GLYPH | MOUNT_GLYPH => 9,
_ => 7,
}
}
pub fn chunk_coord(tile_pos: u32) -> u32 {
tile_pos / CHUNK_SIZE
}
pub fn local_coord(tile_pos: u32) -> u32 {
tile_pos % CHUNK_SIZE
}
pub fn chunk_id(area_id: &str, cx: u32, cy: u32) -> String {
format!("{}:{},{}", area_id, cx, cy)
}
pub fn read_glyph(tiles: &[u16], lx: u32, ly: u32) -> u16 {
if lx >= CHUNK_SIZE || ly >= CHUNK_SIZE {
return 0;
}
let idx = (ly as usize) * (CHUNK_SIZE as usize) + (lx as usize);
if idx >= tiles.len() {
return 0;
}
tiles[idx]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlyphDisplay {
pub id: u16,
pub display_char: char,
pub name: String,
pub category: String,
pub layer: u8,
pub blocks_move: bool,
pub blocks_sight: bool,
pub move_cost: u32,
}
pub const MAX_TOPOLOGY_LAYER: u8 = 9;
impl GlyphDisplay {
pub fn validate(&self) -> Result<(), String> {
if self.layer > MAX_TOPOLOGY_LAYER {
return Err(format!(
"glyph_invalid_layer: id={}, layer={} exceeds max {}",
self.id, self.layer, MAX_TOPOLOGY_LAYER
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlyphRegistry {
entries: HashMap<u16, GlyphDisplay>,
}
impl Default for GlyphRegistry {
fn default() -> Self {
Self::with_defaults()
}
}
impl GlyphRegistry {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
pub fn with_defaults() -> Self {
let mut reg = Self::new();
reg.add(GROUND, '.', "Ground", "ground", 7, false, false, 1);
reg.add(DIRT, ',', "Dirt", "ground", 7, false, false, 1);
reg.add(SAND, ':', "Sand", "ground", 7, false, false, 2);
reg.add(MUD, '`', "Mud", "ground", 7, false, false, 2);
reg.add(LIQUID, '~', "Liquid", "ground", 7, false, false, 3);
reg.add(DEEP_WATER, '=', "Deep Water", "ground", 7, true, false, 999);
reg.add(ROCKY_GROUND, 'b', "Rocky Ground", "ground", 7, false, false, 2);
reg.add(GRASS, ';', "Grass", "ground", 7, false, false, 2);
reg.add(DEBRIS, '\'', "Debris", "ground", 7, false, false, 2);
reg.add(WALL, '#', "Wall", "structure", 6, true, true, 999);
reg.add(WALL_VERT, '|', "Wall Vert", "structure", 6, true, true, 999);
reg.add(WALL_HORIZ, '_', "Wall Horiz", "structure", 6, true, true, 999);
reg.add(PILLAR, 'i', "Pillar", "structure", 6, true, true, 999);
reg.add(DOOR_CLOSED, 'I', "Door Closed", "structure", 6, true, true, 999);
reg.add(DOOR_OPEN, '*', "Door Open", "structure", 6, false, false, 1);
reg.add(STAIRS_UP, '^', "Stairs Up", "structure", 6, false, false, 1);
reg.add(STAIRS_DOWN, 'v', "Stairs Down", "structure", 6, false, false, 1);
reg.add(BRIDGE, '{', "Bridge", "structure", 6, false, false, 1);
reg.add(BOULDER, 'o', "Boulder", "object", 7, true, true, 999);
reg.add(CHEST, 'C', "Chest", "object", 7, true, false, 999);
reg.add(CRATE, 'c', "Crate", "object", 7, true, false, 999);
reg.add(SHRINE, 'S', "Shrine", "object", 7, false, false, 1);
reg.add(BED, 'B', "Bed", "object", 7, true, false, 999);
reg.add(CONSUMABLE, '!', "Consumable", "object", 7, false, false, 1);
reg.add(CURRENCY, '$', "Currency", "object", 7, false, false, 1);
reg.add(KEY_ITEM, 'K', "Key Item", "object", 7, false, false, 1);
reg.add(UNKNOWN_OBJ, '?', "Unknown", "object", 7, true, false, 999);
reg.add(HAZARD, 'x', "Hazard", "effect", 7, false, false, 2);
reg.add(SMOKE, '%', "Smoke", "effect", 7, false, true, 2);
reg.add(MAGIC_EFFECT, '}', "Magic Effect", "effect", 7, false, false, 2);
reg.add(STAR, '@', "Star", "cosmic", 0, false, false, 999);
reg.add(NEBULA, '&', "Nebula", "cosmic", 0, false, false, 999);
reg.add(VOID, ' ', "Void", "cosmic", 0, false, false, 999);
reg.add(WARP_GATE, 'W', "Warp Gate", "cosmic", 0, false, false, 1);
reg.add(STATION_GLYPH, 'H', "Station", "cosmic", 2, false, false, 1);
reg.add(ASTEROID, 'a', "Asteroid", "cosmic", 2, false, false, 999);
reg.add(PLANET, 'O', "Planet", "cosmic", 2, false, false, 999);
reg.add(TRADE_ROUTE, '-', "Trade Route", "cosmic", 2, false, false, 1);
reg.add(MOUNTAIN, 'A', "Mountain", "world", 3, true, true, 999);
reg.add(FOREST, 'T', "Forest", "world", 3, false, false, 2);
reg.add(VILLAGE, 'h', "Village", "world", 3, false, false, 1);
reg.add(RUIN, 'R', "Ruin", "world", 3, false, false, 2);
reg.add(MINE, 'M', "Mine", "world", 3, false, false, 1);
reg.add(FARMLAND, 'f', "Farmland", "world", 3, false, false, 1);
reg.add(FURNACE, 'F', "Furnace", "prefab", 9, false, false, 1);
reg.add(LOOM, 'L', "Loom", "prefab", 9, false, false, 1);
reg.add(WELL, 'w', "Well", "prefab", 9, false, false, 1);
reg.add(SIGN, 's', "Sign", "prefab", 9, false, false, 1);
reg.add(LEVER, 'l', "Lever", "prefab", 9, false, false, 1);
reg.add(TRAP, 't', "Trap", "prefab", 9, false, false, 1);
reg.add(PORTAL_GLYPH, 'P', "Portal", "prefab", 9, false, false, 1);
reg.add(NPC_GLYPH, 'N', "NPC", "prefab", 9, false, false, 1);
reg.add(MERCHANT_GLYPH, 'V', "Merchant", "prefab", 9, false, false, 1);
reg.add(QUEST_GLYPH, 'Q', "Quest", "prefab", 9, false, false, 1);
reg.add(CAMPFIRE, '+', "Campfire", "prefab", 9, false, false, 1);
reg.add(BARREL, 'U', "Barrel", "prefab", 9, false, false, 1);
reg.add(PLAYER_GLYPH, '@', "Player", "entity", 9, false, false, 1);
reg.add(ENEMY_GLYPH, 'E', "Enemy", "entity", 9, false, false, 1);
reg.add(BOSS_GLYPH, 'D', "Boss", "entity", 9, false, false, 1);
reg.add(COMPANION_GLYPH, 'G', "Companion", "entity", 9, false, false, 1);
reg.add(MOUNT_GLYPH, 'm', "Mount", "entity", 9, false, false, 1);
reg.add(ADMIN_GLYPH, '@', "Admin", "entity", 9, false, false, 1);
reg.add(AGENT_GLYPH, 'a', "Agent", "entity", 9, false, false, 1);
reg
}
pub fn register(&mut self, entry: GlyphDisplay) {
self.entries.insert(entry.id, entry);
}
pub fn get(&self, id: u16) -> Option<&GlyphDisplay> {
self.entries.get(&id)
}
pub fn display_char(&self, id: u16) -> char {
if let Some(entry) = self.entries.get(&id) {
entry.display_char
} else if id < 128 {
id as u8 as char
} else {
'?'
}
}
pub fn display_name(&self, id: u16) -> &str {
self.entries.get(&id).map(|e| e.name.as_str()).unwrap_or("Unknown")
}
pub fn merge(&mut self, other: &GlyphRegistry) {
for (id, entry) in &other.entries {
self.entries.insert(*id, entry.clone());
}
}
pub fn entries_sorted(&self) -> Vec<&GlyphDisplay> {
let mut entries: Vec<_> = self.entries.values().collect();
entries.sort_by_key(|e| e.id);
entries
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn load_json(&mut self, json: &str) -> Result<usize, String> {
#[derive(Deserialize)]
struct GlyphFile {
glyphs: Vec<GlyphJsonEntry>,
}
#[derive(Deserialize)]
struct GlyphJsonEntry {
id: u16,
display_char: String,
name: String,
#[serde(default = "default_category")]
category: String,
#[serde(default = "default_layer")]
layer: u8,
#[serde(default)]
blocks_move: bool,
#[serde(default)]
blocks_sight: bool,
#[serde(default = "default_move_cost")]
move_cost: u32,
}
fn default_category() -> String {
"custom".to_string()
}
fn default_layer() -> u8 {
7
}
fn default_move_cost() -> u32 {
1
}
let file: GlyphFile = serde_json::from_str(json).map_err(|e| format!("glyph_json_parse:{}", e))?;
let count = file.glyphs.len();
for g in file.glyphs {
let display_char = g.display_char.chars().next().unwrap_or('?');
self.register(GlyphDisplay {
id: g.id,
display_char,
name: g.name,
category: g.category,
layer: g.layer,
blocks_move: g.blocks_move,
blocks_sight: g.blocks_sight,
move_cost: g.move_cost,
});
}
Ok(count)
}
fn add(
&mut self,
id: u16,
display_char: char,
name: &str,
category: &str,
layer: u8,
blocks_move: bool,
blocks_sight: bool,
move_cost: u32,
) {
self.entries.insert(
id,
GlyphDisplay {
id,
display_char,
name: name.to_string(),
category: category.to_string(),
layer,
blocks_move,
blocks_sight,
move_cost,
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_move_walls_and_barriers() {
assert!(blocks_move(WALL));
assert!(blocks_move(WALL_VERT));
assert!(blocks_move(WALL_HORIZ));
assert!(blocks_move(DOOR_CLOSED));
assert!(blocks_move(DEEP_WATER));
assert!(blocks_move(BOULDER));
assert!(blocks_move(CHEST));
assert!(blocks_move(CRATE));
assert!(blocks_move(BED));
assert!(blocks_move(UNKNOWN_OBJ));
assert!(blocks_move(PILLAR));
}
#[test]
fn blocks_move_passable_ground() {
for &g in &[GROUND, DIRT, SAND, MUD, GRASS, DEBRIS, ROCKY_GROUND] {
assert!(!blocks_move(g), "ground glyph {g} should not block move");
}
}
#[test]
fn blocks_move_passable_liquids() {
assert!(!blocks_move(LIQUID));
}
#[test]
fn blocks_move_passable_doors_and_stairs() {
assert!(!blocks_move(DOOR_OPEN));
assert!(!blocks_move(STAIRS_UP));
assert!(!blocks_move(STAIRS_DOWN));
assert!(!blocks_move(BRIDGE));
}
#[test]
fn blocks_move_passable_objects() {
for &g in &[SHRINE, CONSUMABLE, CURRENCY, KEY_ITEM] {
assert!(!blocks_move(g), "object glyph {g} should not block move");
}
}
#[test]
fn blocks_move_passable_effects() {
assert!(!blocks_move(HAZARD));
assert!(!blocks_move(SMOKE));
assert!(!blocks_move(MAGIC_EFFECT));
}
#[test]
fn blocks_sight_opaque() {
assert!(blocks_sight(WALL));
assert!(blocks_sight(WALL_VERT));
assert!(blocks_sight(WALL_HORIZ));
assert!(blocks_sight(DOOR_CLOSED));
assert!(blocks_sight(BOULDER));
assert!(blocks_sight(SMOKE));
assert!(blocks_sight(PILLAR));
}
#[test]
fn blocks_sight_transparent_ground() {
for &g in &[GROUND, DIRT, SAND, MUD, LIQUID, DEEP_WATER, ROCKY_GROUND, GRASS, DEBRIS] {
assert!(!blocks_sight(g), "ground glyph {g} should not block sight");
}
}
#[test]
fn blocks_sight_transparent_structures() {
assert!(!blocks_sight(DOOR_OPEN));
assert!(!blocks_sight(STAIRS_UP));
assert!(!blocks_sight(STAIRS_DOWN));
assert!(!blocks_sight(BRIDGE));
}
#[test]
fn blocks_sight_transparent_objects() {
for &g in &[CHEST, CRATE, SHRINE, BED, CONSUMABLE, CURRENCY, KEY_ITEM, UNKNOWN_OBJ] {
assert!(!blocks_sight(g), "object glyph {g} should not block sight");
}
}
#[test]
fn blocks_sight_transparent_effects() {
assert!(!blocks_sight(HAZARD));
assert!(!blocks_sight(MAGIC_EFFECT));
}
#[test]
fn move_cost_one() {
for &g in &[
GROUND,
DIRT,
DOOR_OPEN,
STAIRS_UP,
STAIRS_DOWN,
BRIDGE,
SHRINE,
CONSUMABLE,
CURRENCY,
KEY_ITEM,
] {
assert_eq!(move_cost(g), 1, "glyph {g} should cost 1");
}
}
#[test]
fn move_cost_two() {
for &g in &[SAND, MUD, GRASS, DEBRIS, ROCKY_GROUND, HAZARD, SMOKE] {
assert_eq!(move_cost(g), 2, "glyph {g} should cost 2");
}
}
#[test]
fn move_cost_three() {
assert_eq!(move_cost(LIQUID), 3);
}
#[test]
fn move_cost_impassable() {
for &g in &[
WALL,
WALL_VERT,
WALL_HORIZ,
DOOR_CLOSED,
DEEP_WATER,
BOULDER,
CHEST,
CRATE,
BED,
UNKNOWN_OBJ,
] {
assert_eq!(move_cost(g), 999, "glyph {g} should be impassable (999)");
}
}
#[test]
fn move_cost_unknown_id() {
assert_eq!(move_cost(0), 999);
assert_eq!(move_cost(65535), 999);
}
#[test]
fn chunk_and_local_round_trip() {
for tile_pos in [0, 1, 31, 32, 33, 63, 64, 100, 1023, 1024] {
let c = chunk_coord(tile_pos);
let l = local_coord(tile_pos);
assert_eq!(
c * CHUNK_SIZE + l,
tile_pos,
"round-trip failed for tile_pos={tile_pos}"
);
}
}
#[test]
fn chunk_coord_boundary() {
assert_eq!(chunk_coord(0), 0);
assert_eq!(chunk_coord(CHUNK_SIZE - 1), 0);
assert_eq!(chunk_coord(CHUNK_SIZE), 1);
}
#[test]
fn local_coord_boundary() {
assert_eq!(local_coord(0), 0);
assert_eq!(local_coord(CHUNK_SIZE - 1), CHUNK_SIZE - 1);
assert_eq!(local_coord(CHUNK_SIZE), 0);
assert_eq!(local_coord(CHUNK_SIZE + 1), 1);
}
#[test]
fn chunk_id_format() {
assert_eq!(chunk_id("area_1", 0, 0), "area_1:0,0");
assert_eq!(chunk_id("dungeon-42", 3, 7), "dungeon-42:3,7");
assert_eq!(chunk_id("", 10, 20), ":10,20");
}
#[test]
fn read_glyph_within_bounds() {
let size = (CHUNK_SIZE * CHUNK_SIZE) as usize;
let mut tiles = vec![GROUND; size];
tiles[(3 * CHUNK_SIZE + 5) as usize] = WALL;
assert_eq!(read_glyph(&tiles, 5, 3), WALL);
assert_eq!(read_glyph(&tiles, 0, 0), GROUND);
assert_eq!(read_glyph(&tiles, CHUNK_SIZE - 1, CHUNK_SIZE - 1), GROUND);
}
#[test]
fn read_glyph_out_of_bounds_x() {
let tiles = vec![GROUND; (CHUNK_SIZE * CHUNK_SIZE) as usize];
assert_eq!(read_glyph(&tiles, CHUNK_SIZE, 0), 0);
assert_eq!(read_glyph(&tiles, u32::MAX, 0), 0);
}
#[test]
fn read_glyph_out_of_bounds_y() {
let tiles = vec![GROUND; (CHUNK_SIZE * CHUNK_SIZE) as usize];
assert_eq!(read_glyph(&tiles, 0, CHUNK_SIZE), 0);
assert_eq!(read_glyph(&tiles, 0, u32::MAX), 0);
}
#[test]
fn read_glyph_short_slice() {
let tiles = vec![GROUND; 10];
assert_eq!(read_glyph(&tiles, 15, 0), 0);
}
#[test]
fn read_glyph_empty_slice() {
let tiles: &[u16] = &[];
assert_eq!(read_glyph(tiles, 0, 0), 0);
}
#[test]
fn glyph_constant_all_unique() {
use std::collections::HashMap;
let constants: &[(&str, u16)] = &[
("GROUND", GROUND),
("DIRT", DIRT),
("SAND", SAND),
("MUD", MUD),
("LIQUID", LIQUID),
("DEEP_WATER", DEEP_WATER),
("ROCKY_GROUND", ROCKY_GROUND),
("GRASS", GRASS),
("DEBRIS", DEBRIS),
("WALL", WALL),
("WALL_VERT", WALL_VERT),
("WALL_HORIZ", WALL_HORIZ),
("PILLAR", PILLAR),
("DOOR_CLOSED", DOOR_CLOSED),
("DOOR_OPEN", DOOR_OPEN),
("STAIRS_UP", STAIRS_UP),
("STAIRS_DOWN", STAIRS_DOWN),
("BRIDGE", BRIDGE),
("BOULDER", BOULDER),
("CHEST", CHEST),
("CRATE", CRATE),
("SHRINE", SHRINE),
("BED", BED),
("CONSUMABLE", CONSUMABLE),
("CURRENCY", CURRENCY),
("KEY_ITEM", KEY_ITEM),
("UNKNOWN_OBJ", UNKNOWN_OBJ),
("HAZARD", HAZARD),
("SMOKE", SMOKE),
("MAGIC_EFFECT", MAGIC_EFFECT),
];
let mut seen: HashMap<u16, Vec<&str>> = HashMap::new();
for &(name, val) in constants {
seen.entry(val).or_default().push(name);
}
let mut collisions: Vec<String> = Vec::new();
for (val, names) in &seen {
if names.len() > 1 {
collisions.push(format!("{} collide on ID 0x{:04X}", names.join(" and "), val,));
}
}
assert!(
collisions.is_empty(),
"glyph collisions found:\n{}",
collisions.join("\n"),
);
}
#[test]
fn u16_ids_match_ascii_bytes() {
assert_eq!(GROUND, b'.' as u16);
assert_eq!(WALL, b'#' as u16);
assert_eq!(PLAYER_GLYPH, b'@' as u16);
assert_eq!(CHEST, b'C' as u16);
assert_eq!(LIQUID, b'~' as u16);
assert_eq!(STAIRS_UP, b'^' as u16);
}
#[test]
fn default_registry_has_all_base_glyphs() {
let reg = GlyphRegistry::with_defaults();
assert!(
reg.len() >= 50,
"expected at least 50 default glyphs, got {}",
reg.len()
);
assert_eq!(reg.display_char(WALL), '#');
assert_eq!(reg.display_char(GROUND), '.');
assert_eq!(reg.display_char(PLAYER_GLYPH), '@');
assert_eq!(reg.display_name(WALL), "Wall");
}
#[test]
fn registry_display_char_fallback_ascii() {
let reg = GlyphRegistry::new();
assert_eq!(reg.display_char(b'Z' as u16), 'Z');
}
#[test]
fn registry_display_char_fallback_unknown() {
let reg = GlyphRegistry::new();
assert_eq!(reg.display_char(300), '?');
}
#[test]
fn registry_merge() {
let mut base = GlyphRegistry::with_defaults();
let mut custom = GlyphRegistry::new();
custom.register(GlyphDisplay {
id: 256,
display_char: '\u{1F30A}', name: "Ocean".to_string(),
category: "custom".to_string(),
layer: 3,
blocks_move: true,
blocks_sight: false,
move_cost: 999,
});
base.merge(&custom);
assert_eq!(base.display_char(256), '\u{1F30A}');
assert_eq!(base.display_name(256), "Ocean");
assert_eq!(base.display_char(WALL), '#');
}
#[test]
fn registry_entries_sorted() {
let reg = GlyphRegistry::with_defaults();
let sorted = reg.entries_sorted();
for i in 1..sorted.len() {
assert!(sorted[i].id >= sorted[i - 1].id);
}
}
#[test]
fn validate_layer_within_range() {
let glyph = GlyphDisplay {
id: 100,
display_char: '.',
name: "Test".to_string(),
category: "test".to_string(),
layer: 9,
blocks_move: false,
blocks_sight: false,
move_cost: 1,
};
assert!(glyph.validate().is_ok());
}
#[test]
fn validate_layer_zero() {
let glyph = GlyphDisplay {
id: 100,
display_char: '.',
name: "Test".to_string(),
category: "test".to_string(),
layer: 0,
blocks_move: false,
blocks_sight: false,
move_cost: 1,
};
assert!(glyph.validate().is_ok());
}
#[test]
fn validate_layer_exceeds_max() {
let glyph = GlyphDisplay {
id: 100,
display_char: '.',
name: "Test".to_string(),
category: "test".to_string(),
layer: 10,
blocks_move: false,
blocks_sight: false,
move_cost: 1,
};
let result = glyph.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("glyph_invalid_layer"));
}
#[test]
fn validate_layer_max_u8() {
let glyph = GlyphDisplay {
id: 100,
display_char: '.',
name: "Test".to_string(),
category: "test".to_string(),
layer: 255,
blocks_move: false,
blocks_sight: false,
move_cost: 1,
};
assert!(glyph.validate().is_err());
}
#[test]
fn validate_all_defaults_valid() {
let reg = GlyphRegistry::with_defaults();
for entry in reg.entries_sorted() {
assert!(
entry.validate().is_ok(),
"default glyph id={} has invalid layer {}",
entry.id,
entry.layer
);
}
}
}