diff --git a/client/tests/test_sprint30.gd b/client/tests/test_sprint30.gd new file mode 100644 index 000000000..ca78695ef --- /dev/null +++ b/client/tests/test_sprint30.gd @@ -0,0 +1,590 @@ +## Sprint 30 — QA acceptance tests +## +## Covers all 5 Sprint 30 client tickets: +## #718 — Persist CharacterVisualDescriptor on new game start +## #719 — Hair highlight: make swatch read-only (Option B — no compositor yet) +## #720 — Replace DirAccess scanning with manifest JSON for export builds +## #712 — BoneAttachment3D marker above Head bone for floating icons +## #674 — Star map insert module (test-first: scene must exist when implemented) +## +## Test convention: +## - Tests that should PASS immediately = regression guards on existing code +## - Tests prefixed [ACCEPTANCE] = will FAIL until the ticket is implemented +## +## Ticket refs: #718, #719, #720, #712, #674 +class_name TestSprint30 +extends GdUnitTestSuite + + +func before_each() -> void: + # Reset GameState fields touched by #718 tests to avoid cross-test pollution. + GameState.character_visual_descriptor = null + + +func after_each() -> void: + GameState.character_visual_descriptor = null + + +# ============================================================================= +# #718 — Persist CharacterVisualDescriptor +# ============================================================================= + +func test_game_state_has_character_visual_descriptor_field() -> void: + ## GameState.character_visual_descriptor must exist and default to null. + ## Confirms the field added in game_state.gd line 103 is present. + var gs := GameState.new() + auto_free(gs) + # The field is declared on the class — access it without error + var val: Variant = gs.get("character_visual_descriptor") + # Field should exist (not return null from missing property vs. null value) + assert_bool(gs.has_method("apply_snapshot")).override_failure_message( + "GameState must be a valid autoload class with apply_snapshot" + ).is_true() + # The property itself must be gettable and null by default + assert_bool(val == null).override_failure_message( + "GameState.character_visual_descriptor must default to null" + ).is_true() + + +func test_descriptor_to_dict_includes_all_required_fields() -> void: + ## CharacterVisualDescriptor.to_dict() must include all wire-format fields. + var desc := CharacterVisualDescriptor.new() + auto_free(desc) + var d := desc.to_dict() + var required_keys := [ + "body_type", "head_id", "hair_id", "hair_tint", + "facial_hair_id", "facial_hair_tint", "eyebrow_id", "eyebrow_tint", + "eye_color", "skin_tone", "clothing_slots", "clothing_tints", + "accessory_slots", "accessory_tints", + ] + for key in required_keys: + assert_bool(d.has(key)).override_failure_message( + "to_dict() must include field '%s'" % key + ).is_true() + + +func test_descriptor_to_dict_body_type_is_wire_string() -> void: + ## body_type in to_dict() must be a string (rmp_serde unit enum), not an int. + var desc := CharacterVisualDescriptor.new() + auto_free(desc) + desc.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M + var d := desc.to_dict() + assert_str(d["body_type"]).override_failure_message( + "to_dict() body_type must be wire string 'AverageM'" + ).is_equal("AverageM") + + +func test_descriptor_round_trip_preserves_fields() -> void: + ## from_dict(to_dict(desc)) must preserve all scalar fields. + var original := CharacterVisualDescriptor.new() + auto_free(original) + original.body_type = CharacterVisualDescriptor.BodyType.THIN_F + original.head_id = "head_002" + original.hair_id = "bob" + original.hair_tint = Color(0.8, 0.4, 0.2) + original.skin_tone = 3 + + var wire := original.to_dict() + var restored := CharacterVisualDescriptor.from_dict(wire) + assert_bool(restored != null).override_failure_message( + "from_dict() must return a descriptor for valid wire data" + ).is_true() + if restored == null: + return + + assert_int(int(restored.body_type)).override_failure_message( + "body_type must survive round-trip" + ).is_equal(int(CharacterVisualDescriptor.BodyType.THIN_F)) + + assert_str(restored.head_id).override_failure_message( + "head_id must survive round-trip" + ).is_equal("head_002") + + assert_str(restored.hair_id).override_failure_message( + "hair_id must survive round-trip" + ).is_equal("bob") + + assert_int(restored.skin_tone).override_failure_message( + "skin_tone must survive round-trip" + ).is_equal(3) + + +func test_descriptor_from_dict_returns_null_when_missing_body_type() -> void: + ## from_dict() must return null if body_type is absent (required field). + var d := {"head_id": "head_001"} # missing body_type + var result := CharacterVisualDescriptor.from_dict(d) + assert_bool(result == null).override_failure_message( + "from_dict() must return null when body_type is missing" + ).is_true() + + +func test_descriptor_color_encoding_is_float_array() -> void: + ## Colors must encode as [r, g, b, a] float arrays for rmp_serde compatibility. + var desc := CharacterVisualDescriptor.new() + auto_free(desc) + desc.eye_color = Color(0.1, 0.2, 0.3, 1.0) + var d := desc.to_dict() + var encoded: Variant = d["eye_color"] + assert_bool(encoded is Array).override_failure_message( + "eye_color must encode as an Array [r, g, b, a]" + ).is_true() + if not (encoded is Array): + return + assert_int((encoded as Array).size()).override_failure_message( + "eye_color array must have 4 elements" + ).is_equal(4) + assert_float((encoded as Array)[0]).override_failure_message( + "eye_color[0] (r) must be approx 0.1" + ).is_equal_approx(0.1, 0.001) + + +func test_apply_snapshot_restores_character_visual_descriptor() -> void: + ## #718: apply_snapshot() must restore character_visual_descriptor from + ## the "character_visual_descriptor" key in ObserverSnapshot (save/load path). + var desc := CharacterVisualDescriptor.new() + desc.body_type = CharacterVisualDescriptor.BodyType.MUSCULAR_F + desc.head_id = "head_003" + desc.hair_id = "dreads" + desc.skin_tone = 5 + var snapshot := { + "character_visual_descriptor": desc.to_dict(), + } + GameState.apply_snapshot(snapshot) + var restored: Variant = GameState.character_visual_descriptor + assert_bool(restored != null).override_failure_message( + "apply_snapshot() must restore character_visual_descriptor from snapshot" + ).is_true() + if restored != null and restored is CharacterVisualDescriptor: + var r := restored as CharacterVisualDescriptor + assert_int(int(r.body_type)).override_failure_message( + "restored body_type must match" + ).is_equal(int(CharacterVisualDescriptor.BodyType.MUSCULAR_F)) + assert_str(r.head_id).override_failure_message( + "restored head_id must match" + ).is_equal("head_003") + assert_int(r.skin_tone).override_failure_message( + "restored skin_tone must match" + ).is_equal(5) + + +func test_apply_snapshot_preserves_descriptor_when_field_absent() -> void: + ## #718: If snapshot lacks "character_visual_descriptor", the existing field + ## must not be overwritten (server only sends when descriptor changes). + var desc := CharacterVisualDescriptor.new() + desc.hair_id = "bob" + GameState.character_visual_descriptor = desc + # Snapshot with no character_visual_descriptor key + GameState.apply_snapshot({"tick": 1}) + var after: Variant = GameState.character_visual_descriptor + assert_bool(after != null).override_failure_message( + "apply_snapshot() must NOT clear descriptor when field is absent" + ).is_true() + if after is CharacterVisualDescriptor: + assert_str((after as CharacterVisualDescriptor).hair_id).override_failure_message( + "descriptor must be unchanged after snapshot with no character_visual_descriptor key" + ).is_equal("bob") + + +func test_protocol_encode_startup_includes_descriptor() -> void: + ## #718: Protocol.encode_startup_message() must include "character_visual_descriptor" + ## in the encoded payload when a descriptor is provided. + var desc := CharacterVisualDescriptor.new() + desc.body_type = CharacterVisualDescriptor.BodyType.THIN_M + desc.hair_id = "buzzed" + var bytes := Protocol.encode_startup_message(12345, "detective", desc) + assert_bool(bytes.size() > 0).override_failure_message( + "encode_startup_message() must produce non-empty bytes" + ).is_true() + # Decode and verify the field is present (Messagepack.decode returns {status, value}) + var raw = Messagepack.decode(bytes) + assert_bool(raw.status == null).override_failure_message( + "encode_startup_message() output must be valid msgpack" + ).is_true() + if raw.status != null: + return + var msg: Dictionary = raw.value as Dictionary + assert_bool(msg.has("character_visual_descriptor")).override_failure_message( + "StartupMessage must include 'character_visual_descriptor' key when descriptor is provided" + ).is_true() + if msg.has("character_visual_descriptor"): + assert_bool(msg["character_visual_descriptor"] is Dictionary).override_failure_message( + "character_visual_descriptor in StartupMessage must be a Dictionary" + ).is_true() + + +func test_protocol_encode_startup_omits_descriptor_when_null() -> void: + ## #718: encode_startup_message() must still produce valid bytes when descriptor is null. + var bytes := Protocol.encode_startup_message(0, "detective", null) + assert_bool(bytes.size() > 0).override_failure_message( + "encode_startup_message() must produce valid bytes even with null descriptor" + ).is_true() + + +func test_descriptor_has_no_hair_highlight_tint_field() -> void: + ## CharacterVisualDescriptor must NOT have a hair_highlight_tint field. + ## The highlight is always auto-derived from hair_tint (Option B of #719). + ## to_dict() must not include it in the wire format. + var desc := CharacterVisualDescriptor.new() + auto_free(desc) + var d := desc.to_dict() + assert_bool(d.has("hair_highlight_tint")).override_failure_message( + "to_dict() must NOT include hair_highlight_tint — highlight is auto-derived" + ).is_false() + assert_bool(desc.get("hair_highlight_tint") != null and typeof(desc.get("hair_highlight_tint")) != TYPE_NIL).override_failure_message( + "CharacterVisualDescriptor must not define a hair_highlight_tint property" + ).is_false() + + +# ============================================================================= +# #719 — Hair highlight swatch (Option B: read-only, auto-derived) +# ============================================================================= + +func test_derive_hair_highlight_lightens_primary() -> void: + ## _derive_hair_highlight() must return primary.lightened(0.3). + ## Tests the derivation formula in character_creation.gd:1917. + var cc_scene_path := "res://scenes/character_creation.tscn" + if not ResourceLoader.exists(cc_scene_path): + push_warning("test_derive_hair_highlight_lightens_primary: scene not available in headless — skipping") + return + var packed := load(cc_scene_path) as PackedScene + if packed == null: + return + var cc := packed.instantiate() as CharacterCreation + if cc == null: + push_warning("test_derive_hair_highlight_lightens_primary: failed to instantiate — skipping") + return + auto_free(cc) + + # CharacterCreation._derive_hair_highlight is a private method but testable via call() + var primary := Color(0.4, 0.3, 0.5) + var expected := primary.lightened(0.3) + var result: Variant = cc.call("_derive_hair_highlight", primary) + assert_bool(result is Color).override_failure_message( + "_derive_hair_highlight must return a Color" + ).is_true() + if not (result is Color): + return + var r := result as Color + assert_float(r.r).override_failure_message("derived highlight.r incorrect").is_equal_approx(expected.r, 0.001) + assert_float(r.g).override_failure_message("derived highlight.g incorrect").is_equal_approx(expected.g, 0.001) + assert_float(r.b).override_failure_message("derived highlight.b incorrect").is_equal_approx(expected.b, 0.001) + + +func test_hair_highlight_swatch_exists_in_ui() -> void: + ## [ACCEPTANCE #719] After fix, _hair_highlight_swatch must be non-null + ## (a display node must be created in _build_hair_color_dock). + ## WILL FAIL until #719 is implemented. + var cc_scene_path := "res://scenes/character_creation.tscn" + if not ResourceLoader.exists(cc_scene_path): + push_warning("test_hair_highlight_swatch_exists_in_ui: scene not available in headless — skipping") + return + var packed := load(cc_scene_path) as PackedScene + if packed == null: + return + var cc := packed.instantiate() as CharacterCreation + if cc == null: + return + auto_free(cc) + add_child(cc) + await get_tree().process_frame + + # _hair_highlight_swatch must be set after _ready() builds the hair color dock + var swatch: Variant = cc.get("_hair_highlight_swatch") + assert_bool(swatch != null).override_failure_message( + "[#719] _hair_highlight_swatch must not be null — a display node must be created" + ).is_true() + + +func test_hair_highlight_swatch_is_not_interactive() -> void: + ## [ACCEPTANCE #719] The highlight swatch must be non-interactive. + ## Either mouse_filter = IGNORE, or the node is a ColorRect (not a Button with a callback). + ## WILL FAIL until #719 is implemented. + var cc_scene_path := "res://scenes/character_creation.tscn" + if not ResourceLoader.exists(cc_scene_path): + push_warning("test_hair_highlight_swatch_is_not_interactive: scene not available — skipping") + return + var packed := load(cc_scene_path) as PackedScene + if packed == null: + return + var cc := packed.instantiate() as CharacterCreation + if cc == null: + return + auto_free(cc) + add_child(cc) + await get_tree().process_frame + + var swatch: Variant = cc.get("_hair_highlight_swatch") + if swatch == null: + push_warning("test_hair_highlight_swatch_is_not_interactive: swatch not found — #719 not yet implemented") + return + + # If swatch is a Control node, mouse_filter must be IGNORE (2) + if swatch is Control: + var ctrl := swatch as Control + assert_int(ctrl.mouse_filter).override_failure_message( + "[#719] hair highlight swatch must have mouse_filter=IGNORE (non-interactive)" + ).is_equal(Control.MOUSE_FILTER_IGNORE) + + +# ============================================================================= +# #720 — Manifest JSON completeness (replaces DirAccess scanning) +# ============================================================================= + +func test_manifest_json_is_parseable() -> void: + ## manifest.json must exist and parse as a Dictionary. + var path := "res://assets/characters/manifest.json" + assert_bool(ResourceLoader.exists(path) or FileAccess.file_exists(path)).override_failure_message( + "manifest.json must exist at res://assets/characters/manifest.json" + ).is_true() + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("test_manifest_json_is_parseable: file not openable — skipping") + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + assert_bool(parsed is Dictionary).override_failure_message( + "manifest.json must parse as a JSON object (Dictionary)" + ).is_true() + + +func test_manifest_hair_includes_all_asset_dirs() -> void: + ## [ACCEPTANCE #720] manifest.json "hair" array must include every .glb in + ## assets/characters/hair/. Currently missing: balding, buzzed_female, dreads, + ## long_dreads, mohawk, ponytail_f, simple_parted, slick_back. + ## WILL FAIL until #720 populates the manifest fully. + var path := "res://assets/characters/manifest.json" + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("test_manifest_hair_includes_all_asset_dirs: manifest not readable — skipping") + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + return + var manifest := parsed as Dictionary + var hair_list: Array = manifest.get("hair", []) + + # All hair IDs confirmed from assets/characters/hair/*.glb scan (2026-04-04) + var expected_hair := [ + "bald", "balding", "bob", "buns", "buzzed", "buzzed_female", + "dreads", "long", "long_dreads", "mohawk", "ponytail", "ponytail_f", + "simple_parted", "slick_back", + ] + for hair_id in expected_hair: + assert_bool(hair_list.has(hair_id)).override_failure_message( + "[#720] manifest 'hair' must include '%s'" % hair_id + ).is_true() + + +func test_manifest_heads_are_populated() -> void: + ## [ACCEPTANCE #720] manifest.json "heads" must not be empty. + ## heads/templates/ contains head_001..head_004 — all must be listed. + ## WILL FAIL until #720 populates the manifest. + var path := "res://assets/characters/manifest.json" + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("test_manifest_heads_are_populated: manifest not readable — skipping") + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + return + var manifest := parsed as Dictionary + var heads_list: Array = manifest.get("heads", []) + + var expected_heads := ["head_001", "head_002", "head_003", "head_004"] + assert_bool(not heads_list.is_empty()).override_failure_message( + "[#720] manifest 'heads' must not be empty — 4 head templates exist" + ).is_true() + for head_id in expected_heads: + assert_bool(heads_list.has(head_id)).override_failure_message( + "[#720] manifest 'heads' must include '%s'" % head_id + ).is_true() + + +func test_manifest_body_types_includes_all_11() -> void: + ## [ACCEPTANCE #720] manifest.json "body_types" must include all 11 types. + ## Currently has 6; missing: thin_m, thin_f, heavy_m, heavy_f, child. + ## WILL FAIL until #720 updates the manifest. + var path := "res://assets/characters/manifest.json" + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("test_manifest_body_types_includes_all_11: manifest not readable — skipping") + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + return + var manifest := parsed as Dictionary + var bt_list: Array = manifest.get("body_types", []) + + var expected_types := [ + "thin_m", "thin_f", "average_m", "average_f", + "muscular_m", "muscular_f", "teen_m", "teen_f", + "heavy_m", "heavy_f", "child", + ] + for bt in expected_types: + assert_bool(bt_list.has(bt)).override_failure_message( + "[#720] manifest 'body_types' must include '%s'" % bt + ).is_true() + + +func test_manifest_clothing_includes_all_items() -> void: + ## [ACCEPTANCE #720] manifest.json "clothing" must include all items in + ## assets/characters/clothing/. Currently missing: boots_work, coveralls_basic, + ## jacket_utility, pants_cargo, shirt_henley. + ## WILL FAIL until #720 populates the manifest. + var path := "res://assets/characters/manifest.json" + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("test_manifest_clothing_includes_all_items: manifest not readable — skipping") + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + return + var manifest := parsed as Dictionary + var clothing_data: Variant = manifest.get("clothing", {}) + var clothing_keys: Array = [] + if clothing_data is Dictionary: + clothing_keys = (clothing_data as Dictionary).keys() + + # All clothing item IDs confirmed from assets/characters/clothing/ scan (2026-04-04) + var expected_items := [ + "boots_work", "coveralls_basic", "jacket_utility", "pants_cargo", + "peasant_pants", "peasant_shoes", "peasant_tunic", "shirt_henley", + ] + for item_id in expected_items: + assert_bool(clothing_keys.has(item_id)).override_failure_message( + "[#720] manifest 'clothing' must include '%s'" % item_id + ).is_true() + + +func test_manifest_eyebrows_are_populated() -> void: + ## [ACCEPTANCE #720] manifest.json "eyebrows" must list all eyebrow styles. + ## assets/characters/eyebrows/ has: female, regular, teen, thick. + ## WILL FAIL until #720 populates the manifest. + var path := "res://assets/characters/manifest.json" + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("test_manifest_eyebrows_are_populated: manifest not readable — skipping") + return + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not (parsed is Dictionary): + return + var manifest := parsed as Dictionary + var eb_list: Array = manifest.get("eyebrows", []) + + var expected_eyebrows := ["female", "regular", "teen", "thick"] + assert_bool(not eb_list.is_empty()).override_failure_message( + "[#720] manifest 'eyebrows' must not be empty" + ).is_true() + for eb_id in expected_eyebrows: + assert_bool(eb_list.has(eb_id)).override_failure_message( + "[#720] manifest 'eyebrows' must include '%s'" % eb_id + ).is_true() + + +func test_dir_access_scan_functions_removed() -> void: + ## [ACCEPTANCE #720] After fix, _scan_subdirs and _scan_asset_ids must be + ## removed from CharacterCreation. These functions fail in exported PCK builds. + ## WILL FAIL until #720 removes the DirAccess fallbacks. + var cc_scene_path := "res://scenes/character_creation.tscn" + if not ResourceLoader.exists(cc_scene_path): + push_warning("test_dir_access_scan_functions_removed: scene not available — skipping") + return + var packed := load(cc_scene_path) as PackedScene + if packed == null: + return + var cc := packed.instantiate() as CharacterCreation + if cc == null: + return + auto_free(cc) + + assert_bool(cc.has_method("_scan_subdirs")).override_failure_message( + "[#720] _scan_subdirs must be removed — use manifest JSON instead" + ).is_false() + assert_bool(cc.has_method("_scan_asset_ids")).override_failure_message( + "[#720] _scan_asset_ids must be removed — use manifest JSON instead" + ).is_false() + + +# ============================================================================= +# #712 — BoneAttachment3D overhead anchor in CharacterVisual +# ============================================================================= + +func test_character_visual_has_get_overhead_anchor() -> void: + ## CharacterVisual must expose get_overhead_anchor() as part of its public API. + ## This is a static assertion — no 3D assets required. + var cv := CharacterVisual.new() + auto_free(cv) + assert_bool(cv.has_method("get_overhead_anchor")).override_failure_message( + "CharacterVisual must have get_overhead_anchor() method (#712)" + ).is_true() + + +func test_overhead_anchor_is_null_before_load() -> void: + ## get_overhead_anchor() must return null before load_descriptor() is called. + ## The anchor is created during skeleton load, not at construction. + var cv := CharacterVisual.new() + auto_free(cv) + var anchor: Variant = cv.get_overhead_anchor() + assert_bool(anchor == null).override_failure_message( + "get_overhead_anchor() must be null before load_descriptor() is called" + ).is_true() + + +func test_overhead_anchor_offset_constant() -> void: + ## [ACCEPTANCE #712] If CharacterVisual exposes the overhead anchor offset + ## as a constant or via get_overhead_anchor(), the offset must be Vector3(0, 0.3, 0). + ## Verified via code inspection: _overhead_anchor.position = Vector3(0, 0.3, 0). + ## This test loads a scene and verifies if assets are present. + var cv := CharacterVisual.new() + auto_free(cv) + add_child(cv) + + # Without GLB assets available in headless, skeleton load is a no-op. + # Check that _overhead_attachment is also null before load (belt-and-suspenders). + var attachment: Variant = cv.get("_overhead_attachment") + assert_bool(attachment == null).override_failure_message( + "_overhead_attachment must be null before skeleton is loaded" + ).is_true() + + +# ============================================================================= +# #674 — Star map insert module (test-first) +# ============================================================================= + +func test_star_map_scene_exists() -> void: + ## [ACCEPTANCE #674] The star map scene must exist at the expected path. + ## WILL FAIL until #674 is implemented. + var expected_path := "res://ui/star_map.tscn" + assert_bool(ResourceLoader.exists(expected_path)).override_failure_message( + "[#674] Star map scene must exist at res://ui/star_map.tscn" + ).is_true() + + +func test_star_map_is_accessible_from_insert_ui() -> void: + ## [ACCEPTANCE #674] The star map module must be reachable from the insert UI. + ## Verify via HUD or main scene that a star_map node/scene is connected. + ## WILL FAIL until #674 wires the scene into the insert layer. + var hud_scene_path := "res://ui/hud.tscn" + if not ResourceLoader.exists(hud_scene_path): + push_warning("test_star_map_is_accessible_from_insert_ui: HUD scene not found — skipping") + return + var packed := load(hud_scene_path) as PackedScene + if packed == null: + return + var hud := packed.instantiate() + if hud == null: + return + auto_free(hud) + add_child(hud) + await get_tree().process_frame + + # Star map must be reachable as a named node from the HUD or insert layer + var star_map := hud.get_node_or_null("StarMap") + assert_bool(star_map != null).override_failure_message( + "[#674] HUD must contain a StarMap node accessible from the insert UI" + ).is_true()