diff --git a/TODO.md b/TODO.md index 88f8906..bc482b7 100644 --- a/TODO.md +++ b/TODO.md @@ -639,8 +639,12 @@ must inherit the reviewed face/hand/gear baseline instead of multiplying whateve - [x] Add the four specs to `CHARACTERS`, commit their GLBs/AO maps and manifest roles/tint data. (Note: this adds ~2 MB to the next release build via `export_filter=all_resources` — accepted for the crowd-variety roster.) -- [ ] Replace the fixed `character_model_for()` mapping with deterministic per-role pools and add - tests for repeatability and duplicate avoidance (`godot-export` contract check included). +- [x] Per-role pools: `character_model_for()` now picks from `CHARACTER_POOLS` by seed — the + routine crowd roles gain the crowd variants (tourist → casual/crowd_a/crowd_d, + confused_visitor → casual/crowd_b, intoxicated → casual/crowd_c) while the special roles + keep their existing alternation (guest ⅓ jacket, trespasser/threat ½ hoodie). The test + now checks seed-stability plus that every pool entry resolves to a committed GLB + (277/277). - [ ] Rebuild and verify gear fit for every new variant (`blender_assets.py fit` plus the runtime gear checks) and re-run `inspect` on the four new GLBs. - [ ] Build the crowd-variety reference board (licensed street-crowd photos plus AI proposals) and diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index 88301b1..cd49a5a 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -195,32 +195,28 @@ const GEAR_ATTACH := { } ## Character variants from tools/blender_assets.py. The manifest is the source ## of truth; this mapping keeps selection deterministic and testable offline. -const CHARACTER_MODELS := { - Rules.NpcType.TOURIST: "npc_casual", - Rules.NpcType.CONFUSED_VISITOR: "npc_casual", - Rules.NpcType.INTOXICATED: "npc_casual", - Rules.NpcType.AUTHORIZED_GUEST: "npc_jacket", - Rules.NpcType.AUTHORIZED_STAFF: "npc_jacket", - Rules.NpcType.PROTESTER: "npc_jacket", - Rules.NpcType.INFLUENCER: "npc_coat", - Rules.NpcType.TRESPASSER: "npc_coat", - Rules.NpcType.VIOLENT_THREAT: "npc_hoodie", - Rules.NpcType.MENTAL_CRISIS: "npc_hoodie", - Rules.NpcType.PERFORMER: "npc_dress", - Rules.NpcType.DELIVERY: "npc_vest", - Rules.NpcType.RESPONDER: "npc_firefighter", +const CHARACTER_POOLS := { + Rules.NpcType.TOURIST: ["npc_casual", "npc_crowd_a", "npc_crowd_d"], + Rules.NpcType.CONFUSED_VISITOR: ["npc_casual", "npc_crowd_b"], + Rules.NpcType.INTOXICATED: ["npc_casual", "npc_crowd_c"], + Rules.NpcType.AUTHORIZED_GUEST: ["npc_jacket", "npc_casual", "npc_casual"], + Rules.NpcType.AUTHORIZED_STAFF: ["npc_jacket"], + Rules.NpcType.PROTESTER: ["npc_jacket"], + Rules.NpcType.INFLUENCER: ["npc_coat"], + Rules.NpcType.TRESPASSER: ["npc_hoodie", "npc_coat"], + Rules.NpcType.VIOLENT_THREAT: ["npc_hoodie", "npc_coat"], + Rules.NpcType.MENTAL_CRISIS: ["npc_hoodie"], + Rules.NpcType.PERFORMER: ["npc_dress"], + Rules.NpcType.DELIVERY: ["npc_vest"], + Rules.NpcType.RESPONDER: ["npc_firefighter"], } -## Which library character represents this profile. Pure and seed-stable; some -## roles alternate between two silhouettes so crowds do not clone. +## Which library character represents this profile. Pure and seed-stable; a role +## picks from its pool by seed so crowds vary without cloning, and every pool +## entry resolves to a committed library GLB. static func character_model_for(npc_type: int, seed_value: int) -> String: - match npc_type: - Rules.NpcType.AUTHORIZED_GUEST: - return "npc_jacket" if seed_value % 3 == 0 else "npc_casual" - Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT: - return "npc_hoodie" if seed_value % 2 == 0 else "npc_coat" - _: - return str(CHARACTER_MODELS.get(npc_type, "npc_casual")) + var pool: Array = CHARACTER_POOLS.get(npc_type, ["npc_casual"]) + return str(pool[absi(seed_value) % pool.size()]) const HAIR_STYLES := ["short", "long", "bun", "beard", "bald", "short"] const HAND_ITEMS := ["phone", "cup", "clipboard", "umbrella", "camera", "none"] diff --git a/src/test/tests/test_npc_character.gd b/src/test/tests/test_npc_character.gd index 47b4876..a935b89 100644 --- a/src/test/tests/test_npc_character.gd +++ b/src/test/tests/test_npc_character.gd @@ -180,10 +180,12 @@ func test_pose_reaches_the_skeleton_bones() -> bool: return a and b and c and d func test_character_selection_is_deterministic_and_complete() -> bool: + # Seed 5 resolves each role to a fixed pool member; the routine crowd roles + # now pick from the crowd-variety pools instead of a single npc_casual. var expected := { - Rules.NpcType.TOURIST: "npc_casual", - Rules.NpcType.CONFUSED_VISITOR: "npc_casual", - Rules.NpcType.INTOXICATED: "npc_casual", + Rules.NpcType.TOURIST: "npc_crowd_d", + Rules.NpcType.CONFUSED_VISITOR: "npc_crowd_b", + Rules.NpcType.INTOXICATED: "npc_crowd_c", Rules.NpcType.AUTHORIZED_STAFF: "npc_jacket", Rules.NpcType.PROTESTER: "npc_jacket", Rules.NpcType.INFLUENCER: "npc_coat", @@ -201,6 +203,11 @@ func test_character_selection_is_deterministic_and_complete() -> bool: problems.append("%s missing" % model) if NpcAvatar.character_model_for(npc_type, 5) != model: problems.append("%d is not seed-stable" % npc_type) + # Every seed in a role's pool must resolve to a committed library model. + for seed_value in [0, 1, 2, 3, 4]: + var pooled := NpcAvatar.character_model_for(npc_type, seed_value) + if not ResourceLoader.exists(NpcAvatar.BLENDER_NPC_DIR + pooled + ".glb"): + problems.append("%d seed %d -> %s missing" % [npc_type, seed_value, pooled]) # Seed-alternating variants still resolve to a library model. for seed_value in [1, 2, 3, 4, 5, 6]: var variant := NpcAvatar.character_model_for(Rules.NpcType.TRESPASSER, seed_value)