Merge remote-tracking branch 'origin/client'
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# Ticket CLI
|
||||
|
||||
**Use `tooling/db/ticket`** for all ticket operations. Never use `sqlite3` directly (crashes in Claude Code).
|
||||
|
||||
## Positional arguments — not flags
|
||||
|
||||
`ticket create` uses **positional** arguments for `type` and `title`. There is no `--title` flag.
|
||||
|
||||
```bash
|
||||
# CORRECT — type and title are positional
|
||||
tooling/db/ticket create story "My ticket title" --description "Details here" --team server --priority low
|
||||
|
||||
# WRONG — --title does not exist, gets absorbed into the title string
|
||||
tooling/db/ticket create story --title "My ticket title" --description "Details here"
|
||||
# Creates a ticket titled: "--title My ticket title"
|
||||
```
|
||||
|
||||
## Full usage
|
||||
|
||||
```bash
|
||||
# Create
|
||||
tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]
|
||||
# type: initiative | epic | story | task | bug
|
||||
# priority: critical | high | medium | low
|
||||
|
||||
# Read
|
||||
tooling/db/ticket show <id>
|
||||
tooling/db/ticket list [--sprint N] [--team T] [--status S]
|
||||
|
||||
# Update
|
||||
tooling/db/ticket assign <id> <agent>
|
||||
```
|
||||
|
||||
## Key rules
|
||||
|
||||
- **Type and title are positional** — everything else is a flag
|
||||
- **Quote the title** — always wrap in double quotes to handle spaces
|
||||
- **Never use `sqlite3` CLI** — it crashes (std::bad_alloc). Use `tooling/db/sqlite-query` or `tooling/db/sqlite-exec` for raw SQL
|
||||
- **Verify after create** — run `tooling/db/ticket show <id>` to confirm the title is clean
|
||||
@@ -8,11 +8,20 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
### Added
|
||||
- `corridor-status` subcommand for atlas CLI — shows remaining unfinished systems grouped by geographic sector and hop distance (#744)
|
||||
- Star map insert module — concentric hop-ring view of 301 systems, sector-colored, click-to-select with info panel, pan/zoom (#674)
|
||||
- BoneAttachment3D overhead anchor above Head bone for future floating UI elements (#712)
|
||||
- CharacterVisualDescriptor wired into startup IPC and snapshot restore for save/load persistence (#718)
|
||||
- Display-only hair highlight swatch (auto-derived from primary tint) in character creation (#719)
|
||||
- Asset manifest fully populated (11 body types, 14 hair, 4 heads, 4 eyebrows, 8 clothing) with regeneration script (#720)
|
||||
- Sprint 30 acceptance test suite (27 tests across all 5 tickets)
|
||||
|
||||
### Fixed
|
||||
- `habitable_planet_count` filter now accepts both "breathable" and "standard" atmosphere values — previously all committed systems reported 0 habitable planets (#762). Systems committed before this fix may have stale `habitable_planet_count = 0`; re-commit to update.
|
||||
- `corridor-status` uses LEFT JOIN so systems without gate records are included in counts
|
||||
- `generate_body_matrix` now emits `atmosphere: "standard"` (was "breathable") to match committed-system conventions
|
||||
- DirAccess asset scanning replaced with manifest JSON — fixes character creation in exported PCK builds (#720)
|
||||
- Star map set_insert_active() no longer auto-shows the modal panel (#674)
|
||||
- Star map insert state propagation wired into main.gd (#674)
|
||||
|
||||
## [v0.1.29] — 2026-04-03
|
||||
|
||||
|
||||
@@ -84,6 +84,16 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
||||
- Three test tiers: (1) Live server — highest fidelity, (2) MessagePack replay via `Protocol.decode_snapshot()` — for unreachable rooms, (3) TestHarness mock — for UI-only tests where fog data doesn't matter.
|
||||
- `make fixtures-gauntlet` regenerates real server snapshot fixtures from the Gauntlet world.
|
||||
|
||||
### GDScript conventions
|
||||
|
||||
**Autoload parse-order rule:** Autoload scripts (`client/scripts/autoloads/`) compile before global `class_name` scripts are registered. Referencing a `class_name` type directly in an autoload causes a parse-time "not declared" error. Pattern:
|
||||
- Declare fields untyped: `var my_field = null` (comment the intended type)
|
||||
- Do **not** reference `class_name` types at the top level or in `_ready()` of autoloads
|
||||
- In method bodies called at runtime (e.g. `apply_snapshot`), use `load()` inline — by then the script is cached and `load()` returns the cached resource without reloading: `var CVD := load("res://scripts/rendering/character_visual_descriptor.gd")`
|
||||
- Do **not** cache the `load()` result in `_ready()` — `_ready()` fires during autoload init, before the target script is in the resource cache, causing an actual file reload that breaks self-references in scripts using their own `class_name`
|
||||
|
||||
`game_state.gd` (`character_visual_descriptor` field) and `sim_bridge.gd` (`harness` field) follow this pattern.
|
||||
|
||||
### File conventions
|
||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
||||
|
||||
@@ -7,11 +7,12 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
|
||||
pre-pr-server pre-pr-client pre-pr-content \
|
||||
fixtures-client fixtures-gauntlet golden-diff golden-update \
|
||||
checklist-validate checklist-generate \
|
||||
checklist-validate checklist-generate check-star-map \
|
||||
build-sr-voice run-sr-voice test-voice-mock test-voice-real \
|
||||
perf-baseline debug-schedule \
|
||||
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \
|
||||
screenshot visual-movie test-visual visual-update
|
||||
screenshot visual-movie test-visual visual-update \
|
||||
manifest
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -62,6 +63,7 @@ help:
|
||||
@echo " make test-visual Run visual golden regression tests"
|
||||
@echo " make visual-update Regenerate visual goldens and stage for commit"
|
||||
@echo ""
|
||||
@echo " make manifest Regenerate assets/characters/manifest.json from asset dirs (#720)"
|
||||
@echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)"
|
||||
@echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)"
|
||||
@echo " make pre-pr-client Client-scoped pre-PR (lint, build, test)"
|
||||
@@ -345,6 +347,9 @@ checklist-validate:
|
||||
checklist-generate:
|
||||
@tooling/validate-checklist
|
||||
|
||||
check-star-map:
|
||||
@python3 tooling/generate-star-map-data.py --check
|
||||
|
||||
perf-baseline:
|
||||
@tooling/perf-baseline
|
||||
|
||||
@@ -399,6 +404,12 @@ test-voice-real:
|
||||
cd server && cargo test --test voice_pipeline -- --nocapture
|
||||
@echo "Results: .tmp/voice-test/results.txt"
|
||||
|
||||
# --- Asset manifest ---
|
||||
|
||||
manifest:
|
||||
@tooling/generate-character-manifest
|
||||
@echo "Manifest regenerated — commit client/assets/characters/manifest.json if changed."
|
||||
|
||||
# --- Clean ---
|
||||
|
||||
clean:
|
||||
|
||||
@@ -1,13 +1,75 @@
|
||||
{
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "teen_m", "teen_f"],
|
||||
"heads": [],
|
||||
"hair": ["bob", "buns", "buzzed", "long", "ponytail", "bald"],
|
||||
"facial_hair": ["beard", "moustache", "mutton_chops"],
|
||||
"eyebrows": [],
|
||||
"body_types": [
|
||||
"average_f",
|
||||
"average_m",
|
||||
"child",
|
||||
"heavy_f",
|
||||
"heavy_m",
|
||||
"muscular_f",
|
||||
"muscular_m",
|
||||
"teen_f",
|
||||
"teen_m",
|
||||
"thin_f",
|
||||
"thin_m"
|
||||
],
|
||||
"heads": [
|
||||
"head_001",
|
||||
"head_002",
|
||||
"head_003",
|
||||
"head_004"
|
||||
],
|
||||
"hair": [
|
||||
"bald",
|
||||
"balding",
|
||||
"bob",
|
||||
"buns",
|
||||
"buzzed",
|
||||
"buzzed_female",
|
||||
"dreads",
|
||||
"long",
|
||||
"long_dreads",
|
||||
"mohawk",
|
||||
"ponytail",
|
||||
"ponytail_f",
|
||||
"simple_parted",
|
||||
"slick_back"
|
||||
],
|
||||
"facial_hair": [
|
||||
"beard",
|
||||
"moustache",
|
||||
"mutton_chops"
|
||||
],
|
||||
"eyebrows": [
|
||||
"female",
|
||||
"regular",
|
||||
"teen",
|
||||
"thick"
|
||||
],
|
||||
"clothing": {
|
||||
"peasant_tunic": {"slot": "torso"},
|
||||
"peasant_pants": {"slot": "legs"},
|
||||
"peasant_shoes": {"slot": "feet"}
|
||||
"boots_work": {
|
||||
"slot": "feet"
|
||||
},
|
||||
"coveralls_basic": {
|
||||
"slot": "torso"
|
||||
},
|
||||
"jacket_utility": {
|
||||
"slot": "torso"
|
||||
},
|
||||
"pants_cargo": {
|
||||
"slot": "legs"
|
||||
},
|
||||
"peasant_pants": {
|
||||
"slot": "legs"
|
||||
},
|
||||
"peasant_shoes": {
|
||||
"slot": "feet"
|
||||
},
|
||||
"peasant_tunic": {
|
||||
"slot": "torso"
|
||||
},
|
||||
"shirt_henley": {
|
||||
"slot": "torso"
|
||||
}
|
||||
},
|
||||
"accessories": []
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -100,7 +100,8 @@ var character_archetype: String = "detective"
|
||||
# #705: Character visual descriptor — set by character_creation.gd on confirmation.
|
||||
# Passed to EntityRenderer for the player entity's CharacterVisual on game start.
|
||||
# Null when no custom appearance has been selected (fallback: default descriptor).
|
||||
var character_visual_descriptor: CharacterVisualDescriptor = null
|
||||
# Type is CharacterVisualDescriptor — untyped to avoid autoload parse-order issue.
|
||||
var character_visual_descriptor = null
|
||||
|
||||
# #646: AI-Enhanced Dialogue enabled state (D-138).
|
||||
# Runtime toggle — true means the LLM re-voicing pipeline should run (server-side).
|
||||
@@ -362,6 +363,19 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
settings_response = null
|
||||
|
||||
# #718: character_visual_descriptor — restored from server snapshot on save/load.
|
||||
# Server persists the descriptor and includes it in ObserverSnapshot after load.
|
||||
# Only update when field is present (null means no change).
|
||||
if snapshot.has("character_visual_descriptor") and snapshot.character_visual_descriptor is Dictionary:
|
||||
# load() returns a cached script — safe to call per-tick once the resource is in cache.
|
||||
# Cannot use CharacterVisualDescriptor directly: autoloads compile before global class_names
|
||||
# are registered, causing a parse-time "not declared" error.
|
||||
var CVD := load("res://scripts/rendering/character_visual_descriptor.gd")
|
||||
if CVD != null:
|
||||
var restored = CVD.from_dict(snapshot.character_visual_descriptor)
|
||||
if restored != null:
|
||||
character_visual_descriptor = restored
|
||||
|
||||
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||
# Only update when field is present (null means no change, server sends when KG changes).
|
||||
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
||||
|
||||
@@ -5,7 +5,8 @@ enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
|
||||
|
||||
var state: ConnectionState = ConnectionState.DISCONNECTED
|
||||
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
|
||||
var harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client)
|
||||
# Type is TestHarness — untyped to avoid autoload parse-order issue.
|
||||
var harness = null # Test simulation (D-020: game logic lives outside production client)
|
||||
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||
|
||||
@@ -33,7 +34,7 @@ signal handshake_failed(reason: String)
|
||||
|
||||
func _ready() -> void:
|
||||
if test_mode:
|
||||
harness = TestHarness.new()
|
||||
harness = load("res://scripts/protocol/test_harness.gd").new()
|
||||
print("SimBridge: Running in test mode (dynamic snapshot)")
|
||||
|
||||
|
||||
@@ -231,9 +232,9 @@ func _process(delta: float) -> void:
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
# Send startup message with world_seed (#175, D-010/D-029).
|
||||
# Send startup message with world_seed and character appearance (#175, D-010/D-029, #718).
|
||||
# Server blocks waiting for this before entering the tick loop.
|
||||
var startup_bytes := Protocol.encode_startup_message(GameState.world_seed, GameState.character_archetype)
|
||||
var startup_bytes := Protocol.encode_startup_message(GameState.world_seed, GameState.character_archetype, GameState.character_visual_descriptor)
|
||||
if startup_bytes.size() > 0:
|
||||
var send_err := _bridge.send_message(startup_bytes)
|
||||
if send_err != OK:
|
||||
|
||||
@@ -51,4 +51,4 @@ func reload() -> void:
|
||||
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
|
||||
## Delegates to YamlParser.parse_flat() (#560).
|
||||
static func _parse_yaml(text: String) -> Dictionary:
|
||||
return YamlParser.parse_flat(text)
|
||||
return load("res://scripts/util/yaml_parser.gd").parse_flat(text)
|
||||
|
||||
@@ -24,6 +24,7 @@ extends Node2D
|
||||
@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load
|
||||
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
|
||||
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
|
||||
@onready var star_map = $UILayer/HUD/StarMap # #674: star map insert module (hop-ring view)
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||
@@ -254,6 +255,8 @@ func _propagate_insert_state() -> void:
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
if minimap:
|
||||
minimap.set_insert_active(insert_state)
|
||||
if star_map:
|
||||
star_map.set_insert_active(insert_state)
|
||||
|
||||
|
||||
# D-057: Update interaction list from game state.
|
||||
|
||||
@@ -494,11 +494,12 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
||||
|
||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #588).
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #588, #718).
|
||||
## Sent by the client immediately after handshake validation.
|
||||
## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032).
|
||||
## character_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant).
|
||||
static func encode_startup_message(world_seed: int, character_archetype: String = "detective") -> PackedByteArray:
|
||||
## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict.
|
||||
static func encode_startup_message(world_seed: int, character_archetype: String = "detective", character_visual: Variant = null) -> PackedByteArray:
|
||||
# Map client lowercase archetype string to server PascalCase enum variant.
|
||||
# Explicit match prevents unknown strings silently reaching the server as
|
||||
# garbage enum values — fail loudly and fall back to "Detective".
|
||||
@@ -515,6 +516,8 @@ static func encode_startup_message(world_seed: int, character_archetype: String
|
||||
"world_seed": world_seed,
|
||||
"character_archetype": archetype_variant,
|
||||
}
|
||||
if character_visual != null and character_visual.has_method("to_dict"):
|
||||
msg["character_visual_descriptor"] = character_visual.to_dict()
|
||||
var result = Messagepack.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: startup message encode failed: %s" % result.status)
|
||||
|
||||
@@ -23,6 +23,7 @@ extends Node3D
|
||||
## get_clothing_node_count() — number of clothing MeshInstance3D nodes
|
||||
## get_accessory_node_count() — number of accessory BoneAttachment3D nodes
|
||||
## get_skin_tone_texture_name(index) — skin tone texture filename key for index
|
||||
## get_overhead_anchor() — Marker3D above Head bone for floating UI (#712)
|
||||
##
|
||||
## D-159 (11 body types), D-160 (18 segments), D-161 (head separate),
|
||||
## D-162 (clothing pre-fitted), D-163 (heads via BoneAttachment3D), D-164 (skeleton fork)
|
||||
@@ -101,6 +102,8 @@ var _clothing_meshes: Array[MeshInstance3D] = []
|
||||
var _bone_attachments: Array[BoneAttachment3D] = []
|
||||
var _outline_nodes: Array[MeshInstance3D] = []
|
||||
var _accessory_attachments: Array[BoneAttachment3D] = []
|
||||
var _overhead_anchor: Marker3D = null
|
||||
var _overhead_attachment: BoneAttachment3D = null
|
||||
|
||||
# Inspectable state for tests
|
||||
var _active_torso_variant: String = "full"
|
||||
@@ -218,6 +221,13 @@ func get_skin_tone_texture_name(index: int) -> String:
|
||||
return SKIN_TONES[clampi(index, 0, SKIN_TONES.size() - 1)]["tex"]
|
||||
|
||||
|
||||
## Return the overhead anchor Marker3D (#712). Null if skeleton not loaded.
|
||||
## Anchor point for floating UI elements: status indicators, thought bubbles,
|
||||
## alert markers, speech icons. Positioned ~0.3m above the Head bone.
|
||||
func get_overhead_anchor() -> Marker3D:
|
||||
return _overhead_anchor
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal — teardown
|
||||
# =============================================================================
|
||||
@@ -234,6 +244,13 @@ func _clear() -> void:
|
||||
node.free()
|
||||
_outline_nodes.clear()
|
||||
|
||||
# #712: free overhead anchor before general bone attachments
|
||||
if is_instance_valid(_overhead_attachment) and _overhead_attachment.get_parent():
|
||||
_overhead_attachment.get_parent().remove_child(_overhead_attachment)
|
||||
_overhead_attachment.free()
|
||||
_overhead_attachment = null
|
||||
_overhead_anchor = null
|
||||
|
||||
for att in _bone_attachments:
|
||||
if is_instance_valid(att) and att.get_parent():
|
||||
att.get_parent().remove_child(att)
|
||||
@@ -291,6 +308,7 @@ func _load_skeleton() -> void:
|
||||
for m in armature_meshes:
|
||||
print(" armature mesh: ", m.name, " visible=", m.visible)
|
||||
_validate_slot_bones()
|
||||
_create_overhead_anchor()
|
||||
|
||||
|
||||
## Warn on any SLOT_TO_BONE entry that doesn't exist in the loaded skeleton.
|
||||
@@ -302,6 +320,26 @@ func _validate_slot_bones() -> void:
|
||||
push_warning("CharacterVisual: SLOT_TO_BONE['%s'] = '%s' — bone not found in skeleton" % [slot, bone_name])
|
||||
|
||||
|
||||
## #712: Create a Marker3D anchored ~0.3m above the Head bone via BoneAttachment3D.
|
||||
## Anchor point for floating UI elements (status indicators, thought bubbles, etc.).
|
||||
func _create_overhead_anchor() -> void:
|
||||
if _skeleton == null:
|
||||
return
|
||||
var bone_idx := _skeleton.find_bone("Head")
|
||||
if bone_idx == -1:
|
||||
push_warning("CharacterVisual: Head bone not found — overhead anchor not created")
|
||||
return
|
||||
_overhead_attachment = BoneAttachment3D.new()
|
||||
_overhead_attachment.bone_name = "Head"
|
||||
_overhead_attachment.name = "OverheadAttachment"
|
||||
_skeleton.add_child(_overhead_attachment)
|
||||
|
||||
_overhead_anchor = Marker3D.new()
|
||||
_overhead_anchor.name = "OverheadAnchor"
|
||||
_overhead_anchor.position = Vector3(0, 0.3, 0)
|
||||
_overhead_attachment.add_child(_overhead_anchor)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal — body segments (D-160)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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("hair_highlight_tint" in desc).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()
|
||||
@@ -669,6 +669,11 @@ func _build_hair_color_dock() -> Control:
|
||||
func(c): _on_hair_primary_changed(c))
|
||||
row.add_child(_hair_primary_swatch)
|
||||
|
||||
# #719 (Option B): highlight is auto-derived from primary — display-only, not editable.
|
||||
_hair_highlight_swatch = _make_display_swatch(
|
||||
_derive_hair_highlight(_descriptor.hair_tint), "Highlight")
|
||||
row.add_child(_hair_highlight_swatch)
|
||||
|
||||
_eyebrow_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Brows ●",
|
||||
func(c): _on_eyebrow_tint_changed(c))
|
||||
row.add_child(_eyebrow_tint_swatch)
|
||||
@@ -1840,45 +1845,6 @@ func _apply_search_filter(grid: GridContainer, query: String) -> void:
|
||||
child.visible = lower_q.is_empty() or (child as Button).text.to_lower().contains(lower_q)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Asset scanning helpers
|
||||
# =============================================================================
|
||||
|
||||
## Scan a directory for subdirectory names (item_id directories like clothing/coveralls_basic/).
|
||||
## Returns fallback list if the directory is absent or empty.
|
||||
## TODO: replace with manifest JSON for export builds (DirAccess won't list res:// in PCK).
|
||||
static func _scan_subdirs(dir_path: String, fallback: Array) -> Array:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
return fallback
|
||||
var ids: Array = []
|
||||
dir.list_dir_begin()
|
||||
var name := dir.get_next()
|
||||
while name != "":
|
||||
if dir.current_is_dir() and not name.begins_with("."):
|
||||
ids.append(name)
|
||||
name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
return ids if not ids.is_empty() else fallback
|
||||
|
||||
|
||||
## Scan a directory for .glb asset IDs. Returns fallback list if directory absent.
|
||||
## TODO: replace with manifest JSON for export builds (DirAccess won't list res:// in PCK).
|
||||
static func _scan_asset_ids(dir_path: String, ext: String, fallback: Array) -> Array:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
return fallback
|
||||
var ids: Array = []
|
||||
dir.list_dir_begin()
|
||||
var name := dir.get_next()
|
||||
while name != "":
|
||||
if not dir.current_is_dir() and name.ends_with(ext):
|
||||
ids.append(name.get_basename())
|
||||
name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
return ids if not ids.is_empty() else fallback
|
||||
|
||||
|
||||
func _get_clothing_ids_for_slot(slot: String) -> Array:
|
||||
# Clothing items from manifest — slot assignment is explicit, not prefix-based.
|
||||
var clothing_data: Variant = _manifest.get("clothing", {})
|
||||
|
||||
+6
-1
@@ -1,6 +1,7 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
[gd_scene load_steps=3 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/hud.gd" id="1_hud"]
|
||||
[ext_resource type="PackedScene" path="res://ui/star_map.tscn" id="2_starmap"]
|
||||
|
||||
[node name="HUD" type="Control"]
|
||||
layout_mode = 3
|
||||
@@ -36,3 +37,7 @@ text = "Mode: Baseline"
|
||||
[node name="TimeLabel" type="Label" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Time: 08:00"
|
||||
|
||||
; #674: Star map insert — concentric hop-ring view, hidden by default, toggled via keybind
|
||||
[node name="StarMap" parent="." instance=ExtResource("2_starmap")]
|
||||
visible = false
|
||||
|
||||
@@ -62,7 +62,7 @@ func _show_character_creation() -> void:
|
||||
_char_creation.creation_cancelled.connect(_on_creation_cancelled)
|
||||
|
||||
|
||||
func _on_creation_confirmed(descriptor: CharacterVisualDescriptor) -> void:
|
||||
func _on_creation_confirmed(descriptor) -> void:
|
||||
if _char_creation != null and is_instance_valid(_char_creation):
|
||||
_char_creation.queue_free()
|
||||
_char_creation = null
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
class_name StarMapRenderer
|
||||
extends Control
|
||||
|
||||
## Star map — concentric hop-ring view of the Settled Reach gate network (#674).
|
||||
## Renders 301 systems as dots on concentric rings (hop distance from Gateway).
|
||||
## Sector-colored: core (white-gold), north (blue), south (orange), east (green), west (tan).
|
||||
##
|
||||
## Data source: res://data/star_map_data.json (generated from star-map.json + systems.db).
|
||||
## Regenerate with: tooling/generate-star-map-data.py
|
||||
##
|
||||
## D-013: Diegetic neural insert overlay. Accessible from the insert UI.
|
||||
## Parent epic: #51 (Diegetic Insert/Minimap), ticket #674.
|
||||
|
||||
const DATA_PATH := "res://data/star_map_data.json"
|
||||
|
||||
# Layout
|
||||
const MAP_CENTER_FRACTION := Vector2(0.5, 0.5) # center of control
|
||||
const MIN_RING_RADIUS: float = 30.0 # innermost ring (hop 0 = gateway dot only)
|
||||
const RING_SPACING: float = 22.0 # pixels between hop rings
|
||||
const MAX_HOP_RINGS: int = 24 # max hop distance we render rings for
|
||||
|
||||
# Dot sizing
|
||||
const DOT_RADIUS_HUB: float = 4.5
|
||||
const DOT_RADIUS_JUNCTION: float = 3.5
|
||||
const DOT_RADIUS_DEFAULT: float = 2.5
|
||||
const DOT_RADIUS_DEAD_END: float = 2.0
|
||||
const GATEWAY_RADIUS: float = 6.0
|
||||
|
||||
# Selection
|
||||
const SELECTION_RING_RADIUS: float = 8.0
|
||||
const HIT_RADIUS: float = 10.0 # click tolerance
|
||||
|
||||
# Edge rendering
|
||||
const EDGE_WIDTH: float = 0.4
|
||||
const EDGE_ALPHA: float = 0.12
|
||||
const EDGE_SELECTED_ALPHA: float = 0.5
|
||||
|
||||
# Colors — sector palette from wireframe
|
||||
const COLOR_BG: Color = Color("#0d1117")
|
||||
const COLOR_RING: Color = Color("#1a2030")
|
||||
const COLOR_RING_MAJOR: Color = Color("#222a3a")
|
||||
const COLOR_GATEWAY: Color = Color("#f0d060")
|
||||
const COLOR_SELECTION: Color = Color("#f0d060")
|
||||
const COLOR_TEXT: Color = Color("#c8d0e0")
|
||||
const COLOR_TEXT_DIM: Color = Color("#667788")
|
||||
const COLOR_INFO_BG: Color = Color(0.05, 0.08, 0.14, 0.92)
|
||||
|
||||
const SECTOR_COLORS: Dictionary = {
|
||||
"core": Color("#c8d0e0"),
|
||||
"north_reach": Color("#4488aa"),
|
||||
"south_reach": Color("#aa6644"),
|
||||
"east_reach": Color("#44aa66"),
|
||||
"west_reach": Color("#aa8844"),
|
||||
"deep_frontier": Color("#556677"),
|
||||
"unknown": Color("#445566"),
|
||||
}
|
||||
|
||||
const SECTOR_LABELS: Dictionary = {
|
||||
"north_reach": "NORTH REACH",
|
||||
"south_reach": "SOUTH REACH",
|
||||
"east_reach": "EAST REACH",
|
||||
"west_reach": "WEST REACH",
|
||||
}
|
||||
|
||||
# Quadrant angles for sector placement (radians, 0 = right, counterclockwise)
|
||||
# North = top (-PI/2), East = right (0), South = bottom (PI/2), West = left (PI)
|
||||
const SECTOR_ANGLE_CENTER: Dictionary = {
|
||||
"north_reach": -PI / 2.0,
|
||||
"east_reach": 0.0,
|
||||
"south_reach": PI / 2.0,
|
||||
"west_reach": PI,
|
||||
}
|
||||
const SECTOR_ANGLE_SPREAD: float = PI / 2.5 # each sector occupies ~72° of arc
|
||||
const CORE_ANGLE_SPREAD: float = TAU # core systems spread full circle
|
||||
const DEEP_FRONTIER_ANGLE_SPREAD: float = TAU # deep frontier wraps entire outer edge
|
||||
|
||||
# Pan/zoom
|
||||
const ZOOM_MIN: float = 0.3
|
||||
const ZOOM_MAX: float = 3.0
|
||||
const ZOOM_STEP: float = 0.15
|
||||
|
||||
# Internal state
|
||||
var _nodes: Array = []
|
||||
var _edges: Array = []
|
||||
var _node_positions: Dictionary = {} # system_id -> Vector2 (screen coords relative to map center)
|
||||
var _node_lookup: Dictionary = {} # system_id -> node dict
|
||||
var _selected_system: String = ""
|
||||
var _hovered_system: String = ""
|
||||
|
||||
var _zoom: float = 1.0
|
||||
var _pan_offset: Vector2 = Vector2.ZERO
|
||||
var _is_panning: bool = false
|
||||
var _pan_start: Vector2 = Vector2.ZERO
|
||||
var _pan_start_offset: Vector2 = Vector2.ZERO
|
||||
|
||||
var _data_loaded: bool = false
|
||||
var _insert_active: bool = true
|
||||
var _show_edges: bool = false # toggle edge display
|
||||
var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_load_data()
|
||||
if _data_loaded:
|
||||
_compute_layout()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
if _dirty and _data_loaded:
|
||||
queue_redraw()
|
||||
_dirty = false
|
||||
|
||||
|
||||
## Called from main.gd when insert state changes.
|
||||
## Only force-hides when insert is inactive. Does NOT auto-show — star map is
|
||||
## modal (player opens via toggle_visible()), not always-on like the minimap.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
if not active:
|
||||
visible = false
|
||||
|
||||
|
||||
## Toggle visibility (e.g., from a keybind or button).
|
||||
func toggle_visible() -> void:
|
||||
visible = not visible
|
||||
if visible:
|
||||
_dirty = true
|
||||
|
||||
|
||||
## Return the currently selected system data, or empty dict.
|
||||
func get_selected_system() -> Dictionary:
|
||||
return _node_lookup.get(_selected_system, {})
|
||||
|
||||
|
||||
## Return total system count.
|
||||
func get_system_count() -> int:
|
||||
return _nodes.size()
|
||||
|
||||
|
||||
## Return total edge count.
|
||||
func get_edge_count() -> int:
|
||||
return _edges.size()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data loading
|
||||
# =============================================================================
|
||||
|
||||
func _load_data() -> void:
|
||||
if not FileAccess.file_exists(DATA_PATH):
|
||||
push_warning("StarMapRenderer: data file not found at %s" % DATA_PATH)
|
||||
return
|
||||
var file := FileAccess.open(DATA_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("StarMapRenderer: could not open %s" % DATA_PATH)
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
push_warning("StarMapRenderer: invalid JSON in %s" % DATA_PATH)
|
||||
return
|
||||
var data := parsed as Dictionary
|
||||
_nodes = data.get("nodes", [])
|
||||
_edges = data.get("edges", [])
|
||||
for node: Dictionary in _nodes:
|
||||
_node_lookup[node.get("system_id", "")] = node
|
||||
_data_loaded = true
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Layout — place systems on concentric rings by hop distance
|
||||
# =============================================================================
|
||||
|
||||
func _compute_layout() -> void:
|
||||
_node_positions.clear()
|
||||
|
||||
# Group nodes by hop distance
|
||||
var rings: Dictionary = {} # hop -> Array of nodes
|
||||
for node: Dictionary in _nodes:
|
||||
var hop: int = int(node.get("hop_distance", 0))
|
||||
if not rings.has(hop):
|
||||
rings[hop] = []
|
||||
rings[hop].append(node)
|
||||
|
||||
# Place each ring
|
||||
for hop: int in rings:
|
||||
var ring_nodes: Array = rings[hop]
|
||||
var radius: float = MIN_RING_RADIUS + hop * RING_SPACING
|
||||
|
||||
if hop == 0:
|
||||
# Gateway at center
|
||||
for node: Dictionary in ring_nodes:
|
||||
_node_positions[node["system_id"]] = Vector2.ZERO
|
||||
continue
|
||||
|
||||
# Sort nodes within ring by sector for angular grouping
|
||||
ring_nodes.sort_custom(_sort_by_sector_angle)
|
||||
|
||||
# Distribute nodes within their sector's angular range
|
||||
var sector_groups: Dictionary = {}
|
||||
for node: Dictionary in ring_nodes:
|
||||
var sector: String = node.get("geographic_sector", "unknown")
|
||||
if not sector_groups.has(sector):
|
||||
sector_groups[sector] = []
|
||||
sector_groups[sector].append(node)
|
||||
|
||||
for sector: String in sector_groups:
|
||||
var group: Array = sector_groups[sector]
|
||||
var count: int = group.size()
|
||||
|
||||
var center_angle: float
|
||||
var spread: float
|
||||
if sector == "core":
|
||||
center_angle = 0.0
|
||||
spread = CORE_ANGLE_SPREAD
|
||||
elif sector == "deep_frontier":
|
||||
center_angle = 0.0
|
||||
spread = DEEP_FRONTIER_ANGLE_SPREAD
|
||||
elif SECTOR_ANGLE_CENTER.has(sector):
|
||||
center_angle = SECTOR_ANGLE_CENTER[sector]
|
||||
spread = SECTOR_ANGLE_SPREAD
|
||||
else:
|
||||
center_angle = 0.0
|
||||
spread = TAU
|
||||
|
||||
# Distribute evenly within sector arc, with deterministic offset per system
|
||||
for i: int in range(count):
|
||||
var node: Dictionary = group[i]
|
||||
var t: float
|
||||
if count == 1:
|
||||
t = 0.0
|
||||
else:
|
||||
t = float(i) / float(count) - 0.5 # -0.5 to +0.5
|
||||
var angle: float = center_angle + t * spread
|
||||
# Add small per-node jitter based on system_id hash for visual variety
|
||||
var jitter: float = _system_hash(node["system_id"]) * 0.08
|
||||
angle += jitter
|
||||
# Slight radial variation to avoid perfect circles
|
||||
var r_var: float = radius + _system_hash(node["system_id"] + "r") * RING_SPACING * 0.3
|
||||
_node_positions[node["system_id"]] = Vector2(cos(angle), sin(angle)) * r_var
|
||||
|
||||
|
||||
func _sort_by_sector_angle(a: Dictionary, b: Dictionary) -> bool:
|
||||
var sa: float = _sector_sort_key(a)
|
||||
var sb: float = _sector_sort_key(b)
|
||||
if sa != sb:
|
||||
return sa < sb
|
||||
return a.get("system_id", "") < b.get("system_id", "")
|
||||
|
||||
|
||||
func _sector_sort_key(node: Dictionary) -> float:
|
||||
var sector: String = node.get("geographic_sector", "unknown")
|
||||
match sector:
|
||||
"core": return 0.0
|
||||
"north_reach": return 1.0
|
||||
"east_reach": return 2.0
|
||||
"south_reach": return 3.0
|
||||
"west_reach": return 4.0
|
||||
"deep_frontier": return 5.0
|
||||
_: return 6.0
|
||||
|
||||
|
||||
## Deterministic float in [-1, 1] from a string key.
|
||||
func _system_hash(key: String) -> float:
|
||||
var h: int = key.hash() & 0x7FFFFFFF # mask to 31-bit positive range
|
||||
return float(h) / 2147483647.0 * 2.0 - 1.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Drawing
|
||||
# =============================================================================
|
||||
|
||||
func _draw() -> void:
|
||||
if not _data_loaded:
|
||||
return
|
||||
|
||||
var sz: Vector2 = get_rect().size
|
||||
var center: Vector2 = sz * MAP_CENTER_FRACTION + _pan_offset
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, sz), COLOR_BG)
|
||||
|
||||
# Hop rings (concentric circles)
|
||||
_draw_rings(center)
|
||||
|
||||
# Sector labels
|
||||
_draw_sector_labels(center)
|
||||
|
||||
# Edges (gate connections)
|
||||
if _show_edges or _selected_system != "":
|
||||
_draw_edges(center)
|
||||
|
||||
# System dots
|
||||
_draw_systems(center)
|
||||
|
||||
# Selection highlight
|
||||
if _selected_system != "":
|
||||
_draw_selection(center)
|
||||
|
||||
# Info panel for selected system
|
||||
if _selected_system != "":
|
||||
_draw_info_panel(sz)
|
||||
|
||||
# Title
|
||||
_draw_title()
|
||||
|
||||
|
||||
func _draw_rings(center: Vector2) -> void:
|
||||
for hop: int in range(MAX_HOP_RINGS + 1):
|
||||
var radius: float = (MIN_RING_RADIUS + hop * RING_SPACING) * _zoom
|
||||
if radius < 1.0 or radius > 2000.0:
|
||||
continue
|
||||
var color: Color = COLOR_RING_MAJOR if hop % 5 == 0 else COLOR_RING
|
||||
draw_arc(center, radius, 0.0, TAU, 64, color, 0.5 if hop % 5 == 0 else 0.3, true)
|
||||
|
||||
|
||||
func _draw_sector_labels(center: Vector2) -> void:
|
||||
var label_radius: float = (MIN_RING_RADIUS + 12 * RING_SPACING) * _zoom
|
||||
for sector: String in SECTOR_LABELS:
|
||||
var angle: float = SECTOR_ANGLE_CENTER.get(sector, 0.0)
|
||||
var pos: Vector2 = center + Vector2(cos(angle), sin(angle)) * label_radius
|
||||
var label: String = SECTOR_LABELS[sector]
|
||||
var color: Color = SECTOR_COLORS.get(sector, COLOR_TEXT_DIM)
|
||||
var font := get_theme_default_font()
|
||||
var font_size: int = 10
|
||||
var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
|
||||
draw_string(font, pos - text_size / 2.0, label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color)
|
||||
|
||||
|
||||
func _draw_systems(center: Vector2) -> void:
|
||||
for node: Dictionary in _nodes:
|
||||
var sid: String = node.get("system_id", "")
|
||||
if not _node_positions.has(sid):
|
||||
continue
|
||||
var pos: Vector2 = center + _node_positions[sid] * _zoom
|
||||
var sector: String = node.get("geographic_sector", "unknown")
|
||||
var topology: String = node.get("gate_topology", "")
|
||||
var color: Color = SECTOR_COLORS.get(sector, COLOR_TEXT_DIM)
|
||||
var radius: float = _dot_radius(topology)
|
||||
|
||||
# Gateway gets special treatment
|
||||
if node.get("is_gateway", false):
|
||||
color = COLOR_GATEWAY
|
||||
radius = GATEWAY_RADIUS
|
||||
|
||||
# Dim deep frontier slightly
|
||||
if sector == "deep_frontier":
|
||||
color.a = 0.7
|
||||
|
||||
# Hover highlight
|
||||
if sid == _hovered_system and sid != _selected_system:
|
||||
draw_arc(pos, radius + 3.0, 0.0, TAU, 16, Color(color.r, color.g, color.b, 0.4), 1.0, true)
|
||||
|
||||
draw_circle(pos, radius, color)
|
||||
|
||||
|
||||
func _draw_edges(center: Vector2) -> void:
|
||||
for edge: Array in _edges:
|
||||
if edge.size() < 2:
|
||||
continue
|
||||
var sid_a: String = edge[0]
|
||||
var sid_b: String = edge[1]
|
||||
if not _node_positions.has(sid_a) or not _node_positions.has(sid_b):
|
||||
continue
|
||||
var pos_a: Vector2 = center + _node_positions[sid_a] * _zoom
|
||||
var pos_b: Vector2 = center + _node_positions[sid_b] * _zoom
|
||||
|
||||
var alpha: float = EDGE_ALPHA
|
||||
# Highlight edges connected to selected system
|
||||
if _selected_system == sid_a or _selected_system == sid_b:
|
||||
alpha = EDGE_SELECTED_ALPHA
|
||||
|
||||
var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, alpha)
|
||||
draw_line(pos_a, pos_b, color, EDGE_WIDTH, true)
|
||||
|
||||
|
||||
func _draw_selection(center: Vector2) -> void:
|
||||
if not _node_positions.has(_selected_system):
|
||||
return
|
||||
var pos: Vector2 = center + _node_positions[_selected_system] * _zoom
|
||||
draw_arc(pos, SELECTION_RING_RADIUS, 0.0, TAU, 24, COLOR_SELECTION, 1.2, true)
|
||||
|
||||
# Draw label next to selection
|
||||
var node: Dictionary = _node_lookup.get(_selected_system, {})
|
||||
var label: String = node.get("proper_name", _selected_system)
|
||||
if label.is_empty():
|
||||
label = _selected_system
|
||||
var font := get_theme_default_font()
|
||||
draw_string(font, pos + Vector2(SELECTION_RING_RADIUS + 4, 4), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, COLOR_SELECTION)
|
||||
|
||||
|
||||
func _draw_info_panel(sz: Vector2) -> void:
|
||||
var node: Dictionary = _node_lookup.get(_selected_system, {})
|
||||
if node.is_empty():
|
||||
return
|
||||
|
||||
var panel_w: float = 220.0
|
||||
var panel_h: float = 130.0
|
||||
var margin: float = 16.0
|
||||
var panel_pos := Vector2(sz.x - panel_w - margin, margin)
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), COLOR_INFO_BG)
|
||||
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.15), false, 1.0)
|
||||
|
||||
var font := get_theme_default_font()
|
||||
var y: float = panel_pos.y + 20.0
|
||||
var x: float = panel_pos.x + 12.0
|
||||
var line_h: float = 18.0
|
||||
|
||||
# System name
|
||||
var name: String = node.get("proper_name", "")
|
||||
if name.is_empty():
|
||||
name = node.get("system_id", "Unknown")
|
||||
draw_string(font, Vector2(x, y), name, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, COLOR_TEXT)
|
||||
y += line_h
|
||||
|
||||
# System ID
|
||||
draw_string(font, Vector2(x, y), node.get("system_id", ""), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM)
|
||||
y += line_h
|
||||
|
||||
# Sector
|
||||
var sector: String = node.get("geographic_sector", "").replace("_", " ").capitalize()
|
||||
var sector_color: Color = SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM)
|
||||
draw_string(font, Vector2(x, y), "Sector: " + sector, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, sector_color)
|
||||
y += line_h
|
||||
|
||||
# Hop distance
|
||||
draw_string(font, Vector2(x, y), "Hop distance: " + str(node.get("hop_distance", "?")), HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM)
|
||||
y += line_h
|
||||
|
||||
# Topology
|
||||
var topo: String = node.get("gate_topology", "").replace("_", " ").capitalize()
|
||||
draw_string(font, Vector2(x, y), "Topology: " + topo, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM)
|
||||
y += line_h
|
||||
|
||||
# Gate connections
|
||||
draw_string(font, Vector2(x, y), "Gates: " + str(node.get("aperture_count", 0)) + " apertures", HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM)
|
||||
|
||||
|
||||
func _draw_title() -> void:
|
||||
var font := get_theme_default_font()
|
||||
draw_string(font, Vector2(16, 28), "THE REACH — NAVIGATOR", HORIZONTAL_ALIGNMENT_LEFT, -1, 16, COLOR_TEXT)
|
||||
draw_string(font, Vector2(16, 44), "Concord Assembly Gate Network — %d Systems" % _nodes.size(), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM)
|
||||
|
||||
|
||||
func _dot_radius(topology: String) -> float:
|
||||
match topology:
|
||||
"hub": return DOT_RADIUS_HUB
|
||||
"junction": return DOT_RADIUS_JUNCTION
|
||||
"dead_end": return DOT_RADIUS_DEAD_END
|
||||
_: return DOT_RADIUS_DEFAULT
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Input — selection, pan, zoom
|
||||
# =============================================================================
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
if mb.pressed:
|
||||
match mb.button_index:
|
||||
MOUSE_BUTTON_LEFT:
|
||||
_handle_click(mb.position)
|
||||
MOUSE_BUTTON_MIDDLE:
|
||||
_is_panning = true
|
||||
_pan_start = mb.position
|
||||
_pan_start_offset = _pan_offset
|
||||
MOUSE_BUTTON_WHEEL_UP:
|
||||
var old_zoom := _zoom
|
||||
_zoom = clampf(_zoom + ZOOM_STEP, ZOOM_MIN, ZOOM_MAX)
|
||||
if _zoom != old_zoom:
|
||||
_dirty = true
|
||||
MOUSE_BUTTON_WHEEL_DOWN:
|
||||
var old_zoom := _zoom
|
||||
_zoom = clampf(_zoom - ZOOM_STEP, ZOOM_MIN, ZOOM_MAX)
|
||||
if _zoom != old_zoom:
|
||||
_dirty = true
|
||||
else:
|
||||
if mb.button_index == MOUSE_BUTTON_MIDDLE:
|
||||
_is_panning = false
|
||||
|
||||
elif event is InputEventMouseMotion:
|
||||
var mm := event as InputEventMouseMotion
|
||||
if _is_panning:
|
||||
_pan_offset = _pan_start_offset + (mm.position - _pan_start)
|
||||
_dirty = true
|
||||
else:
|
||||
_update_hover(mm.position)
|
||||
|
||||
|
||||
## Find the system_id of the nearest node to screen position, or "" if none within HIT_RADIUS.
|
||||
func _find_nearest_system(pos: Vector2) -> String:
|
||||
var sz: Vector2 = get_rect().size
|
||||
var center: Vector2 = sz * MAP_CENTER_FRACTION + _pan_offset
|
||||
var best_dist: float = HIT_RADIUS
|
||||
var best_sid: String = ""
|
||||
for node: Dictionary in _nodes:
|
||||
var sid: String = node.get("system_id", "")
|
||||
if not _node_positions.has(sid):
|
||||
continue
|
||||
var node_pos: Vector2 = center + _node_positions[sid] * _zoom
|
||||
var dist: float = pos.distance_to(node_pos)
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_sid = sid
|
||||
return best_sid
|
||||
|
||||
|
||||
func _handle_click(pos: Vector2) -> void:
|
||||
var nearest := _find_nearest_system(pos)
|
||||
if nearest != "":
|
||||
_selected_system = nearest
|
||||
_show_edges = true
|
||||
else:
|
||||
_selected_system = ""
|
||||
_show_edges = false
|
||||
_dirty = true
|
||||
|
||||
|
||||
func _update_hover(pos: Vector2) -> void:
|
||||
var nearest := _find_nearest_system(pos)
|
||||
if nearest != _hovered_system:
|
||||
_hovered_system = nearest
|
||||
_dirty = true
|
||||
@@ -0,0 +1,18 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/star_map.gd" id="1_starmap"]
|
||||
|
||||
; #674: Star map insert module — concentric hop-ring view of the Settled Reach gate network.
|
||||
; Sector-colored, interactive selection, pan/zoom. Accessible from the insert UI.
|
||||
; Data source: res://data/star_map_data.json (enriched from star-map.json + systems.db).
|
||||
; Positioned as full-size overlay. Toggle visibility via set_insert_active() or toggle_visible().
|
||||
|
||||
[node name="StarMapRenderer" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 1
|
||||
script = ExtResource("1_starmap")
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate client/assets/characters/manifest.json from the asset directories.
|
||||
|
||||
Run this whenever artists add new assets so the manifest stays in sync:
|
||||
tooling/generate-character-manifest
|
||||
|
||||
The manifest is the single source of truth for CharacterCreation asset IDs.
|
||||
DirAccess.open() cannot enumerate res:// paths in exported PCK builds (#720).
|
||||
|
||||
Output: client/assets/characters/manifest.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ASSETS_ROOT = os.path.join(os.path.dirname(__file__), "..", "client", "assets", "characters")
|
||||
MANIFEST_PATH = os.path.join(ASSETS_ROOT, "manifest.json")
|
||||
|
||||
|
||||
def scan_glb_ids(dir_path: str) -> list[str]:
|
||||
"""Return sorted list of .glb basenames (without extension) in dir_path."""
|
||||
if not os.path.isdir(dir_path):
|
||||
return []
|
||||
ids = sorted(
|
||||
os.path.splitext(f)[0]
|
||||
for f in os.listdir(dir_path)
|
||||
if f.endswith(".glb") and not f.startswith(".")
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
def scan_subdirs(dir_path: str) -> list[str]:
|
||||
"""Return sorted list of subdirectory names in dir_path."""
|
||||
if not os.path.isdir(dir_path):
|
||||
return []
|
||||
return sorted(
|
||||
d for d in os.listdir(dir_path)
|
||||
if os.path.isdir(os.path.join(dir_path, d)) and not d.startswith(".")
|
||||
)
|
||||
|
||||
|
||||
def infer_clothing_slot(item_id: str) -> str:
|
||||
"""Infer clothing slot from item_id by convention."""
|
||||
id_lower = item_id.lower()
|
||||
if any(k in id_lower for k in ("boot", "shoe", "sandal", "slipper")):
|
||||
return "feet"
|
||||
if any(k in id_lower for k in ("pant", "trouser", "skirt", "short")):
|
||||
return "legs"
|
||||
if any(k in id_lower for k in ("glove", "gauntlet")):
|
||||
return "hands"
|
||||
# Default: torso (jacket, tunic, shirt, coveralls, vest, etc.)
|
||||
return "torso"
|
||||
|
||||
|
||||
def build_manifest() -> dict:
|
||||
# Body types: subdirs under bodies/
|
||||
body_types = scan_subdirs(os.path.join(ASSETS_ROOT, "bodies"))
|
||||
|
||||
# Heads: .glb files in heads/templates/
|
||||
heads = scan_glb_ids(os.path.join(ASSETS_ROOT, "heads", "templates"))
|
||||
|
||||
# Hair: .glb files in hair/
|
||||
hair = scan_glb_ids(os.path.join(ASSETS_ROOT, "hair"))
|
||||
|
||||
# Facial hair: .glb files in facial_hair/
|
||||
facial_hair = scan_glb_ids(os.path.join(ASSETS_ROOT, "facial_hair"))
|
||||
|
||||
# Eyebrows: .glb files in eyebrows/
|
||||
eyebrows = scan_glb_ids(os.path.join(ASSETS_ROOT, "eyebrows"))
|
||||
|
||||
# Clothing: subdirs under clothing/, each assigned a slot
|
||||
clothing_items = scan_subdirs(os.path.join(ASSETS_ROOT, "clothing"))
|
||||
clothing: dict = {}
|
||||
for item_id in clothing_items:
|
||||
clothing[item_id] = {"slot": infer_clothing_slot(item_id)}
|
||||
|
||||
# Accessories: no directory yet — leave empty
|
||||
accessories: list = []
|
||||
acc_dir = os.path.join(ASSETS_ROOT, "accessories")
|
||||
if os.path.isdir(acc_dir):
|
||||
accessories = scan_glb_ids(acc_dir)
|
||||
|
||||
return {
|
||||
"body_types": body_types,
|
||||
"heads": heads,
|
||||
"hair": hair,
|
||||
"facial_hair": facial_hair,
|
||||
"eyebrows": eyebrows,
|
||||
"clothing": clothing,
|
||||
"accessories": accessories,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
manifest = build_manifest()
|
||||
output = json.dumps(manifest, indent=2) + "\n"
|
||||
with open(MANIFEST_PATH, "w", encoding="utf-8") as f:
|
||||
f.write(output)
|
||||
print(f"Written: {MANIFEST_PATH}")
|
||||
print(f" body_types : {len(manifest['body_types'])}")
|
||||
print(f" heads : {len(manifest['heads'])}")
|
||||
print(f" hair : {len(manifest['hair'])}")
|
||||
print(f" facial_hair: {len(manifest['facial_hair'])}")
|
||||
print(f" eyebrows : {len(manifest['eyebrows'])}")
|
||||
print(f" clothing : {len(manifest['clothing'])}")
|
||||
print(f" accessories: {len(manifest['accessories'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate client/data/star_map_data.json from star-map.json + systems.db.
|
||||
|
||||
Run from any directory — paths are resolved relative to this script's location:
|
||||
python3 tooling/generate-star-map-data.py
|
||||
python3 tooling/generate-star-map-data.py --check # exit 1 if committed JSON is stale
|
||||
|
||||
Sources:
|
||||
docs/design/star-map.json — graph topology (nodes + edges)
|
||||
server/server/data/systems.db — proper names, geographic sectors
|
||||
|
||||
Output:
|
||||
client/data/star_map_data.json — self-contained client data for the star map UI
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Resolve project root from this script's location: tooling/ is one level below root.
|
||||
# Works regardless of cwd — no fragile relative path guessing.
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_PROJECT_ROOT = os.path.dirname(_SCRIPT_DIR)
|
||||
|
||||
# Worktree layout: settled-reach/{client,server,main}/
|
||||
# This script lives in client/tooling/, so _PROJECT_ROOT = client/.
|
||||
# The parent of _PROJECT_ROOT is the worktree parent where sibling dirs live.
|
||||
_WORKTREE_PARENT = os.path.dirname(_PROJECT_ROOT)
|
||||
|
||||
STAR_MAP_PATH = os.path.join(_PROJECT_ROOT, "docs", "design", "star-map.json")
|
||||
SYSTEMS_DB_PATH = os.path.join(_WORKTREE_PARENT, "server", "server", "data", "systems.db")
|
||||
OUTPUT_PATH = os.path.join(_PROJECT_ROOT, "client", "data", "star_map_data.json")
|
||||
|
||||
|
||||
def generate() -> dict:
|
||||
"""Generate the enriched star map data dict."""
|
||||
if not os.path.exists(STAR_MAP_PATH):
|
||||
print(f"ERROR: star-map.json not found at {STAR_MAP_PATH}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not os.path.exists(SYSTEMS_DB_PATH):
|
||||
print(f"ERROR: systems.db not found at {SYSTEMS_DB_PATH}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with open(STAR_MAP_PATH) as f:
|
||||
star_map = json.load(f)
|
||||
|
||||
conn = sqlite3.connect(SYSTEMS_DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT system_id, proper_name, geographic_sector, geographic_band "
|
||||
"FROM star_systems"
|
||||
)
|
||||
db_lookup = {row["system_id"]: dict(row) for row in cur.fetchall()}
|
||||
conn.close()
|
||||
|
||||
nodes = []
|
||||
for n in star_map["nodes"]:
|
||||
sid = n["system_id"]
|
||||
db = db_lookup.get(sid, {})
|
||||
entry = {
|
||||
"system_id": sid,
|
||||
"proper_name": db.get("proper_name", ""),
|
||||
"geographic_sector": db.get("geographic_sector", "unknown"),
|
||||
"geographic_band": db.get("geographic_band", ""),
|
||||
"gate_topology": n["gate_topology"],
|
||||
"aperture_count": n["aperture_count"],
|
||||
"gate_connections": n["gate_connections"],
|
||||
"hop_distance": n["hop_distance_from_gateway"],
|
||||
}
|
||||
if n.get("is_gateway"):
|
||||
entry["is_gateway"] = True
|
||||
nodes.append(entry)
|
||||
|
||||
nodes.sort(key=lambda x: (x["hop_distance"], x["system_id"]))
|
||||
|
||||
return {
|
||||
"_meta": {
|
||||
"generated_from": "star-map.json + systems.db",
|
||||
"system_count": len(nodes),
|
||||
"edge_count": len(star_map["edges"]),
|
||||
"note": "Client-side star map data. Regenerate with: tooling/generate-star-map-data.py",
|
||||
},
|
||||
"nodes": nodes,
|
||||
"edges": star_map["edges"],
|
||||
}
|
||||
|
||||
|
||||
def write_output(data: dict, path: str) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_mode = "--check" in sys.argv
|
||||
|
||||
data = generate()
|
||||
|
||||
if check_mode:
|
||||
# Generate to temp file and compare against committed JSON
|
||||
if not os.path.exists(OUTPUT_PATH):
|
||||
print(f"STALE: {OUTPUT_PATH} does not exist — run without --check to generate")
|
||||
sys.exit(1)
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
|
||||
json.dump(data, tmp, indent=2, ensure_ascii=False)
|
||||
tmp.write("\n")
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
with open(tmp_path) as a, open(OUTPUT_PATH) as b:
|
||||
if a.read() != b.read():
|
||||
print(f"STALE: {OUTPUT_PATH} differs from generated output")
|
||||
print("Run: python3 tooling/generate-star-map-data.py")
|
||||
sys.exit(1)
|
||||
print(f"OK: {OUTPUT_PATH} is up to date")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
else:
|
||||
write_output(data, OUTPUT_PATH)
|
||||
print(f"Generated {OUTPUT_PATH}")
|
||||
print(f" Nodes: {data['_meta']['system_count']}, Edges: {data['_meta']['edge_count']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user