Compare commits
23
Commits
8d8da1b546
...
v0.1.10
@@ -6,13 +6,26 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.1.10] — 2026-02-19
|
||||
|
||||
### Added
|
||||
- `project.yaml` — technical project descriptor with version, architecture, simulation, and content model as the canonical version source of truth
|
||||
- Scratchpad: asset generation pipeline idea (registry, status tracking, prompt versioning, pre-sprint cohesion)
|
||||
- Scratchpad: remote terminal proxy idea for mobile monitoring of Claude Code permission prompts and interactive elements
|
||||
- `make perf-baseline` — full plugin stack tick benchmark (50 measured ticks, 5 warmup) capturing per-tick timing, entity counts, process RSS, and shadowcast benchmarks; outputs structured JSON to `tests/perf/baseline.json` with `--compare` mode for regression detection (>20% threshold, D-026 budget check)
|
||||
- Michroma font integration (#517) — Michroma-Regular.ttf as game font with +1px tracking FontVariation, global Theme with cyan-white (#E0F7FA) implant text color, IMPLANT_TEXT_COLOR/DIM/PULSE constants
|
||||
- Mouse-relative facing and movement (#526, D-054) — mouse position determines facing direction (client-side float), WASD remapped to cursor-relative (W=toward, S=away, A/D=strafe), SET_FACING action sends octant to server, smooth facing indicator rotation
|
||||
- Room reset client UX (#502) — amber reset_plate tile type, 0.15s screen flash on room reset, 'Reset Room' interaction verb
|
||||
- Auto-checklist progress tracking (#503) — ChecklistEvaluator parses room YAML and evaluates 7 condition types against GameState with latching, ChecklistOverlay renders progress in gauntlet mode only, 48 new tests
|
||||
|
||||
### Changed
|
||||
- `push-pr` skill now runs `/commit` first when uncommitted changes are detected
|
||||
- Insert open/close now sends explicit PauseSimulation/ResumeSimulation (#518, D-058) — replaces toggle-style pause with idempotent pair
|
||||
- Interaction list colors reference Constants.IMPLANT_TEXT_COLOR instead of hardcoded values
|
||||
- World radial menu uses theme font instead of ThemeDB.fallback_font
|
||||
|
||||
### Fixed
|
||||
- Bidirectional relationship check (#515) — Check 9 tested `target in npc_rels` which missed NPCs with no relationship entries; changed to `target in self.npcs`
|
||||
|
||||
## [v0.1.9] — 2026-02-18
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ 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 golden-diff golden-update \
|
||||
checklist-validate checklist-generate
|
||||
checklist-validate checklist-generate \
|
||||
perf-baseline
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -44,6 +45,7 @@ help:
|
||||
@echo " make golden-update Regenerate golden file and stage for commit"
|
||||
@echo " make checklist-validate Validate checklist YAML against schema"
|
||||
@echo " make checklist-generate Validate checklists + print condition summary"
|
||||
@echo " make perf-baseline Run performance benchmarks and save baseline"
|
||||
@echo ""
|
||||
@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)"
|
||||
@@ -280,6 +282,9 @@ checklist-validate:
|
||||
checklist-generate:
|
||||
@tooling/validate-checklist
|
||||
|
||||
perf-baseline:
|
||||
@tooling/perf-baseline
|
||||
|
||||
content-ron:
|
||||
cd tooling/content-converter && cargo build --release
|
||||
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Michroma Project Authors (https://github.com/googlefonts/Michroma-font)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,8 @@
|
||||
[gd_resource type="FontVariation" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="FontFile" path="res://assets/fonts/Michroma-Regular.ttf" id="1_base"]
|
||||
|
||||
[resource]
|
||||
base_font = ExtResource("1_base")
|
||||
spacing_glyph = 1
|
||||
spacing_space = 1
|
||||
@@ -0,0 +1,20 @@
|
||||
[gd_resource type="Theme" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="FontVariation" path="res://assets/fonts/michroma_ui.tres" id="1_font"]
|
||||
|
||||
[resource]
|
||||
default_font = ExtResource("1_font")
|
||||
default_font_size = 14
|
||||
|
||||
Label/colors/font_color = Color(0.878, 0.969, 0.98, 1)
|
||||
Label/font_sizes/font_size = 14
|
||||
|
||||
RichTextLabel/colors/default_color = Color(0.878, 0.969, 0.98, 1)
|
||||
RichTextLabel/font_sizes/normal_font_size = 14
|
||||
|
||||
Button/colors/font_color = Color(0.878, 0.969, 0.98, 1)
|
||||
Button/colors/font_hover_color = Color(0.91, 0.773, 0.278, 1)
|
||||
Button/font_sizes/font_size = 14
|
||||
|
||||
LineEdit/colors/font_color = Color(0.878, 0.969, 0.98, 1)
|
||||
LineEdit/font_sizes/font_size = 14
|
||||
@@ -24,6 +24,11 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||
|
||||
[gui]
|
||||
|
||||
theme/custom="res://assets/theme/game_theme.tres"
|
||||
theme/custom_font="res://assets/fonts/michroma_ui.tres"
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1920
|
||||
@@ -111,6 +116,11 @@ bug_report={
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
teleport_hub={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[rendering]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=19 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=20 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
@@ -17,7 +17,8 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"]
|
||||
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="18_bugreport"]
|
||||
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -136,6 +137,9 @@ layer = 20
|
||||
; #496: Gauntlet HUD — room timer + personal bests, hidden in non-gauntlet mode
|
||||
[node name="GauntletHUD" parent="UILayer" instance=ExtResource("17_gauntlet")]
|
||||
|
||||
; #503: Auto-checklist overlay — condition progress in gauntlet mode
|
||||
[node name="ChecklistOverlay" parent="UILayer" instance=ExtResource("18_checklist")]
|
||||
|
||||
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
|
||||
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
|
||||
|
||||
@@ -149,4 +153,4 @@ script = ExtResource("10_cursor")
|
||||
layer = 30
|
||||
|
||||
; #495: WRONG button (F12) — bug report capture dialog
|
||||
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("18_bugreport")]
|
||||
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
|
||||
|
||||
@@ -3,7 +3,11 @@ extends Node
|
||||
# Semantic actions — NO raw key codes cross the bridge
|
||||
# Movement uses hold-to-move (polled each frame in _process).
|
||||
# Discrete actions (interact, stance, etc.) use press events (_unhandled_input).
|
||||
# Composite diagonals: holding W+D simultaneously → northeast.
|
||||
#
|
||||
# D-054: Mouse-relative facing and movement.
|
||||
# Mouse position determines facing direction (client-side float).
|
||||
# WASD is relative to facing: W = toward cursor, S = away, A/D = strafe.
|
||||
# Server receives facing octant only — the full float stays client-side.
|
||||
#
|
||||
# Movement throttle: client-side rate limit per stance (D-053).
|
||||
# Sprint=5/s, Walk=2.5/s, Careful=1.7/s, Crouch=1.25/s.
|
||||
@@ -15,10 +19,18 @@ enum Action {
|
||||
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
||||
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
||||
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
||||
SET_FACING, # D-054: facing octant update (no movement)
|
||||
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
|
||||
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
|
||||
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
|
||||
var facing_angle: float = -PI / 2.0 # Default: North
|
||||
var facing_octant: String = "North" # Derived from facing_angle
|
||||
var _last_sent_octant: String = "North" # Track to avoid redundant sends
|
||||
|
||||
# Minimum milliseconds between movement commands, per stance.
|
||||
# Tuned so Walk feels like walking, Sprint feels fast but readable.
|
||||
const MOVE_INTERVAL_MS := {
|
||||
@@ -31,27 +43,44 @@ var _last_move_msec: int = 0
|
||||
|
||||
|
||||
# Hold-to-move: poll held direction keys each frame, throttled by stance.
|
||||
# D-054: WASD is now mouse-relative. W = toward cursor, A/D = strafe.
|
||||
# Server-side cooldown (D-053) is authoritative; this prevents client flooding.
|
||||
# D-064: movement suppressed during dialogue (walk-away handled by dialogue_box).
|
||||
func _process(_delta: float) -> void:
|
||||
# D-054: Update facing angle from mouse position every frame
|
||||
_update_facing_from_mouse()
|
||||
|
||||
if GameState.dialogue_active:
|
||||
return
|
||||
var dir := Vector2i.ZERO
|
||||
if Input.is_action_pressed("move_north"):
|
||||
dir.y -= 1
|
||||
if Input.is_action_pressed("move_south"):
|
||||
dir.y += 1
|
||||
if Input.is_action_pressed("move_east"):
|
||||
dir.x += 1
|
||||
if Input.is_action_pressed("move_west"):
|
||||
dir.x -= 1
|
||||
|
||||
if dir != Vector2i.ZERO:
|
||||
# D-054: Send facing octant to server when it changes (even without movement)
|
||||
if facing_octant != _last_sent_octant:
|
||||
_last_sent_octant = facing_octant
|
||||
input_queue.append({
|
||||
"action": Action.SET_FACING,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"facing": facing_octant},
|
||||
})
|
||||
|
||||
# Poll held WASD keys
|
||||
var raw_dir := Vector2i.ZERO
|
||||
if Input.is_action_pressed("move_north"):
|
||||
raw_dir.y -= 1
|
||||
if Input.is_action_pressed("move_south"):
|
||||
raw_dir.y += 1
|
||||
if Input.is_action_pressed("move_east"):
|
||||
raw_dir.x += 1
|
||||
if Input.is_action_pressed("move_west"):
|
||||
raw_dir.x -= 1
|
||||
|
||||
if raw_dir != Vector2i.ZERO:
|
||||
var now := Time.get_ticks_msec()
|
||||
var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200)
|
||||
if now - _last_move_msec >= interval:
|
||||
_last_move_msec = now
|
||||
var action: Action = _dir_to_action(dir)
|
||||
# D-054: Transform WASD input relative to mouse facing
|
||||
var world_dir := _wasd_to_world_dir(raw_dir)
|
||||
var action: Action = _dir_to_action(world_dir)
|
||||
input_queue.append({
|
||||
"action": action,
|
||||
"timestamp_msec": now,
|
||||
@@ -77,6 +106,9 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
action = Action.TOGGLE_STANCE_DOWN
|
||||
elif event.is_action_pressed("bug_report"):
|
||||
action = Action.BUG_REPORT
|
||||
elif event.is_action_pressed("teleport_hub"):
|
||||
if GameState.gauntlet_mode:
|
||||
action = Action.TELEPORT_HUB
|
||||
|
||||
if action != -1:
|
||||
input_queue.append({
|
||||
@@ -92,6 +124,85 @@ func flush_queue() -> Array[Dictionary]:
|
||||
return queue
|
||||
|
||||
|
||||
## Reset facing state to default (North). Use in tests per D-030 testability.
|
||||
func reset_facing_state() -> void:
|
||||
facing_angle = -PI / 2.0
|
||||
facing_octant = "North"
|
||||
_last_sent_octant = "North"
|
||||
|
||||
|
||||
# D-054: Compute facing angle from mouse position relative to player screen position.
|
||||
# Uses viewport canvas transform to convert world coords to screen coords.
|
||||
# Intentional coupling: reads GameState.player_position directly — InputMapper is an
|
||||
# autoload that runs before game loop rendering, so position is always current-tick.
|
||||
func _update_facing_from_mouse() -> void:
|
||||
var vp := get_viewport()
|
||||
if vp == null:
|
||||
return
|
||||
var canvas_xf := vp.get_canvas_transform()
|
||||
var player_world_px := GameState.player_position * Constants.TILE_SIZE
|
||||
var player_screen := canvas_xf * player_world_px
|
||||
var mouse_screen := vp.get_mouse_position()
|
||||
var delta := mouse_screen - player_screen
|
||||
# Only update if mouse is meaningfully distant from player (avoid jitter at center)
|
||||
if delta.length_squared() > 4.0:
|
||||
facing_angle = delta.angle()
|
||||
facing_octant = _angle_to_octant(facing_angle)
|
||||
|
||||
|
||||
# D-054: Transform raw WASD input (screen-space) to world direction relative to mouse facing.
|
||||
# W (+Y up in input, mapped to forward), S (backward), A (strafe left), D (strafe right).
|
||||
# Raw input: W=(-Y), S=(+Y), A=(-X), D=(+X) in screen coords.
|
||||
# Forward = facing_angle direction. Output: nearest octant direction vector.
|
||||
func _wasd_to_world_dir(raw_dir: Vector2i) -> Vector2i:
|
||||
# Build a continuous direction vector relative to facing.
|
||||
# raw_dir.y: -1 = W (forward), +1 = S (backward)
|
||||
# raw_dir.x: -1 = A (strafe left), +1 = D (strafe right)
|
||||
var forward := Vector2(cos(facing_angle), sin(facing_angle))
|
||||
var right := Vector2(-forward.y, forward.x) # 90° clockwise
|
||||
|
||||
# Combine: forward/back from W/S, strafe from A/D
|
||||
var world_float := forward * float(-raw_dir.y) + right * float(raw_dir.x)
|
||||
|
||||
# Snap to nearest octant direction
|
||||
return _snap_to_octant_dir(world_float)
|
||||
|
||||
|
||||
# Snap a floating-point direction vector to the nearest of 8 cardinal/diagonal directions.
|
||||
static func _snap_to_octant_dir(dir: Vector2) -> Vector2i:
|
||||
if dir.length_squared() < 0.001:
|
||||
return Vector2i.ZERO
|
||||
var angle := dir.angle()
|
||||
# Quantize to nearest 45° (PI/4)
|
||||
var octant := roundi(angle / (PI / 4.0))
|
||||
match octant:
|
||||
0: return Vector2i(1, 0) # East
|
||||
1: return Vector2i(1, 1) # Southeast
|
||||
2, -6: return Vector2i(0, 1) # South
|
||||
3, -5: return Vector2i(-1, 1) # Southwest
|
||||
4, -4: return Vector2i(-1, 0) # West
|
||||
-3, 5: return Vector2i(-1, -1) # Northwest
|
||||
-2: return Vector2i(0, -1) # North
|
||||
-1: return Vector2i(1, -1) # Northeast
|
||||
_: return Vector2i.ZERO
|
||||
|
||||
|
||||
# D-054: Convert a facing angle (radians) to the nearest octant name.
|
||||
# Godot 2D: 0=East, PI/2=South, -PI/2=North.
|
||||
static func _angle_to_octant(angle: float) -> String:
|
||||
var octant := roundi(angle / (PI / 4.0))
|
||||
match octant:
|
||||
0: return "East"
|
||||
1: return "Southeast"
|
||||
2, -6: return "South"
|
||||
3, -5: return "Southwest"
|
||||
4, -4: return "West"
|
||||
-3, 5: return "Northwest"
|
||||
-2: return "North"
|
||||
-1: return "Northeast"
|
||||
_: return "East"
|
||||
|
||||
|
||||
# Map a direction vector to the corresponding movement Action.
|
||||
# Handles all 8 directions via composite W+D, W+A, etc.
|
||||
static func _dir_to_action(dir: Vector2i) -> Action:
|
||||
|
||||
@@ -10,6 +10,8 @@ var _test_player_pos: Vector2i = Vector2i(10, 10)
|
||||
var _test_facing: String = "North"
|
||||
var _test_input_queue: Array = [] # Queued actions for test mode
|
||||
var _test_in_dialogue: bool = false # Mock dialogue state (#434)
|
||||
var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard
|
||||
var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color
|
||||
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||
|
||||
@@ -40,6 +42,8 @@ func reset_test_state() -> void:
|
||||
_test_facing = "North"
|
||||
_test_input_queue.clear()
|
||||
_test_in_dialogue = false
|
||||
_test_gauntlet_mode = false
|
||||
_test_npc_relationship = "Unknown"
|
||||
|
||||
# Change connection state and emit signal
|
||||
func _set_state(new_state: ConnectionState) -> void:
|
||||
@@ -177,7 +181,16 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
var action: int = player_input.get("action", -1)
|
||||
var wire_name: String = _action_enum_to_wire(action)
|
||||
if not wire_name.is_empty():
|
||||
_test_input_queue.append(wire_name)
|
||||
if wire_name == "SetFacing":
|
||||
# D-054: Use action_data.facing from the input dict, not InputMapper global
|
||||
var facing: String = ""
|
||||
var action_data: Variant = player_input.get("action_data")
|
||||
if action_data is Dictionary:
|
||||
facing = str(action_data.get("facing", ""))
|
||||
if not facing.is_empty():
|
||||
_test_facing = facing
|
||||
else:
|
||||
_test_input_queue.append(wire_name)
|
||||
return OK
|
||||
var action_name := _action_enum_to_wire(player_input.get("action", -1))
|
||||
if action_name.is_empty():
|
||||
@@ -258,6 +271,10 @@ static func _action_enum_to_wire(action: int) -> String:
|
||||
return "" # Client-only action, not part of wire protocol
|
||||
InputMapper.Action.BUG_REPORT:
|
||||
return "" # Client-only action (#495), not part of wire protocol
|
||||
InputMapper.Action.SET_FACING:
|
||||
return "SetFacing" # D-054: facing octant update (no movement)
|
||||
InputMapper.Action.TELEPORT_HUB:
|
||||
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
|
||||
_:
|
||||
push_warning("SimBridge: unknown action enum %s" % action)
|
||||
return ""
|
||||
@@ -273,6 +290,11 @@ func _test_snapshot() -> Dictionary:
|
||||
|
||||
# Process queued inputs
|
||||
for action_name in _test_input_queue:
|
||||
if action_name == "TeleportToHub":
|
||||
# #501: Reset to hub spawn position, clear dialogue
|
||||
_test_player_pos = Vector2i(10, 10)
|
||||
_test_in_dialogue = false
|
||||
continue
|
||||
if action_name == "Interact":
|
||||
# Mock dialogue trigger (#434): if near NPC, start dialogue
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
@@ -285,7 +307,6 @@ func _test_snapshot() -> Dictionary:
|
||||
if _test_is_walkable(new_pos):
|
||||
_test_player_pos = new_pos
|
||||
if delta != Vector2i.ZERO:
|
||||
_test_facing = _delta_to_facing(delta)
|
||||
# Walk-away dismisses dialogue (D-064)
|
||||
if _test_in_dialogue:
|
||||
_test_in_dialogue = false
|
||||
@@ -316,6 +337,7 @@ func _test_snapshot() -> Dictionary:
|
||||
"z": 0,
|
||||
"kind": { "variant": "Npc", "data": null },
|
||||
"visibility": sector,
|
||||
"relationship": _test_npc_relationship,
|
||||
})
|
||||
|
||||
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
||||
@@ -394,6 +416,7 @@ func _test_snapshot() -> Dictionary:
|
||||
"current_monologue": monologue,
|
||||
"current_dialogue": dialogue,
|
||||
"pending_recognitions": pending_recs,
|
||||
"gauntlet_mode": _test_gauntlet_mode,
|
||||
}
|
||||
|
||||
# Generate a small test room: 8x6 room with walls, a door, and floor
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
extends RefCounted
|
||||
|
||||
## #503: Auto-checklist progress tracking — evaluates ObserverSnapshot against
|
||||
## checklist YAML conditions and latches satisfied conditions.
|
||||
##
|
||||
## Usage:
|
||||
## var evaluator := ChecklistEvaluator.new()
|
||||
## evaluator.load_room("inventory_warehouse")
|
||||
## evaluator.evaluate() # call each tick
|
||||
## var results := evaluator.get_results()
|
||||
##
|
||||
## Condition types (per checklist.schema.json):
|
||||
## player_near, player_facing, entity_present, entity_absent,
|
||||
## expected_monologue, expected_dialogue, expected_interaction_verb
|
||||
##
|
||||
## Spec ref: D-030 (testability), checklist.schema.json (#497).
|
||||
|
||||
var _room_conditions: Array = [] # Conditions from per-room checklist
|
||||
var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml
|
||||
var _latched: Dictionary = {} # condition_id -> true (once met, stays met)
|
||||
var _current_room_id: String = ""
|
||||
var _content_base: String = "" # Absolute path to content/ directory
|
||||
var _loaded: bool = false
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
# Content directory lives at repo root (content/), one level above the Godot
|
||||
# project (client/). In editor/dev mode we resolve via the project path.
|
||||
# In exported builds, content is expected at res://content/ (copied by export
|
||||
# preset) — the globalize fallback won't exist, so check res:// first.
|
||||
if DirAccess.dir_exists_absolute("res://content"):
|
||||
_content_base = ProjectSettings.globalize_path("res://content")
|
||||
else:
|
||||
var project_path := ProjectSettings.globalize_path("res://")
|
||||
_content_base = project_path.path_join("../content")
|
||||
|
||||
|
||||
## Load checklist for a room. Clears per-room latches; cross-room latches persist.
|
||||
func load_room(room_id: String) -> void:
|
||||
if room_id == _current_room_id and _loaded:
|
||||
return
|
||||
|
||||
_current_room_id = room_id
|
||||
_room_conditions.clear()
|
||||
|
||||
# Clear per-room latches (keep cross-room latches)
|
||||
var cross_ids := {}
|
||||
for cond in _cross_conditions:
|
||||
cross_ids[cond.get("id", "")] = true
|
||||
var kept := {}
|
||||
for cid in _latched:
|
||||
if cross_ids.has(cid):
|
||||
kept[cid] = true
|
||||
_latched = kept
|
||||
|
||||
# Load per-room checklist
|
||||
var room_path := _content_base.path_join(
|
||||
"gauntlet/rooms/%s/checklist.yaml" % room_id)
|
||||
var room_data := _load_checklist_file(room_path)
|
||||
if room_data.has("conditions"):
|
||||
_room_conditions = room_data["conditions"]
|
||||
_warn_empty_ids(_room_conditions, room_path)
|
||||
|
||||
# Load cross-room checks (only on first load)
|
||||
if _cross_conditions.is_empty():
|
||||
var cross_path := _content_base.path_join("gauntlet/cross_room_checks.yaml")
|
||||
var cross_data := _load_checklist_file(cross_path)
|
||||
if cross_data.has("conditions"):
|
||||
_cross_conditions = cross_data["conditions"]
|
||||
_warn_empty_ids(_cross_conditions, cross_path)
|
||||
|
||||
_loaded = true
|
||||
|
||||
|
||||
## Evaluate all conditions against current GameState. Latches newly met conditions.
|
||||
func evaluate() -> void:
|
||||
for cond in _room_conditions + _cross_conditions:
|
||||
var cid: String = cond.get("id", "")
|
||||
if cid.is_empty() or _latched.has(cid):
|
||||
continue
|
||||
if _evaluate_condition(cond):
|
||||
_latched[cid] = true
|
||||
|
||||
|
||||
## Returns array of {id, description, met} for all loaded conditions.
|
||||
## Conditions with empty id are excluded (invalid, cannot be latched).
|
||||
func get_results() -> Array:
|
||||
var results: Array = []
|
||||
for cond in _room_conditions + _cross_conditions:
|
||||
var cid: String = cond.get("id", "")
|
||||
if cid.is_empty():
|
||||
continue
|
||||
results.append({
|
||||
"id": cid,
|
||||
"description": cond.get("description", ""),
|
||||
"condition_type": cond.get("condition_type", ""),
|
||||
"met": _latched.has(cid),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
## Total number of loaded conditions (excludes conditions with empty id).
|
||||
func get_total_count() -> int:
|
||||
var count: int = 0
|
||||
for cond in _room_conditions + _cross_conditions:
|
||||
if not cond.get("id", "").is_empty():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
## Number of latched (met) conditions.
|
||||
func get_met_count() -> int:
|
||||
return _latched.size()
|
||||
|
||||
|
||||
## Whether all conditions are met.
|
||||
func is_complete() -> bool:
|
||||
return get_met_count() >= get_total_count() and get_total_count() > 0
|
||||
|
||||
|
||||
## Whether any checklist is loaded.
|
||||
func is_loaded() -> bool:
|
||||
return _loaded
|
||||
|
||||
|
||||
## Reset all state (room change to null, or disconnect).
|
||||
func reset() -> void:
|
||||
_room_conditions.clear()
|
||||
_cross_conditions.clear()
|
||||
_latched.clear()
|
||||
_current_room_id = ""
|
||||
_loaded = false
|
||||
|
||||
|
||||
static func _warn_empty_ids(conditions: Array, path: String) -> void:
|
||||
for i in conditions.size():
|
||||
if conditions[i].get("id", "").is_empty():
|
||||
push_warning("ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path])
|
||||
|
||||
|
||||
# -- Condition evaluation ------------------------------------------------------
|
||||
|
||||
func _evaluate_condition(cond: Dictionary) -> bool:
|
||||
match cond.get("condition_type", ""):
|
||||
"player_near":
|
||||
return _eval_player_near(cond)
|
||||
"player_facing":
|
||||
return _eval_player_facing(cond)
|
||||
"entity_present":
|
||||
return _eval_entity_present(cond)
|
||||
"entity_absent":
|
||||
return _eval_entity_absent(cond)
|
||||
"expected_monologue":
|
||||
return _eval_expected_monologue(cond)
|
||||
"expected_dialogue":
|
||||
return _eval_expected_dialogue(cond)
|
||||
"expected_interaction_verb":
|
||||
return _eval_expected_interaction_verb(cond)
|
||||
push_warning("ChecklistEvaluator: unknown condition_type '%s'" % cond.get("condition_type", ""))
|
||||
return false
|
||||
|
||||
|
||||
## x/y and radius are in tile coordinates (matching GameState.player_position),
|
||||
## not pixels. D-066 dual-scale: YAML authors write tile coords, pixel conversion
|
||||
## happens only at render time.
|
||||
func _eval_player_near(cond: Dictionary) -> bool:
|
||||
var tx: float = float(cond.get("x", 0))
|
||||
var ty: float = float(cond.get("y", 0))
|
||||
var radius: float = float(cond.get("radius", 0.0))
|
||||
var target := Vector2(tx, ty)
|
||||
return GameState.player_position.distance_to(target) <= radius
|
||||
|
||||
|
||||
func _eval_player_facing(cond: Dictionary) -> bool:
|
||||
var direction: String = str(cond.get("direction", ""))
|
||||
# Schema uses 4-cardinal (North/South/East/West).
|
||||
# GameState uses 8-directional. Exact match only.
|
||||
return GameState.player_facing == direction
|
||||
|
||||
|
||||
func _eval_entity_present(cond: Dictionary) -> bool:
|
||||
var entity_id: int = int(cond.get("entity_id", -1))
|
||||
return _find_entity(entity_id)
|
||||
|
||||
|
||||
func _eval_entity_absent(cond: Dictionary) -> bool:
|
||||
var entity_id: int = int(cond.get("entity_id", -1))
|
||||
return not _find_entity(entity_id)
|
||||
|
||||
|
||||
func _eval_expected_monologue(cond: Dictionary) -> bool:
|
||||
var contains: String = str(cond.get("contains", ""))
|
||||
if GameState.current_monologue == null:
|
||||
return false
|
||||
var text: String = str(GameState.current_monologue.get("text", ""))
|
||||
return text.find(contains) >= 0
|
||||
|
||||
|
||||
func _eval_expected_dialogue(cond: Dictionary) -> bool:
|
||||
var contains: String = str(cond.get("contains", ""))
|
||||
if GameState.current_dialogue == null:
|
||||
return false
|
||||
var text: String = str(GameState.current_dialogue.get("speech", ""))
|
||||
return text.find(contains) >= 0
|
||||
|
||||
|
||||
func _eval_expected_interaction_verb(cond: Dictionary) -> bool:
|
||||
var entity_id: int = int(cond.get("entity_id", -1))
|
||||
var verb: String = str(cond.get("verb", ""))
|
||||
for interaction in GameState.nearby_interactions:
|
||||
if not interaction is Dictionary:
|
||||
continue
|
||||
if int(interaction.get("entity_id", -1)) != entity_id:
|
||||
continue
|
||||
var verbs: Array = interaction.get("verbs", [])
|
||||
for v in verbs:
|
||||
if not v is Dictionary:
|
||||
continue
|
||||
if str(v.get("label", "")) == verb or str(v.get("kind", "")) == verb:
|
||||
if v.get("available", true):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _find_entity(entity_id: int) -> bool:
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
if int(entity.get("entity_id", -1)) == entity_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# -- YAML parsing (checklist-specific) -----------------------------------------
|
||||
# Handles the constrained checklist YAML format: top-level key:value pairs,
|
||||
# a conditions array of flat dictionaries. No nested arrays or anchors.
|
||||
#
|
||||
# Limitation: unquoted values containing " #" are truncated at the comment marker.
|
||||
# Use quoted strings ("value # with hash") if values must contain literal hashes.
|
||||
|
||||
func _load_checklist_file(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("ChecklistEvaluator: cannot open %s" % path)
|
||||
return {}
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
return parse_checklist_yaml(text)
|
||||
|
||||
|
||||
static func parse_checklist_yaml(text: String) -> Dictionary:
|
||||
var result := {}
|
||||
var conditions: Array = []
|
||||
var current_item: Dictionary = {}
|
||||
var in_conditions := false
|
||||
|
||||
for line in text.split("\n"):
|
||||
var stripped := line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
|
||||
continue
|
||||
|
||||
var indent := line.length() - line.lstrip(" ").length()
|
||||
var content := stripped.strip_edges()
|
||||
|
||||
# Detect conditions: array header
|
||||
if content == "conditions:":
|
||||
in_conditions = true
|
||||
continue
|
||||
|
||||
if not in_conditions:
|
||||
# Top-level key: value
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
else:
|
||||
if content.begins_with("- "):
|
||||
# New array item — flush previous
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var rest := content.substr(2).strip_edges()
|
||||
var colon := rest.find(":")
|
||||
if colon >= 0:
|
||||
var key := rest.substr(0, colon).strip_edges()
|
||||
var val_str := rest.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent >= 2 and not current_item.is_empty():
|
||||
# Continuation of current array item
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent == 0:
|
||||
# Back to top level — shouldn't happen in valid checklist YAML
|
||||
in_conditions = false
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
|
||||
# Flush last item
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
|
||||
if not conditions.is_empty():
|
||||
result["conditions"] = conditions
|
||||
|
||||
return result
|
||||
|
||||
|
||||
static func _parse_value(val: String) -> Variant:
|
||||
if val.is_empty():
|
||||
return ""
|
||||
|
||||
# Strip inline comments (not inside quotes)
|
||||
if not val.begins_with("\""):
|
||||
var comment_pos := val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
|
||||
# Quoted string
|
||||
if val.begins_with("\""):
|
||||
var end_quote := val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
return val.substr(1, end_quote - 1)
|
||||
return val.substr(1)
|
||||
|
||||
# Boolean
|
||||
if val == "true":
|
||||
return true
|
||||
if val == "false":
|
||||
return false
|
||||
|
||||
# Float (contains decimal point)
|
||||
if val.contains(".") and val.is_valid_float():
|
||||
return val.to_float()
|
||||
|
||||
# Integer
|
||||
if val.is_valid_int():
|
||||
return val.to_int()
|
||||
|
||||
# Plain string
|
||||
return val
|
||||
@@ -59,13 +59,22 @@ const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous —
|
||||
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
|
||||
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
|
||||
|
||||
# D-033 color lookup by entity kind (Phase 1: defaults, Phase 2 #361: relationship-based)
|
||||
# D-033 color lookup by relationship string (#521)
|
||||
static func color_for_relationship(relationship: String) -> Color:
|
||||
match relationship:
|
||||
"Friendly": return ENTITY_COLOR_FRIENDLY
|
||||
"PersonOfInterest": return ENTITY_COLOR_POI
|
||||
"Hostile": return ENTITY_COLOR_HOSTILE
|
||||
"Unknown": return ENTITY_COLOR_UNKNOWN
|
||||
_: return ENTITY_COLOR_UNKNOWN
|
||||
|
||||
# D-033 color lookup by entity data — uses relationship for NPCs (#521)
|
||||
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
|
||||
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
|
||||
match kind_variant:
|
||||
"Player": return ENTITY_COLOR_PLAYER
|
||||
"Npc": return ENTITY_COLOR_UNKNOWN
|
||||
"Object", "Terrain": return ENTITY_COLOR_OBJECT
|
||||
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown"))
|
||||
_: return ENTITY_COLOR_OBJECT
|
||||
|
||||
# D-048/D-056: Insert-styled UI color palette
|
||||
@@ -80,3 +89,10 @@ const PERIPHERAL_ALPHA: float = 0.5
|
||||
# Facing direction indicator
|
||||
const FACING_INDICATOR_SIZE: float = 6.0
|
||||
const FACING_INDICATOR_OFFSET: float = 14.0
|
||||
|
||||
# #517: Implant UI font color grading — avoid pure white, project through a lens
|
||||
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
|
||||
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
|
||||
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
|
||||
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
|
||||
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
|
||||
|
||||
+78
-2
@@ -13,12 +13,15 @@ extends Node2D
|
||||
@onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7
|
||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _camera_anchored: bool = false
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
|
||||
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
|
||||
|
||||
func _ready() -> void:
|
||||
print("The Settled Reach — client initialized")
|
||||
@@ -60,8 +63,13 @@ func _process(_delta: float) -> void:
|
||||
# Main game loop: poll snapshot, apply state, flush input
|
||||
var snapshot: Variant = SimBridge.poll_snapshot()
|
||||
if snapshot != null:
|
||||
var old_pos := GameState.player_position
|
||||
GameState.apply_snapshot(snapshot)
|
||||
|
||||
# #501: Detect teleport (large position jump > 5 tiles) and trigger fade
|
||||
if _camera_anchored and _detect_teleport(old_pos, GameState.player_position):
|
||||
_teleport_transition()
|
||||
|
||||
# Late anchor: live mode — first snapshot arrives during _process.
|
||||
# Smoothing is already OFF (disabled in _ready), so setting
|
||||
# global_position takes effect immediately with no lerp.
|
||||
@@ -98,6 +106,10 @@ func _process(_delta: float) -> void:
|
||||
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
|
||||
gauntlet_hud.update_from_state()
|
||||
|
||||
# #503: Update checklist overlay (auto-checklist progress tracking)
|
||||
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
||||
checklist_overlay.update_from_state()
|
||||
|
||||
# Show monologue if server sent one this tick (#414)
|
||||
_consume_monologue()
|
||||
|
||||
@@ -112,9 +124,15 @@ func _process(_delta: float) -> void:
|
||||
# rendered used smoothing=OFF (correct viewport from frame one). Now we
|
||||
# turn smoothing back on and sync its internal state so subsequent frames
|
||||
# get smooth camera tracking during gameplay.
|
||||
# #501: Skip re-enable during teleport — _teleport_transition() disables
|
||||
# smoothing for a clean camera snap. Defer by one frame to avoid the
|
||||
# re-enable block in the same _process() call undoing the snap.
|
||||
if _camera_anchored and not camera.position_smoothing_enabled:
|
||||
camera.position_smoothing_enabled = true
|
||||
camera.reset_smoothing()
|
||||
if _teleport_in_progress:
|
||||
_teleport_in_progress = false
|
||||
else:
|
||||
camera.position_smoothing_enabled = true
|
||||
camera.reset_smoothing()
|
||||
|
||||
# Send queued input to simulation
|
||||
var inputs = InputMapper.flush_queue()
|
||||
@@ -159,6 +177,10 @@ func _consume_monologue() -> void:
|
||||
_last_monologue_tick = GameState.current_tick
|
||||
var mono: Dictionary = GameState.current_monologue
|
||||
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
|
||||
# #502: Amber flash on room reset
|
||||
var mono_id: String = mono.get("id", "")
|
||||
if mono_id.begins_with("room_reset"):
|
||||
_screen_flash(Constants.ENTITY_COLOR_POI, 0.15)
|
||||
GameState.current_monologue = null
|
||||
|
||||
|
||||
@@ -218,3 +240,57 @@ func _on_dialogue_dismissed() -> void:
|
||||
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||
gauntlet_hud.finalize()
|
||||
|
||||
|
||||
# #501: Detect large position jump indicating a teleport (not normal movement).
|
||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
||||
|
||||
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
|
||||
return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD
|
||||
|
||||
|
||||
# #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black.
|
||||
# Clears dialogue/monologue/interaction state (server clears its side too).
|
||||
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
|
||||
func _teleport_transition() -> void:
|
||||
# Snap camera: disable smoothing, force re-anchor.
|
||||
# _teleport_in_progress defers smoothing re-enable by one frame so the
|
||||
# re-enable block at the bottom of _process() doesn't undo the snap.
|
||||
camera.position_smoothing_enabled = false
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
_camera_anchored = true
|
||||
_teleport_in_progress = true
|
||||
|
||||
# Clear client-side buffers
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
dialogue_box.hide_dialogue()
|
||||
|
||||
# Fade from black: instant black overlay, fades to transparent over 0.3s
|
||||
if _flash_rect and is_instance_valid(_flash_rect):
|
||||
_flash_rect.queue_free()
|
||||
_flash_rect = ColorRect.new()
|
||||
_flash_rect.color = Color(0, 0, 0, 1.0)
|
||||
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
|
||||
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
$UILayer.add_child(_flash_rect)
|
||||
var tween := create_tween()
|
||||
tween.tween_property(_flash_rect, "color:a", 0.0, 0.3)
|
||||
tween.tween_callback(_flash_rect.queue_free)
|
||||
|
||||
|
||||
# #502: Full-screen color flash — fades from color to transparent over duration.
|
||||
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
||||
func _screen_flash(color: Color, duration: float) -> void:
|
||||
if _flash_rect and is_instance_valid(_flash_rect):
|
||||
_flash_rect.queue_free()
|
||||
_flash_rect = ColorRect.new()
|
||||
_flash_rect.color = Color(color.r, color.g, color.b, 0.4)
|
||||
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
|
||||
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
$UILayer.add_child(_flash_rect)
|
||||
var tween := create_tween()
|
||||
tween.tween_property(_flash_rect, "color:a", 0.0, duration)
|
||||
tween.tween_callback(_flash_rect.queue_free)
|
||||
|
||||
@@ -23,6 +23,11 @@ const LERP_SPEED: float = 12.0
|
||||
|
||||
var entity_nodes: Dictionary = {} # entity_id -> Node2D
|
||||
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
|
||||
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
|
||||
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
|
||||
|
||||
# #521: Color transition duration in seconds (D-033: "0.5s fade")
|
||||
const COLOR_FADE_DURATION: float = 0.5
|
||||
|
||||
func _ready() -> void:
|
||||
print("EntityRenderer: Initialized")
|
||||
@@ -40,6 +45,22 @@ func _process(delta: float) -> void:
|
||||
if not node.position.is_equal_approx(target):
|
||||
node.position = node.position.lerp(target, weight)
|
||||
|
||||
# #521: Advance color transitions (manual lerp, testable without SceneTree)
|
||||
var finished_ids: Array = []
|
||||
for entity_id in _entity_tweens.keys():
|
||||
if not entity_nodes.has(entity_id):
|
||||
finished_ids.append(entity_id)
|
||||
continue
|
||||
var tween_data: Dictionary = _entity_tweens[entity_id]
|
||||
tween_data.elapsed += delta
|
||||
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
|
||||
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
|
||||
node_c.color = tween_data.from.lerp(tween_data.target, t)
|
||||
if t >= 1.0:
|
||||
finished_ids.append(entity_id)
|
||||
for eid in finished_ids:
|
||||
_entity_tweens.erase(eid)
|
||||
|
||||
|
||||
# Update entities from snapshot data
|
||||
func update_entities(entities: Array) -> void:
|
||||
@@ -75,12 +96,12 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE)
|
||||
entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
|
||||
|
||||
# D-033 color by entity kind (Phase 1 default)
|
||||
# TODO(#361): derive from RelationshipState via knowledge graph
|
||||
# D-033 color by relationship (#521)
|
||||
entity_node.color = _color_for_kind(entity_data)
|
||||
|
||||
add_child(entity_node)
|
||||
entity_nodes[entity_id] = entity_node
|
||||
_entity_relationships[entity_id] = entity_data.get("relationship", "Unknown")
|
||||
|
||||
# Add facing indicator for the player entity
|
||||
if entity_id == GameState.player_entity_id:
|
||||
@@ -112,6 +133,22 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
|
||||
)
|
||||
|
||||
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
|
||||
var new_rel: String = entity_data.get("relationship", "Unknown")
|
||||
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
|
||||
if new_rel != old_rel:
|
||||
_entity_relationships[entity_id] = new_rel
|
||||
var new_color := _color_for_kind(entity_data)
|
||||
_entity_tweens[entity_id] = {
|
||||
"from": entity_node.color,
|
||||
"target": new_color,
|
||||
"elapsed": 0.0,
|
||||
}
|
||||
|
||||
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
|
||||
# are compositionally independent — both can change simultaneously without
|
||||
# interference. If alpha tweening is added later, coordinate with color tween.
|
||||
|
||||
# v2: Peripheral vision dimming (D-015)
|
||||
# null visibility (v1 backward compat) defaults to full alpha
|
||||
var visibility: Variant = entity_data.get("visibility")
|
||||
@@ -119,11 +156,14 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
if not is_equal_approx(entity_node.modulate.a, target_alpha):
|
||||
entity_node.modulate.a = target_alpha
|
||||
|
||||
# v2: Update facing indicator rotation (player entity only)
|
||||
# D-054: Update facing indicator from client-side mouse angle (not server).
|
||||
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
|
||||
if entity_id == GameState.player_entity_id:
|
||||
var indicator = entity_node.get_node_or_null("FacingIndicator")
|
||||
if indicator != null:
|
||||
indicator.rotation = _facing_to_rotation(GameState.player_facing)
|
||||
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
|
||||
# Rotate from North basis: add PI/2 to convert.
|
||||
indicator.rotation = InputMapper.facing_angle + PI / 2.0
|
||||
|
||||
# Remove an entity node
|
||||
func _remove_entity_node(entity_id: int) -> void:
|
||||
@@ -134,6 +174,8 @@ func _remove_entity_node(entity_id: int) -> void:
|
||||
entity_node.queue_free()
|
||||
entity_nodes.erase(entity_id)
|
||||
_entity_targets.erase(entity_id)
|
||||
_entity_relationships.erase(entity_id)
|
||||
_entity_tweens.erase(entity_id)
|
||||
|
||||
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
|
||||
static func _color_for_kind(entity_data: Dictionary) -> Color:
|
||||
@@ -155,16 +197,3 @@ func _add_facing_indicator(parent_node: Control) -> void:
|
||||
# Position at center of parent ColorRect — rotation around this point
|
||||
indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
|
||||
parent_node.add_child(indicator)
|
||||
|
||||
# Convert facing direction string to rotation in radians (0 = North/up)
|
||||
static func _facing_to_rotation(facing: String) -> float:
|
||||
match facing:
|
||||
"North": return 0.0
|
||||
"Northeast": return PI / 4.0
|
||||
"East": return PI / 2.0
|
||||
"Southeast": return 3.0 * PI / 4.0
|
||||
"South": return PI
|
||||
"Southwest": return 5.0 * PI / 4.0
|
||||
"West": return 3.0 * PI / 2.0
|
||||
"Northwest": return 7.0 * PI / 4.0
|
||||
_: return 0.0
|
||||
|
||||
@@ -5,14 +5,15 @@ extends TileMapLayer
|
||||
# Uses a programmatic TileSet with placeholder colored rectangles (D-014)
|
||||
#
|
||||
# Tile types (atlas coords in the programmatic source):
|
||||
# (0,0) = floor — dark gray
|
||||
# (1,0) = wall — lighter gray
|
||||
# (2,0) = door — brown
|
||||
# (3,0) = object — teal
|
||||
# (0,0) = floor — dark gray
|
||||
# (1,0) = wall — lighter gray
|
||||
# (2,0) = door — brown
|
||||
# (3,0) = object — teal
|
||||
# (4,0) = reset_plate — amber (#502)
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3 }
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
|
||||
|
||||
# Wire-format string to TileType mapping
|
||||
const TILE_TYPE_MAP: Dictionary = {
|
||||
@@ -20,6 +21,7 @@ const TILE_TYPE_MAP: Dictionary = {
|
||||
"wall": TileType.WALL,
|
||||
"door": TileType.DOOR,
|
||||
"object": TileType.OBJECT,
|
||||
"reset_plate": TileType.RESET_PLATE,
|
||||
}
|
||||
|
||||
var _initialized: bool = false
|
||||
@@ -36,7 +38,7 @@ func _setup_tileset() -> void:
|
||||
|
||||
# Create an atlas source backed by a programmatic image
|
||||
var source := TileSetAtlasSource.new()
|
||||
var img := Image.create(TILE_SIZE * 4, TILE_SIZE, false, Image.FORMAT_RGBA8)
|
||||
var img := Image.create(TILE_SIZE * 5, TILE_SIZE, false, Image.FORMAT_RGBA8)
|
||||
|
||||
# Floor (0,0) — dark gray
|
||||
_fill_tile(img, 0, Color(0.18, 0.18, 0.22))
|
||||
@@ -46,13 +48,15 @@ func _setup_tileset() -> void:
|
||||
_fill_tile_with_border(img, 2, Color(0.5, 0.35, 0.2), Color(0.35, 0.25, 0.15))
|
||||
# Object (3,0) — teal
|
||||
_fill_tile(img, 3, Color(0.2, 0.45, 0.45))
|
||||
# Reset plate (4,0) — amber (#502)
|
||||
_fill_tile_with_border(img, 4, Color(0.91, 0.77, 0.28), Color(0.65, 0.55, 0.2))
|
||||
|
||||
var tex := ImageTexture.create_from_image(img)
|
||||
source.texture = tex
|
||||
source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE)
|
||||
|
||||
# Create tile entries in the atlas
|
||||
for i in range(4):
|
||||
for i in range(5):
|
||||
source.create_tile(Vector2i(i, 0))
|
||||
|
||||
var source_id := ts.add_source(source)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,692 @@
|
||||
## #503: Auto-checklist progress tracking — unit + integration tests.
|
||||
##
|
||||
## Tests cover:
|
||||
## 1. YAML parser: basic types, conditions array, edge cases
|
||||
## 2. Condition evaluation: all 7 condition types
|
||||
## 3. Latching: conditions stay met once satisfied
|
||||
## 4. Room change: per-room conditions reset, cross-room conditions persist
|
||||
## 5. Overlay: visibility gating on gauntlet_mode
|
||||
## 6. Integration: snapshot -> GameState -> evaluator -> overlay
|
||||
##
|
||||
## Spec ref: D-030 (testability), checklist.schema.json (#497), Sprint 10 Completion Proof.
|
||||
class_name TestChecklist
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
var ChecklistOverlayScript = load("res://ui/checklist_overlay.gd")
|
||||
var ChecklistEvaluatorScript = load("res://scripts/checklist/checklist_evaluator.gd")
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._last_snapshot = null
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.visible_positions = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.nearby_interactions = []
|
||||
GameState.game_time = {}
|
||||
GameState.pending_recognitions = []
|
||||
GameState.room_id = null
|
||||
GameState.gauntlet_mode = false
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_inventory = []
|
||||
|
||||
|
||||
# -- YAML Parser Tests ---------------------------------------------------------
|
||||
|
||||
func test_parse_empty_yaml() -> void:
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml("")
|
||||
assert_that(result.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_parse_top_level_string() -> void:
|
||||
var yaml := "room_id: inventory_warehouse"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.get("room_id")).is_equal("inventory_warehouse")
|
||||
|
||||
|
||||
func test_parse_top_level_quoted_string() -> void:
|
||||
var yaml := 'description: "Tests D-065 (9-slot inventory)."'
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.get("description")).is_equal("Tests D-065 (9-slot inventory).")
|
||||
|
||||
|
||||
func test_parse_single_condition() -> void:
|
||||
var yaml := "conditions:\n - id: test-1\n description: \"Test condition\"\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.has("conditions")).is_true()
|
||||
var conditions: Array = result["conditions"]
|
||||
assert_that(conditions.size()).is_equal(1)
|
||||
assert_that(conditions[0]["id"]).is_equal("test-1")
|
||||
assert_that(conditions[0]["condition_type"]).is_equal("player_near")
|
||||
assert_that(conditions[0]["x"]).is_equal(10)
|
||||
assert_that(conditions[0]["y"]).is_equal(20)
|
||||
assert_that(conditions[0]["radius"]).is_equal_approx(3.0, 0.001)
|
||||
|
||||
|
||||
func test_parse_multiple_conditions() -> void:
|
||||
var yaml := "conditions:\n - id: cond-a\n condition_type: player_near\n x: 1\n y: 2\n radius: 1.0\n\n - id: cond-b\n condition_type: player_facing\n direction: East"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
var conditions: Array = result["conditions"]
|
||||
assert_that(conditions.size()).is_equal(2)
|
||||
assert_that(conditions[0]["id"]).is_equal("cond-a")
|
||||
assert_that(conditions[1]["id"]).is_equal("cond-b")
|
||||
assert_that(conditions[1]["direction"]).is_equal("East")
|
||||
|
||||
|
||||
func test_parse_comments_ignored() -> void:
|
||||
var yaml := "# This is a comment\nroom_id: test\n# Another comment\nconditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.get("room_id")).is_equal("test")
|
||||
var conditions: Array = result["conditions"]
|
||||
assert_that(conditions.size()).is_equal(1)
|
||||
assert_that(conditions[0]["entity_id"]).is_equal(5)
|
||||
|
||||
|
||||
func test_parse_integer_and_float_values() -> void:
|
||||
var yaml := "conditions:\n - id: t\n condition_type: player_near\n x: 42\n y: -3\n radius: 2.5"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
var cond: Dictionary = result["conditions"][0]
|
||||
assert_that(cond["x"]).is_equal(42)
|
||||
assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT)
|
||||
|
||||
|
||||
func test_parse_scope_field() -> void:
|
||||
var yaml := "scope: cross_room\nconditions:\n - id: cr-1\n condition_type: entity_present\n entity_id: 0"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.get("scope")).is_equal("cross_room")
|
||||
|
||||
|
||||
func test_parse_inline_comment_stripped() -> void:
|
||||
var yaml := "room_id: test # this is a comment"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.get("room_id")).is_equal("test")
|
||||
|
||||
|
||||
# -- Condition Evaluation Tests ------------------------------------------------
|
||||
|
||||
func _make_evaluator(conditions: Array):
|
||||
var evaluator = ChecklistEvaluatorScript.new()
|
||||
evaluator._room_conditions = conditions
|
||||
evaluator._loaded = true
|
||||
return evaluator
|
||||
|
||||
|
||||
func test_eval_player_near_within_radius() -> void:
|
||||
GameState.player_position = Vector2(10.0, 20.0)
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "near-1", "condition_type": "player_near",
|
||||
"x": 10, "y": 21, "radius": 2.0,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"player_near: player at (10,20), target (10,21), radius 2.0 — should be met"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_player_near_outside_radius() -> void:
|
||||
GameState.player_position = Vector2(10.0, 20.0)
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "near-2", "condition_type": "player_near",
|
||||
"x": 10, "y": 30, "radius": 2.0,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"player_near: player at (10,20), target (10,30), radius 2.0 — should NOT be met"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_player_near_exact_boundary() -> void:
|
||||
GameState.player_position = Vector2(10.0, 20.0)
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "near-3", "condition_type": "player_near",
|
||||
"x": 10, "y": 22, "radius": 2.0,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"player_near: distance exactly equals radius — should be met (<=)"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_player_facing_match() -> void:
|
||||
GameState.player_facing = "East"
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "face-1", "condition_type": "player_facing",
|
||||
"direction": "East",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_player_facing_no_match() -> void:
|
||||
GameState.player_facing = "North"
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "face-2", "condition_type": "player_facing",
|
||||
"direction": "East",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_player_facing_diagonal_no_match() -> void:
|
||||
# 8-directional facing "Northeast" should NOT match "East" or "North"
|
||||
GameState.player_facing = "Northeast"
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "face-diag", "condition_type": "player_facing",
|
||||
"direction": "East",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"player_facing: Northeast should NOT match East (exact match only)"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_entity_present_found() -> void:
|
||||
GameState.visible_entities = [
|
||||
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
|
||||
{"entity_id": 10, "x": 2.0, "y": 2.0, "z": 0, "kind": "Object"},
|
||||
]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "present-1", "condition_type": "entity_present",
|
||||
"entity_id": 10,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_entity_present_not_found() -> void:
|
||||
GameState.visible_entities = [
|
||||
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
|
||||
]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "present-2", "condition_type": "entity_present",
|
||||
"entity_id": 99,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_entity_absent_when_not_visible() -> void:
|
||||
GameState.visible_entities = [
|
||||
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
|
||||
]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "absent-1", "condition_type": "entity_absent",
|
||||
"entity_id": 99,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"entity_absent: entity 99 not in visible_entities — should be met"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_entity_absent_when_visible() -> void:
|
||||
GameState.visible_entities = [
|
||||
{"entity_id": 10, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
|
||||
]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "absent-2", "condition_type": "entity_absent",
|
||||
"entity_id": 10,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"entity_absent: entity 10 IS visible — should NOT be met"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_expected_monologue_match() -> void:
|
||||
GameState.current_monologue = {"id": "m1", "text": "Something is wrong here.", "duration_seconds": 5.0}
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "mono-1", "condition_type": "expected_monologue",
|
||||
"contains": "wrong here",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_expected_monologue_no_match() -> void:
|
||||
GameState.current_monologue = {"id": "m1", "text": "All clear.", "duration_seconds": 5.0}
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "mono-2", "condition_type": "expected_monologue",
|
||||
"contains": "wrong here",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_expected_monologue_null() -> void:
|
||||
GameState.current_monologue = null
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "mono-3", "condition_type": "expected_monologue",
|
||||
"contains": "test",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"expected_monologue: null monologue should not match"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_expected_dialogue_match() -> void:
|
||||
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?", "options": []}
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "dlg-1", "condition_type": "expected_dialogue",
|
||||
"contains": "Who are you",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_expected_dialogue_no_match() -> void:
|
||||
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Hello.", "options": []}
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "dlg-2", "condition_type": "expected_dialogue",
|
||||
"contains": "Goodbye",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_expected_dialogue_null() -> void:
|
||||
GameState.current_dialogue = null
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "dlg-3", "condition_type": "expected_dialogue",
|
||||
"contains": "test",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"expected_dialogue: null dialogue should not match"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_interaction_verb_match() -> void:
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 13,
|
||||
"entity_type": "Object",
|
||||
"distance": 1,
|
||||
"verbs": [
|
||||
{"kind": "Take", "label": "Pickup", "priority": 1, "available": true},
|
||||
{"kind": "Observe", "label": "Examine", "priority": 2, "available": true},
|
||||
],
|
||||
}]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "verb-1", "condition_type": "expected_interaction_verb",
|
||||
"entity_id": 13, "verb": "Pickup",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_interaction_verb_by_kind() -> void:
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 13,
|
||||
"entity_type": "Object",
|
||||
"distance": 1,
|
||||
"verbs": [
|
||||
{"kind": "Take", "label": "Pickup", "priority": 1, "available": true},
|
||||
],
|
||||
}]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "verb-kind", "condition_type": "expected_interaction_verb",
|
||||
"entity_id": 13, "verb": "Take",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"expected_interaction_verb: should match by kind='Take' as well as label"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_interaction_verb_wrong_entity() -> void:
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 13,
|
||||
"entity_type": "Object",
|
||||
"distance": 1,
|
||||
"verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": true}],
|
||||
}]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "verb-wrong", "condition_type": "expected_interaction_verb",
|
||||
"entity_id": 99, "verb": "Pickup",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"expected_interaction_verb: wrong entity_id should not match"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_interaction_verb_unavailable() -> void:
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 13,
|
||||
"entity_type": "Object",
|
||||
"distance": 1,
|
||||
"verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": false}],
|
||||
}]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "verb-unavail", "condition_type": "expected_interaction_verb",
|
||||
"entity_id": 13, "verb": "Pickup",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"expected_interaction_verb: unavailable verb should not match"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_interaction_verb_no_interactions() -> void:
|
||||
GameState.nearby_interactions = []
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "verb-none", "condition_type": "expected_interaction_verb",
|
||||
"entity_id": 13, "verb": "Pickup",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
|
||||
# -- Latching Tests ------------------------------------------------------------
|
||||
|
||||
func test_latching_condition_stays_met() -> void:
|
||||
# Condition met on first evaluate, stays met even when state changes.
|
||||
GameState.player_facing = "East"
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "latch-1", "condition_type": "player_facing",
|
||||
"direction": "East",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
# Change state so condition would be false if re-evaluated fresh
|
||||
GameState.player_facing = "North"
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"Latched condition should stay met even after state changes"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_latching_monologue_transient() -> void:
|
||||
# Monologue appears for one tick, then disappears. Condition should latch.
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "mono-latch", "condition_type": "expected_monologue",
|
||||
"contains": "recalibrated",
|
||||
}])
|
||||
|
||||
# Tick 1: no monologue
|
||||
GameState.current_monologue = null
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
# Tick 2: monologue fires
|
||||
GameState.current_monologue = {"id": "m1", "text": "Systems recalibrated.", "duration_seconds": 3.0}
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
# Tick 3: monologue consumed (null)
|
||||
GameState.current_monologue = null
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"Monologue condition should stay latched after monologue disappears"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_multiple_conditions_partial_latching() -> void:
|
||||
var evaluator = _make_evaluator([
|
||||
{"id": "c1", "condition_type": "player_facing", "direction": "East"},
|
||||
{"id": "c2", "condition_type": "entity_present", "entity_id": 5},
|
||||
{"id": "c3", "condition_type": "player_near", "x": 50, "y": 50, "radius": 1.0},
|
||||
])
|
||||
|
||||
# Tick 1: only facing matches
|
||||
GameState.player_facing = "East"
|
||||
GameState.visible_entities = []
|
||||
GameState.player_position = Vector2(0, 0)
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
assert_that(evaluator.get_total_count()).is_equal(3)
|
||||
|
||||
# Tick 2: entity also visible
|
||||
GameState.visible_entities = [{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}]
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(2)
|
||||
|
||||
# Tick 3: player moves to target
|
||||
GameState.player_position = Vector2(50.0, 50.0)
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(3)
|
||||
assert_that(evaluator.is_complete()).is_true()
|
||||
|
||||
|
||||
# -- Room Change Tests ---------------------------------------------------------
|
||||
|
||||
func test_room_change_resets_per_room_conditions() -> void:
|
||||
var evaluator = ChecklistEvaluatorScript.new()
|
||||
# Manually set conditions to avoid file loading
|
||||
evaluator._room_conditions = [
|
||||
{"id": "r1-c1", "condition_type": "player_facing", "direction": "East"},
|
||||
]
|
||||
evaluator._loaded = true
|
||||
GameState.player_facing = "East"
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
# Simulate room change by loading a new "room"
|
||||
evaluator._current_room_id = "old_room"
|
||||
evaluator._room_conditions = [
|
||||
{"id": "r2-c1", "condition_type": "player_facing", "direction": "North"},
|
||||
]
|
||||
# Clear latches for the new room (simulating load_room behavior)
|
||||
evaluator._latched.clear()
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"After room change, old latches should be cleared; new condition not met"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_reset_clears_all_state() -> void:
|
||||
var evaluator = _make_evaluator([
|
||||
{"id": "c1", "condition_type": "player_facing", "direction": "East"},
|
||||
])
|
||||
GameState.player_facing = "East"
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
evaluator.reset()
|
||||
assert_that(evaluator.is_loaded()).is_false()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
assert_that(evaluator.get_total_count()).is_equal(0)
|
||||
|
||||
|
||||
# -- get_results Tests ---------------------------------------------------------
|
||||
|
||||
func test_get_results_structure() -> void:
|
||||
var evaluator = _make_evaluator([
|
||||
{"id": "c1", "description": "Test condition", "condition_type": "player_facing", "direction": "North"},
|
||||
])
|
||||
GameState.player_facing = "North"
|
||||
evaluator.evaluate()
|
||||
var results: Array = evaluator.get_results()
|
||||
assert_that(results.size()).is_equal(1)
|
||||
assert_that(results[0]["id"]).is_equal("c1")
|
||||
assert_that(results[0]["description"]).is_equal("Test condition")
|
||||
assert_that(results[0]["condition_type"]).is_equal("player_facing")
|
||||
assert_that(results[0]["met"]).is_true()
|
||||
|
||||
|
||||
func test_get_results_unmet() -> void:
|
||||
var evaluator = _make_evaluator([
|
||||
{"id": "c1", "description": "Test", "condition_type": "player_facing", "direction": "South"},
|
||||
])
|
||||
GameState.player_facing = "North"
|
||||
evaluator.evaluate()
|
||||
var results: Array = evaluator.get_results()
|
||||
assert_that(results[0]["met"]).is_false()
|
||||
|
||||
|
||||
# -- Overlay Visibility Tests --------------------------------------------------
|
||||
|
||||
func _make_checklist_overlay() -> Control:
|
||||
var overlay = Control.new()
|
||||
overlay.set_script(ChecklistOverlayScript)
|
||||
auto_free(overlay)
|
||||
add_child(overlay)
|
||||
return overlay
|
||||
|
||||
|
||||
func test_overlay_hidden_in_non_gauntlet_mode() -> void:
|
||||
var overlay := _make_checklist_overlay()
|
||||
GameState.gauntlet_mode = false
|
||||
overlay.update_from_state()
|
||||
assert_that(overlay.visible).override_failure_message(
|
||||
"Checklist overlay must be hidden in non-gauntlet mode"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_overlay_visible_in_gauntlet_mode() -> void:
|
||||
var overlay := _make_checklist_overlay()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "test_room"
|
||||
overlay.update_from_state()
|
||||
assert_that(overlay.visible).override_failure_message(
|
||||
"Checklist overlay must be visible in gauntlet mode"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_overlay_hides_on_gauntlet_deactivation() -> void:
|
||||
var overlay := _make_checklist_overlay()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "test_room"
|
||||
overlay.update_from_state()
|
||||
assert_that(overlay.visible).is_true()
|
||||
GameState.gauntlet_mode = false
|
||||
overlay.update_from_state()
|
||||
assert_that(overlay.visible).override_failure_message(
|
||||
"Overlay must hide when gauntlet mode deactivates"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_overlay_evaluator_accessible() -> void:
|
||||
var overlay := _make_checklist_overlay()
|
||||
var evaluator = overlay.get_evaluator()
|
||||
assert_that(evaluator).override_failure_message(
|
||||
"Overlay should expose evaluator via get_evaluator()"
|
||||
).is_not_null()
|
||||
|
||||
|
||||
func test_overlay_in_main_scene() -> void:
|
||||
var scene: PackedScene = load("res://scenes/main.tscn")
|
||||
var instance: Node = scene.instantiate()
|
||||
auto_free(instance)
|
||||
add_child(instance)
|
||||
instance._process(0.016)
|
||||
|
||||
var overlay: Node = _find_node_recursive(instance, "ChecklistOverlay")
|
||||
assert_that(overlay).override_failure_message(
|
||||
"ChecklistOverlay node should exist in main scene tree"
|
||||
).is_not_null()
|
||||
if overlay is CanvasItem:
|
||||
assert_that((overlay as CanvasItem).visible).override_failure_message(
|
||||
"ChecklistOverlay should be hidden by default (non-gauntlet mode)"
|
||||
).is_false()
|
||||
|
||||
|
||||
# -- Integration: Snapshot -> Evaluation ----------------------------------------
|
||||
|
||||
func test_integration_snapshot_to_evaluator() -> void:
|
||||
# Integration test: GameState snapshot data -> evaluator -> correct results.
|
||||
# Tests the evaluator directly (overlay wiring tested separately).
|
||||
var evaluator = ChecklistEvaluatorScript.new()
|
||||
evaluator._room_conditions = [
|
||||
{"id": "int-1", "description": "Player entity present", "condition_type": "entity_present", "entity_id": 1},
|
||||
{"id": "int-2", "description": "Player faces East", "condition_type": "player_facing", "direction": "East"},
|
||||
]
|
||||
evaluator._loaded = true
|
||||
|
||||
# Simulate gauntlet snapshot with player entity
|
||||
GameState.visible_entities = [
|
||||
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player"},
|
||||
]
|
||||
GameState.player_facing = "North"
|
||||
|
||||
evaluator.evaluate()
|
||||
|
||||
# Entity present should be met, facing should not
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
assert_that(evaluator.get_total_count()).is_equal(2)
|
||||
|
||||
# Change facing — second condition should also latch
|
||||
GameState.player_facing = "East"
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(2)
|
||||
assert_that(evaluator.is_complete()).is_true()
|
||||
|
||||
# Verify results array contains both conditions as met
|
||||
var results: Array = evaluator.get_results()
|
||||
for r in results:
|
||||
assert_that(r["met"]).override_failure_message(
|
||||
"Condition '%s' should be met after snapshot sequence" % r["id"]
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Edge Cases ----------------------------------------------------------------
|
||||
|
||||
func test_empty_entity_list_entity_present() -> void:
|
||||
GameState.visible_entities = []
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "edge-empty", "condition_type": "entity_present",
|
||||
"entity_id": 0,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
|
||||
func test_empty_entity_list_entity_absent() -> void:
|
||||
GameState.visible_entities = []
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "edge-absent-empty", "condition_type": "entity_absent",
|
||||
"entity_id": 99,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"entity_absent with empty visible_entities should be met"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_zero_radius_player_near() -> void:
|
||||
GameState.player_position = Vector2(10.0, 20.0)
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "edge-zero-radius", "condition_type": "player_near",
|
||||
"x": 10, "y": 20, "radius": 0.0,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"player_near with radius 0 at exact position should be met"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_unknown_condition_type() -> void:
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "edge-unknown", "condition_type": "nonexistent_type",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"Unknown condition type should not be met"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_no_conditions_loaded() -> void:
|
||||
var evaluator = ChecklistEvaluatorScript.new()
|
||||
assert_that(evaluator.is_loaded()).is_false()
|
||||
assert_that(evaluator.get_total_count()).is_equal(0)
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
assert_that(evaluator.is_complete()).is_false()
|
||||
|
||||
|
||||
# -- Helper: recursive node search --------------------------------------------
|
||||
|
||||
func _find_node_recursive(root: Node, target_name: String) -> Node:
|
||||
if root.name == target_name:
|
||||
return root
|
||||
for child in root.get_children():
|
||||
var found := _find_node_recursive(child, target_name)
|
||||
if found != null:
|
||||
return found
|
||||
return null
|
||||
@@ -253,27 +253,29 @@ func test_recognition_transition_progress() -> void:
|
||||
fog_entities.queue_free()
|
||||
|
||||
|
||||
func test_facing_indicator_rotation_matches_player_facing() -> void:
|
||||
# P3-T02: Facing indicator rotation matches player_facing from snapshot.
|
||||
func test_facing_indicator_rotation_matches_input_mapper_angle() -> void:
|
||||
# P3-T02: D-054 — Facing indicator uses InputMapper.facing_angle (client-side float).
|
||||
# Indicator rotation = facing_angle + PI/2 (0=North basis).
|
||||
GameState.player_entity_id = 1
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null}, "visibility": "Forward"}]
|
||||
renderer.update_entities(entity)
|
||||
var indicator = renderer.entity_nodes[1].get_node("FacingIndicator")
|
||||
# Test each cardinal + diagonal direction
|
||||
var expected := {
|
||||
"North": 0.0,
|
||||
"East": PI / 2.0,
|
||||
"South": PI,
|
||||
"West": 3.0 * PI / 2.0,
|
||||
# {facing_angle → expected indicator rotation}
|
||||
var angles := {
|
||||
-PI / 2.0: 0.0, # North
|
||||
0.0: PI / 2.0, # East
|
||||
PI / 2.0: PI, # South
|
||||
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2 by Godot)
|
||||
}
|
||||
for dir in expected:
|
||||
GameState.player_facing = dir
|
||||
for angle in angles:
|
||||
InputMapper.facing_angle = angle
|
||||
renderer.update_entities(entity)
|
||||
assert_that(indicator.rotation).override_failure_message(
|
||||
"%s: expected rotation %.3f, got %.3f" % [dir, expected[dir], indicator.rotation]
|
||||
).is_equal_approx(expected[dir], 0.001)
|
||||
"angle %.3f: expected rotation %.3f, got %.3f" % [angle, angles[angle], indicator.rotation]
|
||||
).is_equal_approx(angles[angle], 0.001)
|
||||
InputMapper.facing_angle = -PI / 2.0 # Reset to default
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
## #521: Confrontation D-033 color shift — QA test suite
|
||||
## Spec refs: D-033 (entity color = relationship), D-063 (confrontation same box)
|
||||
## Sprint Completion Proof (joint.md):
|
||||
## - Entity tint fades 0.5-1s to new relationship color on confrontation delivery
|
||||
## - Cursor hover tint also updates to match relationship
|
||||
## - Palette matches D-033 exactly
|
||||
##
|
||||
## Tests are structured in layers:
|
||||
## 1. D-033 color palette constants — always pass (no implementation dependency)
|
||||
## 2. Relationship-to-color mapping — tests the lookup function
|
||||
## 3. Entity renderer relationship coloring — tests that entities USE relationship
|
||||
## 4. Tween on relationship change — tests fade behavior (0.5-1s)
|
||||
## 5. Protocol/GameState passthrough — tests data pipeline integrity
|
||||
class_name TestColorShift
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript: GDScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
var ConstantsScript: GDScript = load("res://scripts/constants.gd")
|
||||
|
||||
# -- Test data -----------------------------------------------------------------
|
||||
|
||||
# Entity with relationship field (v4 protocol format)
|
||||
func _make_entity(entity_id: int, kind: String, relationship: String, x: float = 5.0, y: float = 5.0) -> Dictionary:
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"x": x, "y": y, "z": 0,
|
||||
"kind": {"variant": kind, "data": null},
|
||||
"visibility": "Forward",
|
||||
"relationship": relationship,
|
||||
"observation": "Visible",
|
||||
}
|
||||
|
||||
func _make_entity_renderer() -> Node2D:
|
||||
var renderer: Node2D = Node2D.new()
|
||||
renderer.set_script(EntityRendererScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.player_entity_id = 1
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 1: D-033 Color Palette Constants
|
||||
# These tests verify the palette is defined correctly. No implementation needed.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d033_unknown_teal() -> void:
|
||||
assert_that(Constants.ENTITY_COLOR_UNKNOWN).is_equal(Color("#4a9ebb"))
|
||||
|
||||
func test_d033_friendly_green() -> void:
|
||||
assert_that(Constants.ENTITY_COLOR_FRIENDLY).is_equal(Color("#6bc9a6"))
|
||||
|
||||
func test_d033_poi_amber() -> void:
|
||||
assert_that(Constants.ENTITY_COLOR_POI).is_equal(Color("#e8c547"))
|
||||
|
||||
func test_d033_hostile_red() -> void:
|
||||
assert_that(Constants.ENTITY_COLOR_HOSTILE).is_equal(Color("#d45d5d"))
|
||||
|
||||
func test_d033_object_grey() -> void:
|
||||
assert_that(Constants.ENTITY_COLOR_OBJECT).is_equal(Color("#8b8ba0"))
|
||||
|
||||
func test_d033_player_cool_white() -> void:
|
||||
assert_that(Constants.ENTITY_COLOR_PLAYER).is_equal(Color("#e0e8ff"))
|
||||
|
||||
func test_d033_palette_all_distinct() -> void:
|
||||
# All 6 D-033 colors must be distinct from each other
|
||||
var colors: Array[Color] = [
|
||||
Constants.ENTITY_COLOR_UNKNOWN,
|
||||
Constants.ENTITY_COLOR_FRIENDLY,
|
||||
Constants.ENTITY_COLOR_POI,
|
||||
Constants.ENTITY_COLOR_HOSTILE,
|
||||
Constants.ENTITY_COLOR_OBJECT,
|
||||
Constants.ENTITY_COLOR_PLAYER,
|
||||
]
|
||||
for i in range(colors.size()):
|
||||
for j in range(i + 1, colors.size()):
|
||||
assert_that(colors[i] != colors[j]).is_true()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 2: Relationship-to-Color Mapping
|
||||
# Tests the lookup function that maps relationship strings to D-033 colors.
|
||||
# Depends on #521 adding color_for_relationship() to Constants.
|
||||
# Uses ConstantsScript method list to skip gracefully if not yet implemented.
|
||||
# ==============================================================================
|
||||
|
||||
func _has_color_for_relationship() -> bool:
|
||||
# Check if the Constants script has a color_for_relationship method.
|
||||
for method in ConstantsScript.get_script_method_list():
|
||||
if method.name == "color_for_relationship":
|
||||
return true
|
||||
return false
|
||||
|
||||
func test_relationship_color_unknown() -> void:
|
||||
if not _has_color_for_relationship():
|
||||
push_warning("TestColorShift: color_for_relationship not implemented yet — awaiting #521")
|
||||
return
|
||||
var color: Color = Constants.color_for_relationship("Unknown")
|
||||
assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
func test_relationship_color_friendly() -> void:
|
||||
if not _has_color_for_relationship():
|
||||
return
|
||||
var color: Color = Constants.color_for_relationship("Friendly")
|
||||
assert_that(color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
|
||||
func test_relationship_color_person_of_interest() -> void:
|
||||
if not _has_color_for_relationship():
|
||||
return
|
||||
var color: Color = Constants.color_for_relationship("PersonOfInterest")
|
||||
assert_that(color).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
|
||||
func test_relationship_color_hostile() -> void:
|
||||
if not _has_color_for_relationship():
|
||||
return
|
||||
var color: Color = Constants.color_for_relationship("Hostile")
|
||||
assert_that(color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
|
||||
func test_relationship_color_fallback() -> void:
|
||||
if not _has_color_for_relationship():
|
||||
return
|
||||
var color: Color = Constants.color_for_relationship("SomethingWeird")
|
||||
assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 3: Entity Renderer — Relationship-Based Coloring
|
||||
# Tests that entity_renderer uses the relationship field for NPC colors.
|
||||
# Player and Object entities should remain unaffected by relationship field.
|
||||
# ==============================================================================
|
||||
|
||||
func test_npc_uses_relationship_color_unknown() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_npc_uses_relationship_color_friendly() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
if not _entity_uses_relationship(renderer):
|
||||
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
|
||||
renderer.queue_free()
|
||||
return
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_npc_uses_relationship_color_poi() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_npc_uses_relationship_color_hostile() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_player_color_ignores_relationship() -> void:
|
||||
# Player entity always uses ENTITY_COLOR_PLAYER regardless of relationship
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(1, "Player", "Hostile")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[1] as ColorRect
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_object_color_ignores_relationship() -> void:
|
||||
# Object entities always use ENTITY_COLOR_OBJECT
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(3, "Object", "Friendly")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[3] as ColorRect
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 4: Tween on Relationship Change
|
||||
# Tests that color transitions use a 0.5-1s fade, not an instant flip.
|
||||
# D-033: "Color shifts smoothly (0.5s fade) when relationship state changes."
|
||||
# D-063: "entity D-033 color may fade" on confrontation delivery.
|
||||
# ==============================================================================
|
||||
|
||||
func test_color_shift_not_instant() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
|
||||
renderer.update_entities(entities_before)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
# Change relationship to Hostile
|
||||
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
|
||||
renderer.update_entities(entities_after)
|
||||
# Immediately after update, color should NOT yet be the target
|
||||
var color_after_immediate: Color = node.color
|
||||
if not _renderer_has_tween_support(renderer):
|
||||
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
|
||||
renderer.queue_free()
|
||||
return
|
||||
# The color should NOT be exactly the target yet (tween in progress)
|
||||
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_color_shift_reaches_target() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
if not _renderer_has_tween_support(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
renderer.update_entities([_make_entity(2, "Npc", "Friendly")])
|
||||
renderer.update_entities([_make_entity(2, "Npc", "Hostile")])
|
||||
# Simulate time passing: ~1.5 seconds of frames
|
||||
var elapsed: float = 0.0
|
||||
while elapsed < 1.5:
|
||||
renderer._process(1.0 / 60.0)
|
||||
elapsed += 1.0 / 60.0
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_color_shift_mid_transition_retrigger() -> void:
|
||||
# Rapid relationship changes: Unknown → Friendly → Hostile in quick succession.
|
||||
# The second change should preempt the first tween and converge to Hostile.
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
if not _renderer_has_tween_support(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
renderer.update_entities([_make_entity(2, "Npc", "Unknown")])
|
||||
# First change: Unknown → Friendly
|
||||
renderer.update_entities([_make_entity(2, "Npc", "Friendly")])
|
||||
# Advance partway (0.1s of a 0.5s tween)
|
||||
for i in range(6):
|
||||
renderer._process(1.0 / 60.0)
|
||||
# Second change mid-tween: Friendly → Hostile (preempts first)
|
||||
renderer.update_entities([_make_entity(2, "Npc", "Hostile")])
|
||||
# Advance past full duration
|
||||
var elapsed: float = 0.0
|
||||
while elapsed < 1.0:
|
||||
renderer._process(1.0 / 60.0)
|
||||
elapsed += 1.0 / 60.0
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_color_shift_same_relationship_no_tween() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var color_first: Color = node.color
|
||||
renderer.update_entities(entities)
|
||||
var color_second: Color = node.color
|
||||
assert_that(color_first).is_equal(color_second)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 5: Protocol — Relationship Field Round-Trip
|
||||
# ==============================================================================
|
||||
|
||||
func test_protocol_entity_relationship_decoded() -> void:
|
||||
var raw: Dictionary = {
|
||||
"entity_id": 5,
|
||||
"x": 10.0, "y": 10.0, "z": 0,
|
||||
"kind": "Npc",
|
||||
"relationship": "PersonOfInterest",
|
||||
"visibility": "Forward",
|
||||
"observation": "Visible",
|
||||
}
|
||||
var decoded: Variant = Protocol._decode_entity(raw)
|
||||
assert_that(decoded).is_not_null()
|
||||
assert_that(decoded.relationship).is_equal("PersonOfInterest")
|
||||
|
||||
func test_protocol_entity_relationship_defaults_unknown() -> void:
|
||||
var raw: Dictionary = {
|
||||
"entity_id": 5,
|
||||
"x": 10.0, "y": 10.0, "z": 0,
|
||||
"kind": "Npc",
|
||||
}
|
||||
var decoded: Variant = Protocol._decode_entity(raw)
|
||||
assert_that(decoded).is_not_null()
|
||||
assert_that(decoded.relationship).is_equal("Unknown")
|
||||
|
||||
func test_protocol_entity_all_relationship_values() -> void:
|
||||
var relationships: Array[String] = ["Unknown", "Friendly", "PersonOfInterest", "Hostile"]
|
||||
for rel in relationships:
|
||||
var raw: Dictionary = {
|
||||
"entity_id": 5,
|
||||
"x": 10.0, "y": 10.0, "z": 0,
|
||||
"kind": "Npc",
|
||||
"relationship": rel,
|
||||
}
|
||||
var decoded: Variant = Protocol._decode_entity(raw)
|
||||
assert_that(decoded).is_not_null()
|
||||
assert_that(decoded.relationship).is_equal(rel)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 6: GameState — Relationship Data Passthrough
|
||||
# ==============================================================================
|
||||
|
||||
func test_game_state_preserves_relationship() -> void:
|
||||
var snapshot: Dictionary = {
|
||||
"tick": 1,
|
||||
"entities": [_make_entity(2, "Npc", "Friendly")],
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_that(GameState.visible_entities.size()).is_equal(1)
|
||||
assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly")
|
||||
|
||||
func test_game_state_relationship_changes_between_snapshots() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [_make_entity(2, "Npc", "Friendly")]})
|
||||
assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly")
|
||||
GameState.apply_snapshot({"tick": 2, "entities": [_make_entity(2, "Npc", "PersonOfInterest")]})
|
||||
assert_that(GameState.visible_entities[0].relationship).is_equal("PersonOfInterest")
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Helpers
|
||||
# ==============================================================================
|
||||
|
||||
func _entity_uses_relationship(renderer: Node2D) -> bool:
|
||||
var friendly: Dictionary = _make_entity(10, "Npc", "Friendly", 3.0, 3.0)
|
||||
var hostile: Dictionary = _make_entity(11, "Npc", "Hostile", 5.0, 5.0)
|
||||
renderer.update_entities([friendly, hostile])
|
||||
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
|
||||
return false
|
||||
var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect
|
||||
var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect
|
||||
var f_color: Color = f_node.color
|
||||
var h_color: Color = h_node.color
|
||||
var uses_rel: bool = not f_color.is_equal_approx(h_color)
|
||||
renderer.update_entities([])
|
||||
return uses_rel
|
||||
|
||||
func _renderer_has_tween_support(renderer: Node2D) -> bool:
|
||||
return renderer.get("_entity_tweens") != null
|
||||
@@ -0,0 +1,288 @@
|
||||
## #501: Hub teleport client UX — QA test suite
|
||||
## Spec refs: D-020 (protocol), D-030 (testability)
|
||||
## Sprint Completion Proof (joint.md):
|
||||
## - Home key sends TeleportToHub in Gauntlet mode
|
||||
## - 0.3s fade-to-black-and-back plays on teleport
|
||||
## - Dialogue/monologue/interaction buffer cleared on teleport
|
||||
## - Non-Gauntlet: action rejected, client shows no effect
|
||||
class_name TestHubTeleport
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- Fixtures ------------------------------------------------------------------
|
||||
|
||||
var _gauntlet_snapshot := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
|
||||
"player_facing": "North",
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"},
|
||||
],
|
||||
"tiles": [],
|
||||
"visible_tiles": [],
|
||||
"visible_positions": [],
|
||||
"nearby_interactions": [],
|
||||
"current_monologue": null,
|
||||
"current_dialogue": null,
|
||||
"pending_recognitions": [],
|
||||
"gauntlet_mode": true,
|
||||
"room_id": "proof_room",
|
||||
}
|
||||
|
||||
var _normal_snapshot := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
|
||||
"player_facing": "North",
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"},
|
||||
],
|
||||
"tiles": [],
|
||||
"visible_tiles": [],
|
||||
"visible_positions": [],
|
||||
"nearby_interactions": [],
|
||||
"current_monologue": null,
|
||||
"current_dialogue": null,
|
||||
"pending_recognitions": [],
|
||||
}
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.gauntlet_mode = false
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
GameState.room_id = null
|
||||
InputMapper.input_queue.clear()
|
||||
SimBridge.reset_test_state()
|
||||
|
||||
|
||||
# -- InputMapper: TELEPORT_HUB action enum ------------------------------------
|
||||
|
||||
func test_teleport_hub_action_exists() -> void:
|
||||
# Verify the enum value exists and is distinct
|
||||
var action: int = InputMapper.Action.TELEPORT_HUB
|
||||
assert_that(action).is_not_equal(InputMapper.Action.INTERACT)
|
||||
assert_that(action).is_not_equal(InputMapper.Action.MOVE_NORTH)
|
||||
|
||||
|
||||
# -- InputMapper: Gauntlet mode guard -----------------------------------------
|
||||
|
||||
func test_teleport_hub_blocked_outside_gauntlet() -> void:
|
||||
# Non-gauntlet: Home key input should NOT queue TELEPORT_HUB
|
||||
GameState.gauntlet_mode = false
|
||||
InputMapper.input_queue.clear()
|
||||
var event := InputEventKey.new()
|
||||
event.physical_keycode = KEY_HOME
|
||||
event.pressed = true
|
||||
InputMapper._unhandled_input(event)
|
||||
var has_teleport := false
|
||||
for entry in InputMapper.input_queue:
|
||||
if entry.action == InputMapper.Action.TELEPORT_HUB:
|
||||
has_teleport = true
|
||||
assert_that(has_teleport).is_false()
|
||||
|
||||
|
||||
func test_teleport_hub_allowed_in_gauntlet_mode() -> void:
|
||||
# Gauntlet mode: Home key SHOULD queue TELEPORT_HUB
|
||||
GameState.gauntlet_mode = true
|
||||
InputMapper.input_queue.clear()
|
||||
var event := InputEventKey.new()
|
||||
event.physical_keycode = KEY_HOME
|
||||
event.pressed = true
|
||||
InputMapper._unhandled_input(event)
|
||||
var has_teleport := false
|
||||
for entry in InputMapper.input_queue:
|
||||
if entry.action == InputMapper.Action.TELEPORT_HUB:
|
||||
has_teleport = true
|
||||
assert_that(has_teleport).is_true()
|
||||
|
||||
|
||||
# -- GameState: gauntlet_mode from snapshot ------------------------------------
|
||||
|
||||
func test_gauntlet_mode_set_from_snapshot() -> void:
|
||||
GameState.apply_snapshot(_gauntlet_snapshot)
|
||||
assert_that(GameState.gauntlet_mode).is_true()
|
||||
assert_that(GameState.room_id).is_equal("proof_room")
|
||||
|
||||
|
||||
func test_gauntlet_mode_false_when_absent() -> void:
|
||||
GameState.apply_snapshot(_normal_snapshot)
|
||||
assert_that(GameState.gauntlet_mode).is_false()
|
||||
assert_that(GameState.room_id).is_null()
|
||||
|
||||
|
||||
func test_gauntlet_mode_transitions_off() -> void:
|
||||
# Gauntlet on → off: mode should clear
|
||||
GameState.apply_snapshot(_gauntlet_snapshot)
|
||||
assert_that(GameState.gauntlet_mode).is_true()
|
||||
GameState.apply_snapshot(_normal_snapshot)
|
||||
assert_that(GameState.gauntlet_mode).is_false()
|
||||
|
||||
|
||||
# -- SimBridge: wire format encoding -------------------------------------------
|
||||
|
||||
func test_teleport_hub_wire_name() -> void:
|
||||
# TELEPORT_HUB must encode to "TeleportToHub" on the wire (matching Rust PlayerAction)
|
||||
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
|
||||
assert_that(wire_name).is_equal("TeleportToHub")
|
||||
|
||||
|
||||
func test_teleport_hub_wire_not_empty() -> void:
|
||||
# Wire name must not be empty (empty = client-only, not sent to server)
|
||||
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
|
||||
assert_that(wire_name.is_empty()).is_false()
|
||||
|
||||
|
||||
func test_teleport_hub_encode_roundtrip() -> void:
|
||||
# Verify MessagePack encode→decode roundtrip for TeleportToHub
|
||||
var encoded: PackedByteArray = Protocol.encode_player_input(42, "TeleportToHub")
|
||||
assert_that(encoded.size()).is_greater(0)
|
||||
var decoded: Variant = Protocol.decode_player_input(encoded)
|
||||
assert_that(decoded).is_not_null()
|
||||
assert_that(decoded.tick).is_equal(42)
|
||||
assert_that(decoded.action.variant).is_equal("TeleportToHub")
|
||||
assert_that(decoded.action.data).is_null()
|
||||
|
||||
|
||||
# -- SimBridge: test mode teleport behavior ------------------------------------
|
||||
|
||||
func test_test_mode_teleport_resets_position() -> void:
|
||||
# In test mode, TeleportToHub should reset player to hub spawn (10, 10)
|
||||
SimBridge.reset_test_state()
|
||||
# Move player away first
|
||||
SimBridge._test_player_pos = Vector2i(50, 50)
|
||||
SimBridge._test_input_queue.append("TeleportToHub")
|
||||
var snap: Dictionary = SimBridge._test_snapshot()
|
||||
# Player should be back at hub spawn
|
||||
var player_entity: Dictionary = snap.entities[0]
|
||||
assert_that(player_entity.x).is_equal(10.0)
|
||||
assert_that(player_entity.y).is_equal(10.0)
|
||||
|
||||
|
||||
func test_test_mode_teleport_clears_dialogue() -> void:
|
||||
# TeleportToHub in test mode should clear dialogue state
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._test_in_dialogue = true
|
||||
SimBridge._test_input_queue.append("TeleportToHub")
|
||||
SimBridge._test_snapshot()
|
||||
assert_that(SimBridge._test_in_dialogue).is_false()
|
||||
|
||||
|
||||
# -- Teleport detection -------------------------------------------------------
|
||||
# Threshold constant lives on the main scene node (TELEPORT_DISTANCE_THRESHOLD = 5.0).
|
||||
# These tests verify the distance math against that threshold.
|
||||
|
||||
const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
|
||||
|
||||
func test_detect_teleport_large_jump() -> void:
|
||||
# Position jump > threshold should be detected as teleport
|
||||
var old_pos := Vector2(10.0, 10.0)
|
||||
var new_pos := Vector2(50.0, 50.0)
|
||||
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true()
|
||||
|
||||
|
||||
func test_detect_teleport_normal_movement() -> void:
|
||||
# Normal 1-tile movement should NOT be detected as teleport
|
||||
var old_pos := Vector2(10.0, 10.0)
|
||||
var new_pos := Vector2(11.0, 10.0)
|
||||
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
|
||||
|
||||
|
||||
func test_detect_teleport_diagonal_movement() -> void:
|
||||
# Diagonal movement (1,1) — distance ~1.41, not a teleport
|
||||
var old_pos := Vector2(10.0, 10.0)
|
||||
var new_pos := Vector2(11.0, 11.0)
|
||||
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
|
||||
|
||||
|
||||
func test_detect_teleport_boundary_exactly_threshold() -> void:
|
||||
# Exactly threshold — should NOT trigger (> not >=)
|
||||
var old_pos := Vector2(10.0, 10.0)
|
||||
var new_pos := Vector2(15.0, 10.0)
|
||||
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
|
||||
|
||||
|
||||
func test_detect_teleport_boundary_just_over() -> void:
|
||||
# Just over threshold — should trigger
|
||||
var old_pos := Vector2(10.0, 10.0)
|
||||
var new_pos := Vector2(15.1, 10.0)
|
||||
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true()
|
||||
|
||||
|
||||
# -- Buffer clearing on teleport -----------------------------------------------
|
||||
|
||||
func test_teleport_clears_dialogue_in_test_mode() -> void:
|
||||
# TeleportToHub in test mode should clear dialogue state via the pipeline
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._test_in_dialogue = true
|
||||
SimBridge._test_input_queue.append("TeleportToHub")
|
||||
var snap: Dictionary = SimBridge._test_snapshot()
|
||||
# Dialogue should be cleared by teleport
|
||||
assert_that(SimBridge._test_in_dialogue).is_false()
|
||||
assert_that(snap.current_dialogue).is_null()
|
||||
|
||||
|
||||
func test_teleport_resets_position_in_test_mode() -> void:
|
||||
# TeleportToHub must reset to hub spawn and clear dialogue (integration)
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._test_player_pos = Vector2i(50, 50)
|
||||
SimBridge._test_in_dialogue = true
|
||||
SimBridge._test_input_queue.append("TeleportToHub")
|
||||
var snap: Dictionary = SimBridge._test_snapshot()
|
||||
var player: Dictionary = snap.entities[0]
|
||||
assert_that(player.x).is_equal(10.0)
|
||||
assert_that(player.y).is_equal(10.0)
|
||||
assert_that(SimBridge._test_in_dialogue).is_false()
|
||||
|
||||
|
||||
# -- Send input integration (test mode) ----------------------------------------
|
||||
|
||||
func test_send_teleport_hub_in_test_mode() -> void:
|
||||
# Verify send_input accepts TELEPORT_HUB in test mode
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge.state = SimBridge.ConnectionState.CONNECTED
|
||||
var err := SimBridge.send_input({
|
||||
"action": InputMapper.Action.TELEPORT_HUB,
|
||||
"timestamp_msec": 12345,
|
||||
})
|
||||
assert_that(err).is_equal(OK)
|
||||
|
||||
|
||||
func test_send_teleport_hub_queues_wire_action() -> void:
|
||||
# Verify TELEPORT_HUB is queued as "TeleportToHub" in test mode
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge.state = SimBridge.ConnectionState.CONNECTED
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.TELEPORT_HUB,
|
||||
"timestamp_msec": 12345,
|
||||
})
|
||||
assert_that(SimBridge._test_input_queue.has("TeleportToHub")).is_true()
|
||||
|
||||
|
||||
func test_gauntlet_mode_from_test_snapshot() -> void:
|
||||
# Verify test snapshot includes gauntlet_mode field
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._test_gauntlet_mode = true
|
||||
var snap: Dictionary = SimBridge._test_snapshot()
|
||||
assert_that(snap.gauntlet_mode).is_true()
|
||||
SimBridge._test_gauntlet_mode = false
|
||||
snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.gauntlet_mode).is_false()
|
||||
|
||||
|
||||
# -- Live mode outbound encoding -----------------------------------------------
|
||||
|
||||
func test_teleport_hub_outbound_entry() -> void:
|
||||
# In live mode, TELEPORT_HUB should produce a valid outbound buffer entry
|
||||
# (We can't test full live mode in unit tests, but we test the encode path)
|
||||
var encoded: PackedByteArray = Protocol.encode_player_input(100, "TeleportToHub")
|
||||
assert_that(encoded.size()).is_greater(0)
|
||||
# Decode and verify
|
||||
var decoded: Variant = Protocol.decode_player_input(encoded)
|
||||
assert_that(decoded.action.variant).is_equal("TeleportToHub")
|
||||
@@ -0,0 +1,119 @@
|
||||
## D-054 facing and movement tests — _angle_to_octant, _snap_to_octant_dir,
|
||||
## _wasd_to_world_dir coverage. All functions are static or use only facing_angle.
|
||||
##
|
||||
## Spec ref: D-054 (mouse-relative facing), Sprint 10 Completion Proof.
|
||||
class_name TestInputMapperFacing
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- _angle_to_octant ----------------------------------------------------------
|
||||
|
||||
func test_angle_to_octant_east() -> void:
|
||||
assert_str(InputMapper._angle_to_octant(0.0)).is_equal("East")
|
||||
|
||||
func test_angle_to_octant_north() -> void:
|
||||
assert_str(InputMapper._angle_to_octant(-PI / 2.0)).is_equal("North")
|
||||
|
||||
func test_angle_to_octant_south() -> void:
|
||||
assert_str(InputMapper._angle_to_octant(PI / 2.0)).is_equal("South")
|
||||
|
||||
func test_angle_to_octant_west() -> void:
|
||||
assert_str(InputMapper._angle_to_octant(PI)).is_equal("West")
|
||||
|
||||
func test_angle_to_octant_northeast() -> void:
|
||||
assert_str(InputMapper._angle_to_octant(-PI / 4.0)).is_equal("Northeast")
|
||||
|
||||
func test_angle_to_octant_southeast() -> void:
|
||||
assert_str(InputMapper._angle_to_octant(PI / 4.0)).is_equal("Southeast")
|
||||
|
||||
func test_angle_to_octant_southwest() -> void:
|
||||
assert_str(InputMapper._angle_to_octant(3.0 * PI / 4.0)).is_equal("Southwest")
|
||||
|
||||
func test_angle_to_octant_northwest() -> void:
|
||||
assert_str(InputMapper._angle_to_octant(-3.0 * PI / 4.0)).is_equal("Northwest")
|
||||
|
||||
|
||||
# -- _snap_to_octant_dir -------------------------------------------------------
|
||||
|
||||
func test_snap_east() -> void:
|
||||
assert_object(InputMapper._snap_to_octant_dir(Vector2(1.0, 0.0))).is_equal(Vector2i(1, 0))
|
||||
|
||||
func test_snap_north() -> void:
|
||||
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, -1.0))).is_equal(Vector2i(0, -1))
|
||||
|
||||
func test_snap_south() -> void:
|
||||
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, 1.0))).is_equal(Vector2i(0, 1))
|
||||
|
||||
func test_snap_west() -> void:
|
||||
assert_object(InputMapper._snap_to_octant_dir(Vector2(-1.0, 0.0))).is_equal(Vector2i(-1, 0))
|
||||
|
||||
func test_snap_northeast() -> void:
|
||||
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.7, -0.7))).is_equal(Vector2i(1, -1))
|
||||
|
||||
func test_snap_southwest() -> void:
|
||||
assert_object(InputMapper._snap_to_octant_dir(Vector2(-0.7, 0.7))).is_equal(Vector2i(-1, 1))
|
||||
|
||||
func test_snap_zero_returns_zero() -> void:
|
||||
assert_object(InputMapper._snap_to_octant_dir(Vector2.ZERO)).is_equal(Vector2i.ZERO)
|
||||
|
||||
func test_snap_tiny_returns_zero() -> void:
|
||||
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.001, 0.0))).is_equal(Vector2i.ZERO)
|
||||
|
||||
func test_snap_diagonal_bias() -> void:
|
||||
# Slightly more east than north — should snap to northeast
|
||||
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.8, -0.6))).is_equal(Vector2i(1, -1))
|
||||
|
||||
|
||||
# -- _wasd_to_world_dir --------------------------------------------------------
|
||||
|
||||
func test_wasd_forward_facing_east() -> void:
|
||||
# W pressed, facing east → move east
|
||||
InputMapper.facing_angle = 0.0 # East
|
||||
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
|
||||
assert_object(result).is_equal(Vector2i(1, 0))
|
||||
|
||||
func test_wasd_forward_facing_north() -> void:
|
||||
# W pressed, facing north → move north
|
||||
InputMapper.facing_angle = -PI / 2.0 # North
|
||||
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
|
||||
assert_object(result).is_equal(Vector2i(0, -1))
|
||||
|
||||
func test_wasd_backward_facing_north() -> void:
|
||||
# S pressed, facing north → move south
|
||||
InputMapper.facing_angle = -PI / 2.0 # North
|
||||
var result := InputMapper._wasd_to_world_dir(Vector2i(0, 1))
|
||||
assert_object(result).is_equal(Vector2i(0, 1))
|
||||
|
||||
func test_wasd_strafe_right_facing_north() -> void:
|
||||
# D pressed, facing north → move east
|
||||
InputMapper.facing_angle = -PI / 2.0 # North
|
||||
var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0))
|
||||
assert_object(result).is_equal(Vector2i(1, 0))
|
||||
|
||||
func test_wasd_strafe_left_facing_north() -> void:
|
||||
# A pressed, facing north → move west
|
||||
InputMapper.facing_angle = -PI / 2.0 # North
|
||||
var result := InputMapper._wasd_to_world_dir(Vector2i(-1, 0))
|
||||
assert_object(result).is_equal(Vector2i(-1, 0))
|
||||
|
||||
func test_wasd_forward_facing_south() -> void:
|
||||
# W pressed, facing south → move south
|
||||
InputMapper.facing_angle = PI / 2.0 # South
|
||||
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
|
||||
assert_object(result).is_equal(Vector2i(0, 1))
|
||||
|
||||
func test_wasd_diagonal_forward_right_facing_east() -> void:
|
||||
# W+D pressed, facing east → move southeast
|
||||
InputMapper.facing_angle = 0.0 # East
|
||||
var result := InputMapper._wasd_to_world_dir(Vector2i(1, -1))
|
||||
assert_object(result).is_equal(Vector2i(1, 1))
|
||||
|
||||
func test_wasd_strafe_right_facing_west() -> void:
|
||||
# D pressed, facing west → move north
|
||||
InputMapper.facing_angle = PI # West
|
||||
var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0))
|
||||
assert_object(result).is_equal(Vector2i(0, -1))
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
InputMapper.reset_facing_state()
|
||||
@@ -298,25 +298,30 @@ func test_entity_renderer_player_has_facing_indicator() -> void:
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_renderer_facing_indicator_rotation_accuracy() -> void:
|
||||
# D-054: Facing indicator now reads InputMapper.facing_angle (float), not
|
||||
# GameState.player_facing (string). Indicator rotation = facing_angle + PI/2.
|
||||
GameState.player_entity_id = 1
|
||||
var renderer := _make_entity_renderer()
|
||||
var directions := {
|
||||
"North": 0.0,
|
||||
"Northeast": PI / 4.0,
|
||||
"East": PI / 2.0,
|
||||
"Southeast": 3.0 * PI / 4.0,
|
||||
"South": PI,
|
||||
"Southwest": 5.0 * PI / 4.0,
|
||||
"West": 3.0 * PI / 2.0,
|
||||
"Northwest": 7.0 * PI / 4.0,
|
||||
# {facing_angle → expected indicator rotation}
|
||||
# Indicator 0 = North (up). facing_angle 0 = East. So rotation = angle + PI/2.
|
||||
var angles := {
|
||||
-PI / 2.0: 0.0, # North
|
||||
-PI / 4.0: PI / 4.0, # Northeast
|
||||
0.0: PI / 2.0, # East
|
||||
PI / 4.0: 3.0 * PI / 4.0, # Southeast
|
||||
PI / 2.0: PI, # South
|
||||
3.0 * PI / 4.0: -3.0 * PI / 4.0, # Southwest (Godot normalizes to (-PI, PI])
|
||||
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2)
|
||||
-3.0 * PI / 4.0: -PI / 4.0, # Northwest (-3PI/4 + PI/2 = -PI/4)
|
||||
}
|
||||
renderer.update_entities(_test_entities_v2)
|
||||
var player_node = renderer.entity_nodes[1]
|
||||
var indicator = player_node.get_node_or_null("FacingIndicator")
|
||||
for dir_name in directions:
|
||||
GameState.player_facing = dir_name
|
||||
for angle in angles:
|
||||
InputMapper.facing_angle = angle
|
||||
renderer.update_entities(_test_entities_v2)
|
||||
assert_that(indicator.rotation).is_equal_approx(directions[dir_name], 0.001)
|
||||
assert_that(indicator.rotation).is_equal_approx(angles[angle], 0.001)
|
||||
InputMapper.facing_angle = -PI / 2.0 # Reset to default (North)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_renderer_npc_has_no_facing_indicator() -> void:
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
extends Control
|
||||
|
||||
## #503: Auto-checklist HUD overlay — shows condition progress in Gauntlet mode.
|
||||
## Renders below the GauntletHUD timer. Each condition shows a check/dash + description.
|
||||
## Only visible in gauntlet_mode. Latched conditions stay checked.
|
||||
##
|
||||
## Spec ref: D-030 (testability), #503, Sprint 10 Completion Proof.
|
||||
|
||||
const _ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd")
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.45)
|
||||
const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met
|
||||
const UNMET_COLOR := Color("#8890a0") # Dim grey — condition pending
|
||||
const HEADER_COLOR := Color("#c8d0e0") # Insert text color — header/summary
|
||||
const COMPLETE_COLOR := Color("#e8c547") # Amber — all conditions met
|
||||
const FONT_SIZE := 11
|
||||
const LINE_HEIGHT := 16
|
||||
const PADDING := Vector2(8, 6)
|
||||
const MAX_DESC_CHARS := 52 # Truncate long descriptions
|
||||
|
||||
var _evaluator = null # ChecklistEvaluator instance
|
||||
var _last_room_id: Variant = null
|
||||
var _cached_font: Font = null # Cached to avoid per-frame theme lookup
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
visible = false
|
||||
_evaluator = _ChecklistEvaluator.new()
|
||||
_cached_font = get_theme_default_font()
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
if not GameState.gauntlet_mode:
|
||||
if visible:
|
||||
visible = false
|
||||
return
|
||||
|
||||
if not visible:
|
||||
visible = true
|
||||
|
||||
var room_id: Variant = GameState.room_id
|
||||
if room_id == null:
|
||||
if _evaluator.is_loaded():
|
||||
_evaluator.reset()
|
||||
_last_room_id = null
|
||||
queue_redraw()
|
||||
return
|
||||
|
||||
# Load checklist on room change
|
||||
if room_id != _last_room_id:
|
||||
_evaluator.load_room(str(room_id))
|
||||
_last_room_id = room_id
|
||||
|
||||
# Evaluate conditions against current snapshot
|
||||
_evaluator.evaluate()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if _evaluator == null or not _evaluator.is_loaded():
|
||||
return
|
||||
|
||||
var font: Font = _cached_font if _cached_font else get_theme_default_font()
|
||||
var results: Array = _evaluator.get_results()
|
||||
if results.is_empty():
|
||||
return
|
||||
|
||||
var met_count: int = _evaluator.get_met_count()
|
||||
var total_count: int = _evaluator.get_total_count()
|
||||
var all_complete: bool = _evaluator.is_complete()
|
||||
|
||||
# Header line: "CHECK: 5/8"
|
||||
var header_text := "CHECK: %d/%d" % [met_count, total_count]
|
||||
var header_color: Color = COMPLETE_COLOR if all_complete else HEADER_COLOR
|
||||
|
||||
# Calculate box height: header + one line per condition + padding
|
||||
var line_count: int = 1 + results.size()
|
||||
var box_height: float = PADDING.y * 2 + line_count * LINE_HEIGHT
|
||||
|
||||
# Calculate box width from longest line
|
||||
var max_width: float = font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
for r in results:
|
||||
var desc: String = r.get("description", r.get("id", ""))
|
||||
if desc.length() > MAX_DESC_CHARS:
|
||||
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
|
||||
var prefix: String = "[x] " if r.get("met", false) else "[ ] "
|
||||
var line_width: float = font.get_string_size(prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
if line_width > max_width:
|
||||
max_width = line_width
|
||||
|
||||
var box_width: float = max_width + PADDING.x * 2
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(box_width, box_height)), BG_COLOR)
|
||||
|
||||
# Header
|
||||
var y: float = PADDING.y + FONT_SIZE
|
||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, header_color)
|
||||
|
||||
# Condition lines
|
||||
for r in results:
|
||||
y += LINE_HEIGHT
|
||||
var is_met: bool = r.get("met", false)
|
||||
var prefix: String = "[x] " if is_met else "[ ] "
|
||||
var desc: String = r.get("description", r.get("id", ""))
|
||||
if desc.length() > MAX_DESC_CHARS:
|
||||
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
|
||||
var color: Color = MET_COLOR if is_met else UNMET_COLOR
|
||||
draw_string(font, Vector2(PADDING.x, y), prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color)
|
||||
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
func get_evaluator():
|
||||
return _evaluator
|
||||
|
||||
|
||||
func get_met_count() -> int:
|
||||
if _evaluator == null:
|
||||
return 0
|
||||
return _evaluator.get_met_count()
|
||||
|
||||
|
||||
func get_total_count() -> int:
|
||||
if _evaluator == null:
|
||||
return 0
|
||||
return _evaluator.get_total_count()
|
||||
|
||||
|
||||
func is_complete() -> bool:
|
||||
if _evaluator == null:
|
||||
return false
|
||||
return _evaluator.is_complete()
|
||||
@@ -0,0 +1,17 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/checklist_overlay.gd" id="1_checklist"]
|
||||
|
||||
; #503: Auto-checklist overlay — below GauntletHUD timer, right-aligned
|
||||
[node name="ChecklistOverlay" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -420.0
|
||||
offset_top = 78.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = 400.0
|
||||
grow_horizontal = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_checklist")
|
||||
@@ -16,8 +16,8 @@ const FADE_IN := 0.12
|
||||
const FADE_OUT := 0.10
|
||||
const LABEL_HEIGHT := 22
|
||||
const LABEL_GAP := 2
|
||||
const INSERT_FG := Color("#c8d0e0")
|
||||
const INSERT_DIM := Color("#8b8ba0")
|
||||
const INSERT_FG := Constants.IMPLANT_TEXT_COLOR
|
||||
const INSERT_DIM := Constants.IMPLANT_TEXT_DIM
|
||||
const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7)
|
||||
|
||||
var _showing: bool = false
|
||||
|
||||
+34
-15
@@ -4,7 +4,9 @@ extends Control
|
||||
## Insert-styled: geometric lines, thin spokes, nearly transparent.
|
||||
## Renders on InsertOverlay (CanvasLayer 10).
|
||||
## Drag-release for power users, click-click for newcomers.
|
||||
## Insert spoke sends Pause on activate, Pause again on close (toggle).
|
||||
## Insert spoke sends PauseSimulation on activate (#518/D-058).
|
||||
## Selecting any non-Insert spoke, cancelling, or pressing Escape calls
|
||||
## deactivate_insert() which sends ResumeSimulation. Pause/resume are idempotent.
|
||||
|
||||
signal spoke_selected(spoke_name: String)
|
||||
|
||||
@@ -35,6 +37,7 @@ var _origin: Vector2 = Vector2.ZERO
|
||||
var _hovered_spoke: int = Spoke.NONE
|
||||
var _drag_mode: bool = false
|
||||
var _insert_active: bool = false
|
||||
var _cached_font: Font = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -43,6 +46,7 @@ func _ready() -> void:
|
||||
size = custom_minimum_size
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_cached_font = get_theme_default_font()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
@@ -83,6 +87,15 @@ func _close_menu() -> void:
|
||||
visible = false
|
||||
|
||||
|
||||
# Handle Escape key to dismiss menu and deactivate insert if active.
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if _open and event.is_action_pressed("ui_cancel"):
|
||||
if _insert_active:
|
||||
deactivate_insert()
|
||||
_close_menu()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _update_hover(mouse_pos: Vector2) -> void:
|
||||
var delta := mouse_pos - _origin
|
||||
var dist := delta.length()
|
||||
@@ -115,29 +128,36 @@ func _confirm_selection() -> void:
|
||||
|
||||
if _hovered_spoke == Spoke.INSERT:
|
||||
_activate_insert()
|
||||
elif _insert_active:
|
||||
# Selecting any non-Insert spoke closes the insert and resumes sim
|
||||
deactivate_insert()
|
||||
|
||||
elif _insert_active:
|
||||
# No spoke selected (cancelled) while insert active — close insert
|
||||
deactivate_insert()
|
||||
|
||||
_close_menu()
|
||||
|
||||
|
||||
func _activate_insert() -> void:
|
||||
# TODO(v7): replace PAUSE toggle with dedicated ToggleInsert action in protocol
|
||||
# #518/D-058: Send PauseSimulation when insert opens. Idempotent —
|
||||
# if already paused (e.g. Gauntlet interlude), server ignores duplicate.
|
||||
if not _insert_active:
|
||||
_insert_active = true
|
||||
_send_pause()
|
||||
|
||||
|
||||
func _send_pause() -> void:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.PAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.PAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
|
||||
func deactivate_insert() -> void:
|
||||
# Called when closing insert view — send Pause again (toggle)
|
||||
# #518/D-058: Send ResumeSimulation when insert closes.
|
||||
if _insert_active:
|
||||
_insert_active = false
|
||||
_send_pause()
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.UNPAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
@@ -174,10 +194,9 @@ func _draw() -> void:
|
||||
|
||||
# Label
|
||||
var label: String = SPOKE_NAMES.get(spoke, "")
|
||||
var font := ThemeDB.fallback_font
|
||||
var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11)
|
||||
var text_size := _cached_font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11)
|
||||
var label_pos := icon_center + Vector2(-text_size.x / 2.0, ICON_SIZE + 14.0)
|
||||
draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color)
|
||||
draw_string(_cached_font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color)
|
||||
|
||||
|
||||
func _draw_spoke_icon(spoke: int, center: Vector2, color: Color) -> void:
|
||||
|
||||
+12
-1
@@ -152,6 +152,17 @@ Tracked questions awaiting discussion or resolution.
|
||||
- **Assigned to:** Gestalt, Paula, Dudley
|
||||
- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt/Paula Round 1)
|
||||
|
||||
### Q-027: Fast-travel system design
|
||||
- **Status:** Open
|
||||
- **Question:** How does inter-system travel work in production gameplay? The current hub teleport (Home key, #501) is scoped as Gauntlet-only dev tool. Production travel must be diegetic and respect asymmetric information. Proposed flow: player goes to local gate → warps to system gate → interacts with target menu → jumps to destination system gate. Key constraints:
|
||||
1. **Region gating:** fast-travel only available from safe or fast-travel-enabled regions. If you rented transport to reach a remote location (e.g. mountain colony), you must return the transport to civilization first — this can be a skip-travel interaction but must happen in-world.
|
||||
2. **Asymmetric information:** NPCs observe arrivals and departures. Travel choices leak information (who saw you leave, who sees you arrive, what transport was used).
|
||||
3. **Home key in production:** at most, Home could prompt "Do you want to fast-travel to the system hub?" if in a safe/enabled region — never instant teleport.
|
||||
4. **Transport types:** walking, rented vehicle, public transit, gate network — each with different information exposure profiles.
|
||||
- **Context:** #501 implemented instant Home key teleport gated behind `gauntlet_mode`. Re-scoped to Gauntlet-only after design review. Production fast-travel needs separate design and implementation.
|
||||
- **Assigned to:** Gestalt, Paula, Tyre
|
||||
- **Source:** Sprint 10 PR review discussion (2026-02-19)
|
||||
|
||||
---
|
||||
|
||||
*26 questions (3 resolved, 2 partially resolved, 21 open). Last updated: 2026-02-11*
|
||||
*27 questions (3 resolved, 2 partially resolved, 22 open). Last updated: 2026-02-19*
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: The Settled Reach
|
||||
version: 0.1.9
|
||||
version: 0.1.10
|
||||
repository: settled-reach
|
||||
codename: commonwealth
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -978,7 +978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.0"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -178,11 +178,14 @@ impl Plugin for BridgePlugin {
|
||||
crate::simulation::dialogue::process_walk_away
|
||||
.after(crate::simulation::input::process_player_input)
|
||||
.after(crate::simulation::dialogue::process_talk_interaction),
|
||||
crate::simulation::dialogue::process_confrontation_response
|
||||
.after(crate::simulation::input::process_player_input),
|
||||
crate::perception::observer::compute_observer_snapshot
|
||||
.after(crate::perception::observer::compute_visibility_geometry)
|
||||
.after(crate::simulation::interaction::compute_nearby_interactions)
|
||||
.after(crate::simulation::monologue::process_sprint_anomaly_monologue)
|
||||
.after(crate::simulation::dialogue::process_talk_interaction)
|
||||
.after(crate::simulation::dialogue::process_confrontation_response)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
crate::perception::observation::emit_observation_events
|
||||
.after(crate::perception::observer::compute_observer_snapshot),
|
||||
|
||||
@@ -159,6 +159,12 @@ pub fn format_snapshot_text(snapshot: &ObserverSnapshot) -> String {
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Blocked entities (debug, #514)
|
||||
if !snapshot.blocked_entities.is_empty() {
|
||||
let ids: Vec<String> = snapshot.blocked_entities.iter().map(|id| id.to_string()).collect();
|
||||
writeln!(out, "Blocked (LOS): {} [{}]", snapshot.blocked_entities.len(), ids.join(", ")).ok();
|
||||
}
|
||||
|
||||
writeln!(out, "===").ok();
|
||||
out
|
||||
}
|
||||
@@ -281,6 +287,7 @@ mod tests {
|
||||
}),
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,10 +409,27 @@ mod tests {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
};
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Tick 0"));
|
||||
assert!(text.contains("Player (-1,-1)"));
|
||||
assert!(text.contains("Tiles: 0 visible"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_entities_rendered() {
|
||||
let mut snap = make_snapshot();
|
||||
snap.blocked_entities = vec![42, 99, 1024];
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Blocked (LOS): 3"));
|
||||
assert!(text.contains("[42, 99, 1024]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_entities_empty_not_rendered() {
|
||||
let snap = make_snapshot();
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(!text.contains("Blocked (LOS)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
||||
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
||||
/// period, then the default is removed once both sides are updated.
|
||||
pub const PROTOCOL_VERSION: u8 = 8;
|
||||
pub const PROTOCOL_VERSION: u8 = 9;
|
||||
|
||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||
/// Contains all information visible to the observer at a given tick.
|
||||
@@ -27,10 +27,11 @@ pub const PROTOCOL_VERSION: u8 = 8;
|
||||
/// v6 adds: player_stance (#449, D-053), player_inventory (#449, D-065).
|
||||
/// v7 adds: pending_recognitions (#423, D-060 cognitive delay).
|
||||
/// v8 adds: dialogue_response (#305, D-028 dialogue pipeline).
|
||||
/// v9 adds: blocked_entities (#514, debug field for LOS-blocked entities).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. Current: 6.
|
||||
/// Protocol version for forward compatibility. Current: 9.
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
@@ -68,6 +69,11 @@ pub struct ObserverSnapshot {
|
||||
/// Client shows speaker name + dialogue text in a dialogue box.
|
||||
#[serde(default)]
|
||||
pub dialogue_response: Option<DialogueResponseEvent>,
|
||||
/// Debug field: entity IDs on the same z-level that are not visible due to
|
||||
/// LOS obstruction or being outside the vision cone (#514).
|
||||
/// Sorted ascending for deterministic output. Client can safely ignore.
|
||||
#[serde(default)]
|
||||
pub blocked_entities: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Game time data for client display (D-031)
|
||||
@@ -316,6 +322,10 @@ pub enum PlayerAction {
|
||||
ToggleStanceUp,
|
||||
/// Move one step down the stance ladder (toward Crouch) per D-053
|
||||
ToggleStanceDown,
|
||||
/// Teleport player to the Gauntlet hub spawn point (#491).
|
||||
/// Clears dialogue, monologue, and interaction buffers.
|
||||
/// Rejected with a log warning on non-Gauntlet maps.
|
||||
TeleportToHub,
|
||||
}
|
||||
|
||||
impl PlayerAction {
|
||||
|
||||
@@ -129,6 +129,23 @@ pub enum RelationshipState {
|
||||
Hostile,
|
||||
}
|
||||
|
||||
impl RelationshipState {
|
||||
/// Decrement relationship state toward more negative (D-063 confrontation response).
|
||||
///
|
||||
/// Friendly → Known → PersonOfInterest → Hostile.
|
||||
/// Unknown stays Unknown (can't confront a stranger meaningfully).
|
||||
/// Hostile stays Hostile — floor, does not wrap or panic.
|
||||
pub fn decrement(self) -> Self {
|
||||
match self {
|
||||
Self::Friendly => Self::Known,
|
||||
Self::Known => Self::PersonOfInterest,
|
||||
Self::PersonOfInterest => Self::Hostile,
|
||||
Self::Unknown => Self::Unknown, // no-op: can't confront a stranger
|
||||
Self::Hostile => Self::Hostile, // floor: already worst state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Entity Knowledge ---
|
||||
|
||||
/// What entity A knows about entity B.
|
||||
|
||||
@@ -35,6 +35,42 @@ impl Plugin for NpcPlugin {
|
||||
#[derive(Component, Debug)]
|
||||
pub struct Npc;
|
||||
|
||||
/// NPC animation tier (D-047).
|
||||
///
|
||||
/// Tier 1 (clear): public daily activities — instantly readable.
|
||||
/// Tier 2 (ambiguous): privately motivated behaviors — player sees the action
|
||||
/// but cannot determine the intention.
|
||||
///
|
||||
/// NPCs start at Tier 1. Confrontation (D-063) and other triggers shift to Tier 2.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum AnimationTier {
|
||||
/// Clear, readable public activities (walking, working, talking).
|
||||
#[default]
|
||||
Tier1,
|
||||
/// Ambiguous, privately motivated behaviors (pausing, lingering, looking around).
|
||||
Tier2,
|
||||
}
|
||||
|
||||
/// Tracks why and when an NPC's routine deviated from normal (D-064 Phase 2).
|
||||
///
|
||||
/// Inserted when a player action causes an NPC to break from their scheduled
|
||||
/// behavior. Acts as a hook for the storyteller system and affects future
|
||||
/// interactions (e.g., second-approach dialogue differences).
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct RoutineDeviation {
|
||||
pub trigger: DeviationTrigger,
|
||||
pub tick: u64,
|
||||
}
|
||||
|
||||
/// What caused an NPC's routine deviation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum DeviationTrigger {
|
||||
/// Player walked away mid-dialogue (D-064 Phase 2).
|
||||
WalkAway,
|
||||
/// Player delivered a confrontation (D-063).
|
||||
Confrontation,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axis 1: Want (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -142,10 +142,8 @@ pub fn emit_observation_events(
|
||||
let pending_ids: Vec<crate::knowledge::types::StableId> =
|
||||
delay.pending().iter().map(|p| p.stable_id).collect();
|
||||
for sid in pending_ids {
|
||||
if !visible_stable_ids.contains(&sid.0) {
|
||||
if delay.cancel(&sid).is_some() {
|
||||
tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0);
|
||||
}
|
||||
if !visible_stable_ids.contains(&sid.0) && delay.cancel(&sid).is_some() {
|
||||
tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ pub fn compute_observer_snapshot(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let (mut entities, visible_ids) =
|
||||
let (mut entities, visible_ids, blocked_entities) =
|
||||
filter_visible_entities(&geometry, ®istry, observer_kg, &all_entities);
|
||||
|
||||
collect_remembered_entities(
|
||||
@@ -206,11 +206,13 @@ pub fn compute_observer_snapshot(
|
||||
current_monologue,
|
||||
pending_recognitions,
|
||||
dialogue_response,
|
||||
blocked_entities,
|
||||
});
|
||||
}
|
||||
|
||||
/// Filter entities by visibility using precomputed geometry.
|
||||
/// Returns (visible entities, set of visible wire IDs).
|
||||
/// Returns (visible entities, set of visible wire IDs, blocked entity IDs).
|
||||
/// Blocked entities are on the same z-level but not in visible_positions (#514).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn filter_visible_entities(
|
||||
geometry: &VisibilityGeometry,
|
||||
@@ -222,15 +224,31 @@ fn filter_visible_entities(
|
||||
Option<&PlayerCharacter>,
|
||||
Option<&crate::npc::Npc>,
|
||||
)>,
|
||||
) -> (Vec<VisibleEntity>, BTreeSet<u64>) {
|
||||
) -> (Vec<VisibleEntity>, BTreeSet<u64>, Vec<u64>) {
|
||||
let mut entities = Vec::new();
|
||||
let mut visible_ids: BTreeSet<u64> = BTreeSet::new();
|
||||
let mut blocked_ids: BTreeSet<u64> = BTreeSet::new();
|
||||
|
||||
for (entity, pos, is_player, is_npc) in all_entities.iter() {
|
||||
if pos.z != geometry.observer_z {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve wire ID early — needed for both visible and blocked paths
|
||||
let wire_id = registry
|
||||
.to_stable(entity)
|
||||
.map(|sid| sid.0)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::error!(?entity, "entity not in EntityRegistry");
|
||||
entity.to_bits()
|
||||
});
|
||||
|
||||
if !geometry.visible_positions.contains(&(pos.x, pos.y)) {
|
||||
// Same z-level but not visible — blocked by LOS or outside vision cone.
|
||||
// Exclude the player entity (always at origin, always visible).
|
||||
if is_player.is_none() {
|
||||
blocked_ids.insert(wire_id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -257,16 +275,6 @@ fn filter_visible_entities(
|
||||
RelationshipState::Unknown
|
||||
};
|
||||
|
||||
// Fallback to Entity::to_bits() is intentional for per-frame systems:
|
||||
// panicking would crash the server every tick. The error log makes this
|
||||
// loud enough to catch in testing while keeping the server alive.
|
||||
let wire_id = registry
|
||||
.to_stable(entity)
|
||||
.map(|sid| sid.0)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::error!(?entity, "entity visible but not in EntityRegistry");
|
||||
entity.to_bits()
|
||||
});
|
||||
visible_ids.insert(wire_id);
|
||||
entities.push(VisibleEntity {
|
||||
entity_id: wire_id,
|
||||
@@ -280,7 +288,9 @@ fn filter_visible_entities(
|
||||
});
|
||||
}
|
||||
|
||||
(entities, visible_ids)
|
||||
// BTreeSet iteration is sorted — deterministic output guaranteed
|
||||
let blocked_vec: Vec<u64> = blocked_ids.into_iter().collect();
|
||||
(entities, visible_ids, blocked_vec)
|
||||
}
|
||||
|
||||
/// Collect remembered entities from the knowledge graph — entities the observer
|
||||
|
||||
@@ -2030,3 +2030,213 @@ fn no_cognitive_delay_component_means_empty_pending_recognitions() {
|
||||
"no CognitiveDelay component should produce empty pending_recognitions"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// blocked_entities debug field tests (#514)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn blocked_entities_empty_when_all_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert!(
|
||||
snapshot.blocked_entities.is_empty(),
|
||||
"no blocked entities when NPC is in LOS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_behind_wall_appears_in_blocked_entities() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Wall between player and NPC
|
||||
world
|
||||
.resource_mut::<WalkabilityMap>()
|
||||
.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
|
||||
// NPC behind the wall
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert!(
|
||||
snapshot.blocked_entities.contains(&npc_sid.0),
|
||||
"NPC behind wall should appear in blocked_entities"
|
||||
);
|
||||
// Not in visible entities
|
||||
let npc_visible = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.any(|e| e.entity_id == npc_sid.0);
|
||||
assert!(!npc_visible, "NPC should not be in visible entities");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_behind_player_appears_in_blocked_entities() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC far behind player (south, outside vision cone)
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert!(
|
||||
snapshot.blocked_entities.contains(&npc_sid.0),
|
||||
"NPC in blind spot should appear in blocked_entities"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_z_level_not_in_blocked_entities() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC on a different z-level
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert!(
|
||||
!snapshot.blocked_entities.contains(&npc_sid.0),
|
||||
"NPC on different z-level should NOT be in blocked_entities"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_entities_sorted_ascending() {
|
||||
// Multiple blocked NPCs should appear in ascending entity_id order
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Wall blocks north
|
||||
world
|
||||
.resource_mut::<WalkabilityMap>()
|
||||
.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
|
||||
// Two NPCs behind wall + one behind player
|
||||
let npc_a = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)))
|
||||
.id();
|
||||
let npc_a_sid = registry.register(npc_a);
|
||||
|
||||
let npc_b = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 10, 0)))
|
||||
.id();
|
||||
let npc_b_sid = registry.register(npc_b);
|
||||
|
||||
let npc_c = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)))
|
||||
.id();
|
||||
let npc_c_sid = registry.register(npc_c);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
assert!(snapshot.blocked_entities.len() >= 3);
|
||||
// Must be sorted ascending (BTreeSet guarantee)
|
||||
for i in 1..snapshot.blocked_entities.len() {
|
||||
assert!(
|
||||
snapshot.blocked_entities[i - 1] < snapshot.blocked_entities[i],
|
||||
"blocked_entities not sorted: {:?}",
|
||||
snapshot.blocked_entities
|
||||
);
|
||||
}
|
||||
// All three NPCs should be present
|
||||
assert!(snapshot.blocked_entities.contains(&npc_a_sid.0));
|
||||
assert!(snapshot.blocked_entities.contains(&npc_b_sid.0));
|
||||
assert!(snapshot.blocked_entities.contains(&npc_c_sid.0));
|
||||
}
|
||||
|
||||
@@ -18,12 +18,13 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::bridge::types::{DialogueResponseEvent, RelationshipState};
|
||||
use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState};
|
||||
use crate::content::line_pool::{
|
||||
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
|
||||
};
|
||||
use crate::content::LinePoolIndexResource;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::simulation::monologue::{MonologueBuffer, MonologueState};
|
||||
use crate::simulation::movement::PlayerCharacter;
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
@@ -117,6 +118,18 @@ pub struct ActiveDialogue {
|
||||
#[derive(Component, Debug)]
|
||||
pub struct WalkAwayRequest;
|
||||
|
||||
/// Marker: player delivered a confrontation this tick (#520, D-063).
|
||||
///
|
||||
/// Set by process_player_input when Interact{verb: "Confront"} is received.
|
||||
/// Consumed by process_confrontation_response each tick. Triggers:
|
||||
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047)
|
||||
/// 2. Observer KG relationship state decremented (D-033 color fade)
|
||||
/// 3. Monologue spike event emitted
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ConfrontationDelivered {
|
||||
pub target: Entity,
|
||||
}
|
||||
|
||||
/// Buffer holding the dialogue response for snapshot inclusion.
|
||||
///
|
||||
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
|
||||
@@ -299,7 +312,7 @@ pub fn select_dialogue_line<'a>(
|
||||
/// line to DialogueResponseBuffer.
|
||||
///
|
||||
/// System ordering: after process_player_input, before compute_observer_snapshot.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
pub fn process_talk_interaction(
|
||||
mut commands: Commands,
|
||||
time: Res<SimulationTime>,
|
||||
@@ -478,13 +491,15 @@ pub fn process_talk_interaction(
|
||||
// System: process_walk_away (D-064)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Process walk-away requests during active dialogue.
|
||||
/// Process walk-away requests during active dialogue (D-064 Phases 2+3).
|
||||
///
|
||||
/// When the player moves (WASD) during an active dialogue, the client sends
|
||||
/// PlayerAction::WalkAway which sets WalkAwayRequest. This system:
|
||||
/// 1. Emits IncompleteInteraction knowledge event (recorded in KG)
|
||||
/// 2. Clears ActiveDialogue state
|
||||
/// 3. Removes the WalkAwayRequest marker
|
||||
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047 ambiguous animation)
|
||||
/// 2. NPC routine deviation recorded (storyteller hook)
|
||||
/// 3. Emits IncompleteInteraction knowledge event (recorded in KG)
|
||||
/// 4. Clears ActiveDialogue state
|
||||
/// 5. Removes the WalkAwayRequest marker
|
||||
///
|
||||
/// If no ActiveDialogue is present, removes WalkAwayRequest silently (no-op).
|
||||
///
|
||||
@@ -500,21 +515,38 @@ pub fn process_walk_away(
|
||||
};
|
||||
|
||||
if let Some(active_dialogue) = active_dialogue_opt {
|
||||
// Emit IncompleteInteraction knowledge event
|
||||
let target = active_dialogue.target;
|
||||
|
||||
// Phase 2, Effect 1: Shift target NPC to Tier 2 animation (D-047)
|
||||
commands
|
||||
.entity(target)
|
||||
.insert(crate::npc::AnimationTier::Tier2);
|
||||
|
||||
// Phase 2, Effect 2: Record routine deviation on target NPC
|
||||
commands
|
||||
.entity(target)
|
||||
.insert(crate::npc::RoutineDeviation {
|
||||
trigger: crate::npc::DeviationTrigger::WalkAway,
|
||||
tick: time.tick,
|
||||
});
|
||||
|
||||
// Phase 3: Emit IncompleteInteraction knowledge event
|
||||
event_queue.push(crate::knowledge::KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
tick: time.tick,
|
||||
event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction {
|
||||
target: active_dialogue.target,
|
||||
target,
|
||||
interaction_type: active_dialogue.interaction_type,
|
||||
},
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
"Walk-away during {:?} dialogue at tick {} (started tick {})",
|
||||
"Walk-away during {:?} dialogue at tick {} (started tick {}): \
|
||||
target {:?} → Tier2 animation + routine deviation",
|
||||
active_dialogue.interaction_type,
|
||||
time.tick,
|
||||
active_dialogue.started_tick,
|
||||
target,
|
||||
);
|
||||
|
||||
commands.entity(player_entity).remove::<ActiveDialogue>();
|
||||
@@ -525,6 +557,109 @@ pub fn process_walk_away(
|
||||
commands.entity(player_entity).remove::<WalkAwayRequest>();
|
||||
}
|
||||
|
||||
/// Hardcoded confrontation monologue lines (D-063).
|
||||
/// Fired as a monologue spike when the player delivers a confrontation.
|
||||
/// Future: move to content pools with trigger="confrontation_delivered".
|
||||
const CONFRONTATION_LINES: &[(&str, &str)] = &[
|
||||
(
|
||||
"confront_01",
|
||||
"That changed everything between us. No going back.",
|
||||
),
|
||||
(
|
||||
"confront_02",
|
||||
"The look on their face... they know I know.",
|
||||
),
|
||||
(
|
||||
"confront_03",
|
||||
"Cards on the table. Let's see what happens next.",
|
||||
),
|
||||
];
|
||||
|
||||
/// Process confrontation world response (#520, D-063).
|
||||
///
|
||||
/// Reads ConfrontationDelivered marker (set by input system), applies three
|
||||
/// server-authoritative effects:
|
||||
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047)
|
||||
/// 2. Observer's KG relationship state decremented (D-033 color fade)
|
||||
/// 3. Monologue spike: immediate monologue line bypassing cooldown
|
||||
///
|
||||
/// System ordering: after process_player_input, before compute_observer_snapshot.
|
||||
pub fn process_confrontation_response(
|
||||
mut commands: Commands,
|
||||
time: Res<SimulationTime>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut rng: ResMut<crate::simulation::rng::SimRng>,
|
||||
mut query: Query<
|
||||
(
|
||||
Entity,
|
||||
&ConfrontationDelivered,
|
||||
&mut KnowledgeGraph,
|
||||
&mut MonologueBuffer,
|
||||
&mut MonologueState,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
) {
|
||||
let Ok((
|
||||
player_entity,
|
||||
confrontation,
|
||||
mut observer_kg,
|
||||
mut monologue_buf,
|
||||
mut monologue_state,
|
||||
)) = query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target = confrontation.target;
|
||||
|
||||
// Effect 1: Shift target NPC to Tier 2 animation (D-047)
|
||||
// + record routine deviation (symmetric with walk-away path)
|
||||
commands.entity(target).insert((
|
||||
crate::npc::AnimationTier::Tier2,
|
||||
crate::npc::RoutineDeviation {
|
||||
trigger: crate::npc::DeviationTrigger::Confrontation,
|
||||
tick: time.tick,
|
||||
},
|
||||
));
|
||||
|
||||
// Effect 2: Decrement observer's relationship with the target (D-033 color fade)
|
||||
if let Some(target_sid) = registry.to_stable(target) {
|
||||
let old_rel = observer_kg.relationship_with(&target_sid);
|
||||
let new_rel = old_rel.decrement();
|
||||
if new_rel != old_rel {
|
||||
observer_kg.set_relationship(&target_sid, new_rel);
|
||||
tracing::info!(
|
||||
target_id = target_sid.0,
|
||||
?old_rel,
|
||||
?new_rel,
|
||||
"Confrontation: relationship decremented"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Effect 3: Monologue spike — bypass cooldown, fire immediately
|
||||
let idx = rng.rng.random_range(0..CONFRONTATION_LINES.len());
|
||||
let (id, text) = CONFRONTATION_LINES[idx];
|
||||
monologue_buf.set(MonologueEvent {
|
||||
id: id.to_string(),
|
||||
text: text.to_string(),
|
||||
duration_seconds: 5.0,
|
||||
});
|
||||
monologue_state.last_fired_tick = time.tick;
|
||||
|
||||
tracing::info!(
|
||||
tick = time.tick,
|
||||
monologue_id = id,
|
||||
"Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike"
|
||||
);
|
||||
|
||||
// Clean up marker
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.remove::<ConfrontationDelivered>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1362,5 +1497,288 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Walk-away Phase 2 tests (D-064, #519) ---------------------------------
|
||||
|
||||
#[test]
|
||||
fn walk_away_shifts_npc_to_tier2_animation() {
|
||||
use crate::knowledge::KnowledgeEventQueue;
|
||||
use crate::npc::AnimationTier;
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ActiveDialogue {
|
||||
target: npc,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: 10,
|
||||
},
|
||||
WalkAwayRequest,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_walk_away);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
let tier = world.get::<AnimationTier>(npc).unwrap();
|
||||
assert_eq!(
|
||||
*tier,
|
||||
AnimationTier::Tier2,
|
||||
"Walk-away should shift NPC to Tier 2 animation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_away_records_routine_deviation() {
|
||||
use crate::knowledge::KnowledgeEventQueue;
|
||||
use crate::npc::{DeviationTrigger, RoutineDeviation};
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
world.resource_mut::<SimulationTime>().tick = 42;
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ActiveDialogue {
|
||||
target: npc,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: 10,
|
||||
},
|
||||
WalkAwayRequest,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_walk_away);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
let deviation = world.get::<RoutineDeviation>(npc).unwrap();
|
||||
assert_eq!(
|
||||
deviation.trigger,
|
||||
DeviationTrigger::WalkAway,
|
||||
"Deviation trigger should be WalkAway"
|
||||
);
|
||||
assert_eq!(deviation.tick, 42, "Deviation should record the walk-away tick");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_away_without_dialogue_does_not_affect_npcs() {
|
||||
use crate::knowledge::KnowledgeEventQueue;
|
||||
use crate::npc::{AnimationTier, RoutineDeviation};
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Player with WalkAwayRequest but NO ActiveDialogue
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
WalkAwayRequest,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_walk_away);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<AnimationTier>(npc).is_none(),
|
||||
"NPC should not get AnimationTier when no dialogue was active"
|
||||
);
|
||||
assert!(
|
||||
world.get::<RoutineDeviation>(npc).is_none(),
|
||||
"NPC should not get RoutineDeviation when no dialogue was active"
|
||||
);
|
||||
}
|
||||
|
||||
use rand::SeedableRng;
|
||||
|
||||
// === Confrontation Response Tests (#520, D-063) ===
|
||||
|
||||
#[test]
|
||||
fn confrontation_shifts_npc_to_tier2() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Known);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
ConfrontationDelivered { target: npc },
|
||||
MonologueBuffer::default(),
|
||||
MonologueState::default(),
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_confrontation_response);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
let tier = world.get::<crate::npc::AnimationTier>(npc);
|
||||
assert_eq!(
|
||||
tier,
|
||||
Some(&crate::npc::AnimationTier::Tier2),
|
||||
"NPC should shift to Tier2 after confrontation"
|
||||
);
|
||||
|
||||
// RoutineDeviation should be recorded (symmetric with walk-away)
|
||||
let deviation = world.get::<crate::npc::RoutineDeviation>(npc);
|
||||
assert!(deviation.is_some(), "NPC should get RoutineDeviation after confrontation");
|
||||
assert_eq!(
|
||||
deviation.unwrap().trigger,
|
||||
crate::npc::DeviationTrigger::Confrontation,
|
||||
"Deviation trigger should be Confrontation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confrontation_decrements_relationship() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Known);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
ConfrontationDelivered { target: npc },
|
||||
MonologueBuffer::default(),
|
||||
MonologueState::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_confrontation_response);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let player_kg = world.get::<KnowledgeGraph>(player).unwrap();
|
||||
assert_eq!(
|
||||
player_kg.relationship_with(&npc_sid),
|
||||
RelationshipState::PersonOfInterest,
|
||||
"Known → PersonOfInterest after confrontation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confrontation_emits_monologue_spike() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
ConfrontationDelivered { target: npc },
|
||||
MonologueBuffer::default(),
|
||||
MonologueState::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_confrontation_response);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buffer = world.get_mut::<MonologueBuffer>(player).unwrap();
|
||||
let event = buffer.take();
|
||||
assert!(event.is_some(), "Monologue spike should be emitted");
|
||||
assert!(
|
||||
event.unwrap().id.starts_with("confront_"),
|
||||
"Should be a confrontation monologue line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confrontation_clears_marker() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
ConfrontationDelivered { target: npc },
|
||||
MonologueBuffer::default(),
|
||||
MonologueState::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_confrontation_response);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<ConfrontationDelivered>(player).is_none(),
|
||||
"Marker should be removed after processing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ impl InputQueue {
|
||||
|
||||
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
|
||||
/// Handles stance toggling (D-053), movement cooldown, and Take/Place verbs (#424).
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
pub fn process_player_input(
|
||||
mut input_queue: ResMut<InputQueue>,
|
||||
mut time: ResMut<SimulationTime>,
|
||||
@@ -104,8 +104,14 @@ pub fn process_player_input(
|
||||
|
||||
for input in inputs {
|
||||
// Discard all gameplay actions while paused (D-052, R2-OQ-01).
|
||||
// Only Pause/Unpause are processed — everything else is discarded.
|
||||
if paused && !matches!(input.action, PlayerAction::Pause | PlayerAction::Unpause) {
|
||||
// Only Pause/Unpause/TeleportToHub are processed — everything else is discarded.
|
||||
// TeleportToHub is exempted because it's a Gauntlet QA action (#491).
|
||||
if paused
|
||||
&& !matches!(
|
||||
input.action,
|
||||
PlayerAction::Pause | PlayerAction::Unpause | PlayerAction::TeleportToHub
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match input.action {
|
||||
@@ -196,6 +202,15 @@ pub fn process_player_input(
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Confront") => {
|
||||
handle_confront(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&player_query,
|
||||
&all_positions,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Reset") => {
|
||||
handle_reset(
|
||||
&mut commands,
|
||||
@@ -222,6 +237,9 @@ pub fn process_player_input(
|
||||
tracing::debug!("WalkAway: marker set on player");
|
||||
}
|
||||
}
|
||||
PlayerAction::TeleportToHub => {
|
||||
handle_teleport_to_hub(&mut player_query, &mut commands);
|
||||
}
|
||||
PlayerAction::UsePerceptionMode(ref mode) => {
|
||||
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
|
||||
}
|
||||
@@ -400,6 +418,65 @@ fn handle_talk(
|
||||
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
|
||||
}
|
||||
|
||||
/// Handle Confront verb: set ConfrontationDelivered marker on the player entity (#520, D-063).
|
||||
/// The confrontation response system runs in process_confrontation_response (dialogue.rs).
|
||||
/// Server-side range check: Confront requires CLOSE_RANGE (same as Talk).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn handle_confront(
|
||||
commands: &mut Commands,
|
||||
registry: &EntityRegistry,
|
||||
player_query: &Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&mut Stance>,
|
||||
Option<&mut PlayerMoveCooldown>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
all_positions: &Query<&TilePosition>,
|
||||
target_entity_id: Option<u64>,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Confront verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Confront: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Server-side range check: reject Confront if target is beyond close range
|
||||
if let Ok(target_pos) = all_positions.get(target_entity) {
|
||||
let distance = player_pos
|
||||
.manhattan_distance(target_pos)
|
||||
.unwrap_or(u32::MAX);
|
||||
if distance > crate::simulation::interaction::CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
distance,
|
||||
"Confront: target out of range (max {})",
|
||||
crate::simulation::interaction::CLOSE_RANGE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.insert(crate::simulation::dialogue::ConfrontationDelivered {
|
||||
target: target_entity,
|
||||
});
|
||||
|
||||
tracing::debug!(target_id, "Confront: ConfrontationDelivered marker set on player");
|
||||
}
|
||||
|
||||
/// Handle Place verb: remove an item from inventory and place it on the ground
|
||||
/// at the player's current position. Removes CarriedBy + InventorySlot, adds
|
||||
/// TilePosition at the player's current tile.
|
||||
@@ -512,6 +589,63 @@ fn handle_reset(
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491).
|
||||
///
|
||||
/// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning
|
||||
/// and returns. On Gauntlet maps, moves the player to HUB.spawn and removes
|
||||
/// dialogue, monologue, and interaction markers to prevent stale state.
|
||||
///
|
||||
/// Does NOT affect: room state, inventory, game time, knowledge graph.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn handle_teleport_to_hub(
|
||||
player_query: &mut Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&mut Stance>,
|
||||
Option<&mut PlayerMoveCooldown>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
commands: &mut Commands,
|
||||
) {
|
||||
#[cfg(not(feature = "gauntlet"))]
|
||||
{
|
||||
tracing::warn!("TeleportToHub rejected: not a Gauntlet map");
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
{
|
||||
let Ok((player_entity, _, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
|
||||
// Move player to hub spawn
|
||||
commands.entity(player_entity).insert(hub_spawn);
|
||||
|
||||
// Clear any pending movement
|
||||
commands.entity(player_entity).remove::<MoveIntent>();
|
||||
|
||||
// Clear dialogue/interaction markers (including mid-confrontation state)
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.remove::<crate::simulation::dialogue::TalkRequest>()
|
||||
.remove::<crate::simulation::dialogue::ActiveDialogue>()
|
||||
.remove::<crate::simulation::dialogue::WalkAwayRequest>()
|
||||
.remove::<crate::simulation::dialogue::ConfrontationDelivered>();
|
||||
|
||||
tracing::info!(
|
||||
x = hub_spawn.x,
|
||||
y = hub_spawn.y,
|
||||
z = hub_spawn.z,
|
||||
"TeleportToHub: player moved to hub spawn"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1690,4 +1824,163 @@ mod tests {
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world); // should not panic
|
||||
}
|
||||
|
||||
// === TeleportToHub Tests (#491) ===
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
#[test]
|
||||
fn teleport_to_hub_moves_player() {
|
||||
// #491: TeleportToHub moves player to hub spawn position.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
// Spawn player at a non-hub position
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(84, 58, 0)))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let pos = world.get::<TilePosition>(player).expect("player has position");
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
assert_eq!(pos.x, hub_spawn.x, "player x at hub spawn");
|
||||
assert_eq!(pos.y, hub_spawn.y, "player y at hub spawn");
|
||||
assert_eq!(pos.z, hub_spawn.z, "player z at hub spawn");
|
||||
}
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
#[test]
|
||||
fn teleport_to_hub_clears_dialogue_markers() {
|
||||
// #491: TeleportToHub removes ActiveDialogue, TalkRequest,
|
||||
// WalkAwayRequest, and ConfrontationDelivered.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
// Spawn a fake NPC target
|
||||
let npc = world.spawn(TilePosition::new(10, 10, 0)).id();
|
||||
|
||||
// Spawn player with active dialogue state + mid-confrontation marker
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(84, 58, 0),
|
||||
crate::simulation::dialogue::TalkRequest { target: npc },
|
||||
crate::simulation::dialogue::ActiveDialogue {
|
||||
target: npc,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: 0,
|
||||
},
|
||||
crate::simulation::dialogue::WalkAwayRequest,
|
||||
crate::simulation::dialogue::ConfrontationDelivered { target: npc },
|
||||
))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::TalkRequest>(player)
|
||||
.is_none(),
|
||||
"TalkRequest cleared after teleport"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::ActiveDialogue>(player)
|
||||
.is_none(),
|
||||
"ActiveDialogue cleared after teleport"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::WalkAwayRequest>(player)
|
||||
.is_none(),
|
||||
"WalkAwayRequest cleared after teleport"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::ConfrontationDelivered>(player)
|
||||
.is_none(),
|
||||
"ConfrontationDelivered cleared after teleport"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
#[test]
|
||||
fn teleport_to_hub_clears_move_intent() {
|
||||
// #491: TeleportToHub removes any pending MoveIntent.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(84, 58, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(85, 58, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_none(),
|
||||
"MoveIntent cleared after teleport"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
#[test]
|
||||
fn teleport_to_hub_allowed_while_paused() {
|
||||
// #491: TeleportToHub is a QA action — allowed even when paused.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick_rate = TickRate::Paused;
|
||||
world.insert_resource(time);
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(84, 58, 0)))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let pos = world.get::<TilePosition>(player).expect("player has position");
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
assert_eq!(pos.x, hub_spawn.x, "teleport works while paused");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,12 @@ impl MonologueBuffer {
|
||||
pub fn take(&mut self) -> Option<MonologueEvent> {
|
||||
self.event.take()
|
||||
}
|
||||
|
||||
/// Set a monologue event, replacing any pending event.
|
||||
/// Used by confrontation response (#520, D-063) to emit a monologue spike.
|
||||
pub fn set(&mut self, event: MonologueEvent) {
|
||||
self.event = Some(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Queued sprint anomaly for delayed "double-take" monologue (#428, D-055).
|
||||
|
||||
@@ -189,16 +189,19 @@ pub const INTERACTION_GALLERY_STABLE_IDS: (u64, u64) = (24, 28);
|
||||
pub const PAUSE_CHAMBER_STABLE_IDS: (u64, u64) = (29, 29);
|
||||
pub const DIALOGUE_ROOM_STABLE_IDS: (u64, u64) = (30, 33);
|
||||
pub const CROWD_PLAZA_STABLE_IDS: (u64, u64) = (34, 48);
|
||||
pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 51);
|
||||
pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 55);
|
||||
|
||||
/// Number of actively-spawned entities in the current Gauntlet build.
|
||||
/// Derived from StableId ranges of built rooms + player + reset plates.
|
||||
/// Reserved (unbuilt) rooms do not contribute entities.
|
||||
/// Derived from StableId ranges of all rooms + player + reset plates.
|
||||
pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0)
|
||||
+ (HUB_STABLE_IDS.1 - HUB_STABLE_IDS.0 + 1) as usize
|
||||
+ (FOG_THEATER_STABLE_IDS.1 - FOG_THEATER_STABLE_IDS.0 + 1) as usize
|
||||
+ (OCCLUSION_STABLE_IDS.1 - OCCLUSION_STABLE_IDS.0 + 1) as usize
|
||||
+ (INVENTORY_STABLE_IDS.1 - INVENTORY_STABLE_IDS.0 + 1) as usize
|
||||
+ (INTERACTION_GALLERY_STABLE_IDS.1 - INTERACTION_GALLERY_STABLE_IDS.0 + 1) as usize
|
||||
+ (PAUSE_CHAMBER_STABLE_IDS.1 - PAUSE_CHAMBER_STABLE_IDS.0 + 1) as usize
|
||||
+ (DIALOGUE_ROOM_STABLE_IDS.1 - DIALOGUE_ROOM_STABLE_IDS.0 + 1) as usize
|
||||
+ (CROWD_PLAZA_STABLE_IDS.1 - CROWD_PLAZA_STABLE_IDS.0 + 1) as usize
|
||||
+ (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -233,6 +236,34 @@ mod tests {
|
||||
assert_eq!(room.name, "pause_chamber");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_fog_theater() {
|
||||
let pos = TilePosition { x: 56, y: 18, z: 0 };
|
||||
let room = room_at(&pos).expect("Fog Theater observer should be in a room");
|
||||
assert_eq!(room.name, "fog_theater");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_interaction_gallery() {
|
||||
let pos = TilePosition { x: 14, y: 92, z: 0 };
|
||||
let room = room_at(&pos).expect("Interaction Gallery observer should be in a room");
|
||||
assert_eq!(room.name, "interaction_gallery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_dialogue_room() {
|
||||
let pos = TilePosition { x: 50, y: 114, z: 0 };
|
||||
let room = room_at(&pos).expect("Dialogue Room observer should be in a room");
|
||||
assert_eq!(room.name, "dialogue_room");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_crowd_plaza() {
|
||||
let pos = TilePosition { x: 96, y: 94, z: 0 };
|
||||
let room = room_at(&pos).expect("Crowd Plaza observer should be in a room");
|
||||
assert_eq!(room.name, "crowd_plaza");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_returns_none_for_corridor() {
|
||||
// Point inside corridor-E (between Hub and Occlusion)
|
||||
|
||||
@@ -19,14 +19,14 @@
|
||||
//! StableId ranges (from gestalt-round3.md):
|
||||
//! Player: 0
|
||||
//! Hub signs: 1-4
|
||||
//! Fog Theater: 5-8 (reserved, not yet built)
|
||||
//! Fog Theater: 5-8
|
||||
//! Occlusion Corridor: 9-12
|
||||
//! Inventory Warehouse: 13-23
|
||||
//! Interaction Gallery: 24-28 (reserved, not yet built)
|
||||
//! Interaction Gallery: 24-28
|
||||
//! Pause Chamber: 29
|
||||
//! Dialogue Room: 30-33 (reserved, not yet built)
|
||||
//! Crowd Plaza: 34-48 (reserved, not yet built)
|
||||
//! Reset plates: 49-51 (Occlusion, Inventory, Pause)
|
||||
//! Dialogue Room: 30-33
|
||||
//! Crowd Plaza: 34-48
|
||||
//! Reset plates: 49-55
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub mod constants;
|
||||
@@ -86,14 +86,22 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
|
||||
// Carve room interiors (2-tile-thick walls → interior starts 2 tiles in)
|
||||
carve_room_interior(&mut walkability, 38, 46, 24, 24); // Hub
|
||||
carve_room_interior(&mut walkability, 28, 2, 44, 32); // Fog Theater
|
||||
carve_room_interior(&mut walkability, 74, 48, 42, 22); // Occlusion Corridor
|
||||
carve_room_interior(&mut walkability, 2, 40, 30, 28); // Inventory Warehouse
|
||||
carve_room_interior(&mut walkability, 2, 82, 24, 20); // Interaction Gallery
|
||||
carve_room_interior(&mut walkability, 42, 78, 16, 16); // Pause Chamber
|
||||
carve_room_interior(&mut walkability, 36, 104, 28, 20); // Dialogue Room
|
||||
carve_room_interior(&mut walkability, 80, 78, 32, 32); // Crowd Plaza
|
||||
|
||||
// Carve corridors between hub and rooms
|
||||
carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater
|
||||
carve_corridor(&mut walkability, 62, 55, 12, 6); // corridor-E: Hub ↔ Occlusion
|
||||
carve_corridor(&mut walkability, 32, 55, 6, 6); // corridor-W: Hub ↔ Inventory
|
||||
carve_corridor(&mut walkability, 47, 70, 6, 8); // corridor-S: Hub ↔ Pause Chamber
|
||||
carve_corridor(&mut walkability, 12, 68, 6, 14); // corridor-SW: Inventory ↔ Interaction Gallery
|
||||
carve_corridor(&mut walkability, 48, 94, 6, 10); // corridor-S2: Pause ↔ Dialogue Room
|
||||
carve_corridor(&mut walkability, 58, 84, 22, 6); // corridor-E2: Pause ↔ Crowd Plaza
|
||||
|
||||
// Set up Occlusion Corridor walls (relative positions converted to absolute)
|
||||
// North wall segment: rel x=[14,22], y=[6,7] — blocks LOS to npc_hidden_wall
|
||||
@@ -142,8 +150,8 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
// --- Hub signs (StableId 1-4) ---
|
||||
rooms::hub::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Fog Theater (StableId 5-8) — reserved, not yet built ---
|
||||
registry.reserve_up_to(9);
|
||||
// --- Fog Theater (StableId 5-8) ---
|
||||
rooms::fog_theater::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Occlusion Corridor (StableId 9-12) ---
|
||||
rooms::occlusion_corridor::spawn_entities(app, &mut registry);
|
||||
@@ -151,23 +159,29 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
// --- Inventory Warehouse (StableId 13-23) ---
|
||||
rooms::inventory_warehouse::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Interaction Gallery (StableId 24-28) — reserved, not yet built ---
|
||||
registry.reserve_up_to(29);
|
||||
// --- Interaction Gallery (StableId 24-28) ---
|
||||
rooms::interaction_gallery::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Pause Chamber (StableId 29) ---
|
||||
rooms::pause_chamber::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Dialogue Room (StableId 30-33) — reserved, not yet built ---
|
||||
// --- Crowd Plaza (StableId 34-48) — reserved, not yet built ---
|
||||
registry.reserve_up_to(49);
|
||||
// --- Dialogue Room (StableId 30-33) ---
|
||||
rooms::dialogue_room::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Reset plates (StableId 49-51) ---
|
||||
// --- Crowd Plaza (StableId 34-48) ---
|
||||
rooms::crowd_plaza::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Reset plates (StableId 49-55) ---
|
||||
// Spawned at corridor entrances per workshop-outcomes.md Section 8.
|
||||
// Each plate triggers reset of its associated room.
|
||||
let reset_plates: &[(&str, TilePosition)] = &[
|
||||
("occlusion_corridor", constants::OCCLUSION_CORRIDOR.reset_plate.expect("occlusion_corridor should have a reset_plate")),
|
||||
("inventory_warehouse", constants::INVENTORY_WAREHOUSE.reset_plate.expect("inventory_warehouse should have a reset_plate")),
|
||||
("pause_chamber", constants::PAUSE_CHAMBER.reset_plate.expect("pause_chamber should have a reset_plate")),
|
||||
("fog_theater", constants::FOG_THEATER.reset_plate.expect("fog_theater should have a reset_plate")),
|
||||
("interaction_gallery", constants::INTERACTION_GALLERY.reset_plate.expect("interaction_gallery should have a reset_plate")),
|
||||
("dialogue_room", constants::DIALOGUE_ROOM.reset_plate.expect("dialogue_room should have a reset_plate")),
|
||||
("crowd_plaza", constants::CROWD_PLAZA.reset_plate.expect("crowd_plaza should have a reset_plate")),
|
||||
];
|
||||
for &(room_name, pos) in reset_plates {
|
||||
let entity = app
|
||||
@@ -189,6 +203,15 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
// --- Populate RoomSnapshots for reset mechanism (#490) ---
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
|
||||
// Fog Theater entities (StableId 5-8): NPCs only
|
||||
for id in constants::FOG_THEATER_STABLE_IDS.0..=constants::FOG_THEATER_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("fog_theater", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Occlusion Corridor entities (StableId 9-12): NPCs only, no floor items
|
||||
for id in 9..=12 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
@@ -208,6 +231,15 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
// Interaction Gallery entities (StableId 24-28): objects only, no floor items
|
||||
for id in constants::INTERACTION_GALLERY_STABLE_IDS.0..=constants::INTERACTION_GALLERY_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("interaction_gallery", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pause Chamber entity (StableId 29): NPC only
|
||||
if let Some(entity) = registry.to_entity(&StableId(29)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
@@ -215,6 +247,24 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogue Room entities (StableId 30-33): NPCs only
|
||||
for id in constants::DIALOGUE_ROOM_STABLE_IDS.0..=constants::DIALOGUE_ROOM_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("dialogue_room", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Crowd Plaza entities (StableId 34-48): NPCs only
|
||||
for id in constants::CROWD_PLAZA_STABLE_IDS.0..=constants::CROWD_PLAZA_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("crowd_plaza", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.insert_resource(snapshots);
|
||||
app.insert_resource(registry);
|
||||
}
|
||||
@@ -331,9 +381,9 @@ mod tests {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Hub sign at StableId {}", id);
|
||||
}
|
||||
|
||||
// Fog Theater 5-8 reserved (no entities)
|
||||
// Fog Theater at 5-8
|
||||
for id in 5..=8 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_none(), "Fog Theater {} reserved", id);
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Fog Theater at StableId {}", id);
|
||||
}
|
||||
|
||||
// Occlusion Corridor at 9-12
|
||||
@@ -346,15 +396,25 @@ mod tests {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Inventory at StableId {}", id);
|
||||
}
|
||||
|
||||
// Interaction Gallery 24-28 reserved
|
||||
// Interaction Gallery at 24-28
|
||||
for id in 24..=28 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_none(), "Gallery {} reserved", id);
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Gallery at StableId {}", id);
|
||||
}
|
||||
|
||||
// Pause Chamber at 29
|
||||
assert!(registry.to_entity(&StableId(29)).is_some(), "Pause Chamber at StableId 29");
|
||||
|
||||
// Reset plates at 49-51
|
||||
// Dialogue Room at 30-33
|
||||
for id in 30..=33 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Dialogue Room at StableId {}", id);
|
||||
}
|
||||
|
||||
// Crowd Plaza at 34-48
|
||||
for id in 34..=48 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Crowd Plaza at StableId {}", id);
|
||||
}
|
||||
|
||||
// Reset plates at 49-55
|
||||
for id in constants::RESET_PLATE_STABLE_IDS.0..=constants::RESET_PLATE_STABLE_IDS.1 {
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Crowd Plaza — Room 7 (32x32)
|
||||
//!
|
||||
//! Density stress test room. 15 NPCs in a 5x3 grid to test perception,
|
||||
//! snapshot, and tick-budget performance under high entity density.
|
||||
//! Validates D-026 tick budget holds with many visible entities.
|
||||
//!
|
||||
//! Observer position: (16, 16) relative = (96, 94) absolute, facing West.
|
||||
//!
|
||||
//! Entities (StableId 34-48):
|
||||
//! crowd_npc_00..crowd_npc_14 — 15 NPCs in a 5-column x 3-row grid
|
||||
//! Grid starts at rel (4, 4) = abs (84, 82), spacing: 5x cols, 8y rows.
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 80;
|
||||
const ORIGIN_Y: i32 = 78;
|
||||
|
||||
/// Grid layout constants.
|
||||
const GRID_COLS: usize = 5;
|
||||
const GRID_ROWS: usize = 3;
|
||||
const GRID_START_X: i32 = 4;
|
||||
const GRID_START_Y: i32 = 4;
|
||||
const GRID_SPACING_X: i32 = 5;
|
||||
const GRID_SPACING_Y: i32 = 8;
|
||||
|
||||
/// WantKind cycle for variety across 15 NPCs.
|
||||
const WANT_CYCLE: &[WantKind] = &[
|
||||
WantKind::Wealth,
|
||||
WantKind::Safety,
|
||||
WantKind::Knowledge,
|
||||
WantKind::Connection,
|
||||
WantKind::Power,
|
||||
WantKind::Freedom,
|
||||
WantKind::Justice,
|
||||
WantKind::Revenge,
|
||||
WantKind::Happiness,
|
||||
];
|
||||
|
||||
/// Spawn Crowd Plaza entities in canonical order (StableId 34-48).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
let mut index = 0usize;
|
||||
for row in 0..GRID_ROWS {
|
||||
for col in 0..GRID_COLS {
|
||||
let rx = GRID_START_X + col as i32 * GRID_SPACING_X;
|
||||
let ry = GRID_START_Y + row as i32 * GRID_SPACING_Y;
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let want_kind = WANT_CYCLE[index % WANT_CYCLE.len()];
|
||||
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: want_kind,
|
||||
intensity: ((index % 8) + 2) as u8, // 2-9 range
|
||||
description: format!("Crowd Plaza NPC #{:02}", index),
|
||||
},
|
||||
Contentment {
|
||||
level: (index as i16 * 7 - 50).clamp(-100, 100),
|
||||
},
|
||||
ToleranceThreshold {
|
||||
current_stress: (index as i16 * 5) % 80,
|
||||
threshold: 40 + (index as i16 % 4) * 10,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Dialogue Room — Room 6 (28x20)
|
||||
//!
|
||||
//! Tests D-041 (knowledge graph), D-028 (dialogue filtering pipeline).
|
||||
//!
|
||||
//! Layout: Four NPCs with DialogueProfile components, enabling the full
|
||||
//! dialogue selection pipeline (access tier, situation, trust, mood scoring).
|
||||
//! NPCs have varying trust/contentment to test different dialogue branches.
|
||||
//!
|
||||
//! Observer position: (14, 10) relative = (50, 114) absolute, facing North.
|
||||
//!
|
||||
//! Entities (StableId 30-33):
|
||||
//! npc_dialogue_a (44, 112) — Relaxed dock worker
|
||||
//! npc_dialogue_b (50, 110) — Guarded technician
|
||||
//! npc_dialogue_c (56, 112) — Stressed supervisor
|
||||
//! npc_dialogue_d (50, 118) — Distant observer (range test)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::dialogue::{CurrentMood, DialogueProfile};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 36;
|
||||
const ORIGIN_Y: i32 = 104;
|
||||
|
||||
/// NPC definitions: (name, rel_x, rel_y, want_kind, intensity, contentment, stress, threshold, location, role).
|
||||
#[allow(clippy::type_complexity)]
|
||||
const NPCS: &[(&str, i32, i32, WantKind, u8, i16, i16, i16, &str, &str)] = &[
|
||||
(
|
||||
"npc_dialogue_a", 8, 8, WantKind::Connection, 4, 20, 10, 60,
|
||||
"the-terminal", "dock-worker",
|
||||
),
|
||||
(
|
||||
"npc_dialogue_b", 14, 6, WantKind::Safety, 6, 0, 25, 45,
|
||||
"the-terminal", "technician",
|
||||
),
|
||||
(
|
||||
"npc_dialogue_c", 20, 8, WantKind::Power, 7, -15, 40, 50,
|
||||
"the-terminal", "supervisor",
|
||||
),
|
||||
(
|
||||
"npc_dialogue_d", 14, 14, WantKind::Knowledge, 3, 10, 5, 70,
|
||||
"the-terminal", "observer",
|
||||
),
|
||||
];
|
||||
|
||||
/// Spawn Dialogue Room entities in canonical order (StableId 30-33).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
for &(name, rx, ry, want_kind, intensity, contentment, stress, threshold, location, role) in
|
||||
NPCS
|
||||
{
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: want_kind,
|
||||
intensity,
|
||||
description: format!("Dialogue Room test NPC: {}", name),
|
||||
},
|
||||
Contentment {
|
||||
level: contentment,
|
||||
},
|
||||
ToleranceThreshold {
|
||||
current_stress: stress,
|
||||
threshold,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
DialogueProfile {
|
||||
location: location.to_string(),
|
||||
role: role.to_string(),
|
||||
},
|
||||
CurrentMood::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Fog Theater — Room 1 (44x32)
|
||||
//!
|
||||
//! Tests D-059 (fog layers), D-060 (cognitive delay for fog recognition).
|
||||
//!
|
||||
//! Layout: Large open room with NPCs at varying distances from observer.
|
||||
//! Tests visibility at clear, peripheral, deep-fog, and edge-of-range
|
||||
//! distances. No internal walls — fog layers are distance-based, not
|
||||
//! occlusion-based (that's the Occlusion Corridor's job).
|
||||
//!
|
||||
//! Observer position: (28, 16) relative = (56, 18) absolute, facing South.
|
||||
//!
|
||||
//! Entities (StableId 5-8):
|
||||
//! npc_fog_clear (56, 22) — 4 tiles south, clear vision cone
|
||||
//! npc_fog_peripheral (46, 18) — 10 tiles west, peripheral sector
|
||||
//! npc_fog_deep (34, 10) — far NW corner, deep fog range
|
||||
//! npc_fog_edge (68, 28) — far SE, edge-of-range test
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 28;
|
||||
const ORIGIN_Y: i32 = 2;
|
||||
|
||||
/// NPC definitions: (name, relative_x, relative_y, want_kind, want_intensity).
|
||||
const NPCS: &[(&str, i32, i32, WantKind, u8)] = &[
|
||||
("npc_fog_clear", 28, 20, WantKind::Safety, 5),
|
||||
("npc_fog_peripheral", 18, 16, WantKind::Knowledge, 6),
|
||||
("npc_fog_deep", 6, 8, WantKind::Freedom, 3),
|
||||
("npc_fog_edge", 40, 26, WantKind::Wealth, 4),
|
||||
];
|
||||
|
||||
/// Spawn Fog Theater entities in canonical order (StableId 5-8).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
for &(name, rx, ry, want_kind, intensity) in NPCS {
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: want_kind,
|
||||
intensity,
|
||||
description: format!("Fog Theater test NPC: {}", name),
|
||||
},
|
||||
Contentment { level: 0 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 50,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Interaction Gallery — Room 4 (24x20)
|
||||
//!
|
||||
//! Tests D-057 (entity interaction vertical list, verb generation per type).
|
||||
//!
|
||||
//! Layout: One entity of each ObjectType to test that each type generates
|
||||
//! the correct interaction verb(s). Container is already tested in
|
||||
//! Inventory Warehouse, so this room covers the remaining 5 types.
|
||||
//!
|
||||
//! Observer position: (12, 10) relative = (14, 92) absolute, facing East.
|
||||
//!
|
||||
//! Entities (StableId 24-28):
|
||||
//! obj_notice (8, 88) — Readable (Read verb)
|
||||
//! obj_terminal (8, 94) — Terminal (Use verb)
|
||||
//! obj_hatch (20, 88) — Door (Open/Close verb)
|
||||
//! obj_pickup (20, 94) — Pickup (Pick up verb)
|
||||
//! obj_bench (14, 96) — Furniture (Sit verb)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::bridge::types::ObjectType;
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 2;
|
||||
const ORIGIN_Y: i32 = 82;
|
||||
|
||||
/// Object definitions: (name, relative_x, relative_y, object_type).
|
||||
const OBJECTS: &[(&str, i32, i32, ObjectType)] = &[
|
||||
("obj_notice", 6, 6, ObjectType::Readable),
|
||||
("obj_terminal", 6, 12, ObjectType::Terminal),
|
||||
("obj_hatch", 18, 6, ObjectType::Door),
|
||||
("obj_pickup", 18, 12, ObjectType::Pickup),
|
||||
("obj_bench", 12, 14, ObjectType::Furniture),
|
||||
];
|
||||
|
||||
/// Spawn Interaction Gallery entities in canonical order (StableId 24-28).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
for &(_name, rx, ry, obj_type) in OBJECTS {
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((Interactable, obj_type, pos))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
//! Each room module exports a `spawn_entities()` function that creates
|
||||
//! entities in canonical order for deterministic StableId assignment.
|
||||
|
||||
pub mod crowd_plaza;
|
||||
pub mod dialogue_room;
|
||||
pub mod fog_theater;
|
||||
pub mod hub;
|
||||
pub mod interaction_gallery;
|
||||
pub mod inventory_warehouse;
|
||||
pub mod occlusion_corridor;
|
||||
pub mod pause_chamber;
|
||||
|
||||
@@ -59,6 +59,7 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -45,6 +45,7 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use bevy_app::prelude::*;
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -51,6 +52,11 @@ fn content_runtime_boot_tick_10_snapshot() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let server_addr = listener.local_addr().expect("get local addr");
|
||||
|
||||
// Barrier keeps the server thread alive until the client has finished
|
||||
// reading all snapshots, preventing a TCP RST race under parallel execution.
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let server_barrier = barrier.clone();
|
||||
|
||||
// Server thread: full plugin stack with real content
|
||||
let server_handle = thread::spawn(move || {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
|
||||
@@ -96,6 +102,9 @@ fn content_runtime_boot_tick_10_snapshot() {
|
||||
for _ in 0..10 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
// Wait for client to finish reading before dropping the TCP socket
|
||||
server_barrier.wait();
|
||||
});
|
||||
|
||||
// Client: connect with read timeout and receive 10 snapshots
|
||||
@@ -129,6 +138,9 @@ fn content_runtime_boot_tick_10_snapshot() {
|
||||
drop(reader);
|
||||
drop(writer);
|
||||
|
||||
// Signal server thread that client is done reading
|
||||
barrier.wait();
|
||||
|
||||
// Server thread must not have panicked
|
||||
server_handle
|
||||
.join()
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Content scaling test (#500, D-026).
|
||||
//!
|
||||
//! Verifies that adding extra NPCs doesn't degrade tick timing beyond
|
||||
//! acceptable bounds. Runs the Gauntlet baseline, then adds additional
|
||||
//! NPCs and compares:
|
||||
//! 1. Tick timing stays within D-026 budget (100ms)
|
||||
//! 2. Baseline entities still behave identically (deterministic)
|
||||
//!
|
||||
//! Run with: cargo test --test content_scaling -- --nocapture
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use std::time::Instant;
|
||||
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::BridgePlugin;
|
||||
use settled_reach_server::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use settled_reach_server::knowledge::KnowledgePlugin;
|
||||
use settled_reach_server::npc::{Contentment, Npc, NpcPlugin, ToleranceThreshold, Want, WantKind};
|
||||
use settled_reach_server::simulation::interaction::Interactable;
|
||||
use settled_reach_server::simulation::movement::TilePosition;
|
||||
use settled_reach_server::simulation::path_follow::MovementSpeed;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
/// Number of ticks to run for timing measurements.
|
||||
const TIMING_TICKS: usize = 50;
|
||||
|
||||
/// D-026 budget: 100ms per tick maximum.
|
||||
const MAX_TICK_MS: f64 = 100.0;
|
||||
|
||||
/// Extra NPC counts for scaling tiers.
|
||||
const EXTRA_NPC_COUNTS: &[usize] = &[0, 15, 50];
|
||||
|
||||
/// Set up a Gauntlet world and return the app.
|
||||
fn setup_baseline() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(KnowledgePlugin);
|
||||
app.add_plugins(NpcPlugin);
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
settled_reach_server::test_world::setup_gauntlet(&mut app);
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
/// Spawn N extra NPCs spread across the Gauntlet hub area.
|
||||
/// NPCs are placed in a grid starting at (40, 48) to stay within walkable space.
|
||||
fn spawn_extra_npcs(app: &mut App, count: usize) {
|
||||
// Remove registry from world so we can mutate it while also spawning entities.
|
||||
let mut registry = app
|
||||
.world_mut()
|
||||
.remove_resource::<EntityRegistry>()
|
||||
.expect("EntityRegistry should exist after setup_gauntlet");
|
||||
let cols = 10;
|
||||
|
||||
for i in 0..count {
|
||||
let x = 40 + (i % cols) as i32;
|
||||
let y = 48 + (i / cols) as i32;
|
||||
let pos = TilePosition::new(x, y, 0);
|
||||
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 5,
|
||||
description: format!("extra_npc_{}", i),
|
||||
},
|
||||
Contentment { level: 0 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 50,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
app.insert_resource(registry);
|
||||
}
|
||||
|
||||
/// Tick the app N times and return average milliseconds per tick.
|
||||
fn measure_tick_timing(app: &mut App, ticks: usize) -> f64 {
|
||||
// Warm-up tick (first tick has startup overhead)
|
||||
app.update();
|
||||
|
||||
let start = Instant::now();
|
||||
for _ in 0..ticks {
|
||||
app.update();
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
elapsed.as_secs_f64() * 1000.0 / ticks as f64
|
||||
}
|
||||
|
||||
/// Collect snapshot entity IDs from the VisibilityGeometry and entity count.
|
||||
fn count_entities(app: &App) -> usize {
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
registry.len() as usize
|
||||
}
|
||||
|
||||
/// Baseline tick timing: Gauntlet with default entities stays within D-026 budget.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn baseline_tick_timing_within_budget() {
|
||||
let mut app = setup_baseline();
|
||||
let entity_count = count_entities(&app);
|
||||
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
|
||||
|
||||
eprintln!(
|
||||
"Baseline: {} entities, avg {:.3}ms/tick over {} ticks",
|
||||
entity_count, avg_ms, TIMING_TICKS
|
||||
);
|
||||
|
||||
assert!(
|
||||
avg_ms < MAX_TICK_MS,
|
||||
"Baseline tick timing ({:.3}ms) exceeds D-026 budget ({}ms)",
|
||||
avg_ms,
|
||||
MAX_TICK_MS
|
||||
);
|
||||
}
|
||||
|
||||
/// Scaling test: adding NPCs keeps tick timing within D-026 budget.
|
||||
/// Tests 0 (baseline), 15, and 50 extra NPCs.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn scaling_tick_timing_within_budget() {
|
||||
let mut results: Vec<(usize, usize, f64)> = Vec::new();
|
||||
|
||||
for &extra_count in EXTRA_NPC_COUNTS {
|
||||
let mut app = setup_baseline();
|
||||
if extra_count > 0 {
|
||||
spawn_extra_npcs(&mut app, extra_count);
|
||||
}
|
||||
let total_entities = count_entities(&app);
|
||||
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
|
||||
results.push((extra_count, total_entities, avg_ms));
|
||||
}
|
||||
|
||||
eprintln!("\n=== Content Scaling Results (D-026: {}ms budget) ===", MAX_TICK_MS);
|
||||
eprintln!("{:<12} {:<10} {:<15}", "Extra NPCs", "Total", "Avg ms/tick");
|
||||
eprintln!("{:-<37}", "");
|
||||
for &(extra, total, avg_ms) in &results {
|
||||
let status = if avg_ms < MAX_TICK_MS { "OK" } else { "OVER" };
|
||||
eprintln!("{:<12} {:<10} {:<15.3} {}", extra, total, avg_ms, status);
|
||||
}
|
||||
|
||||
// Assert all tiers stay within budget
|
||||
for &(extra, _total, avg_ms) in &results {
|
||||
assert!(
|
||||
avg_ms < MAX_TICK_MS,
|
||||
"Tick timing with +{} NPCs ({:.3}ms) exceeds D-026 budget ({}ms)",
|
||||
extra,
|
||||
avg_ms,
|
||||
MAX_TICK_MS
|
||||
);
|
||||
}
|
||||
|
||||
// Assert scaling is reasonable: +50 NPCs shouldn't more than 5x the baseline
|
||||
if results.len() >= 2 {
|
||||
let baseline_ms = results[0].2;
|
||||
let max_extra_ms = results.last().unwrap().2;
|
||||
let scaling_factor = max_extra_ms / baseline_ms;
|
||||
eprintln!(
|
||||
"\nScaling factor (baseline → +{} NPCs): {:.2}x",
|
||||
results.last().unwrap().0,
|
||||
scaling_factor
|
||||
);
|
||||
assert!(
|
||||
scaling_factor < 5.0,
|
||||
"Scaling factor {:.2}x exceeds 5x threshold — possible O(n^2) regression",
|
||||
scaling_factor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Determinism test: baseline entities produce identical snapshots regardless
|
||||
/// of extra NPCs being present. The original Gauntlet entities (StableId 0
|
||||
/// through RESET_PLATE_STABLE_IDS.1) should have the same positions and
|
||||
/// visibility after the same number of ticks.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn extra_npcs_dont_affect_baseline_behavior() {
|
||||
// Run baseline
|
||||
let mut baseline_app = setup_baseline();
|
||||
for _ in 0..10 {
|
||||
baseline_app.update();
|
||||
}
|
||||
let baseline_buffer = baseline_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
|
||||
// Run with extra NPCs
|
||||
let mut scaled_app = setup_baseline();
|
||||
spawn_extra_npcs(&mut scaled_app, 15);
|
||||
for _ in 0..10 {
|
||||
scaled_app.update();
|
||||
}
|
||||
let scaled_buffer = scaled_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
|
||||
let baseline_snap = baseline_buffer.expect("baseline should produce a snapshot");
|
||||
let scaled_snap = scaled_buffer.expect("scaled should produce a snapshot");
|
||||
|
||||
// Same tick
|
||||
assert_eq!(baseline_snap.tick, scaled_snap.tick, "tick count should match");
|
||||
|
||||
// Same game time
|
||||
assert_eq!(
|
||||
baseline_snap.game_time.time_of_day, scaled_snap.game_time.time_of_day,
|
||||
"game time should match"
|
||||
);
|
||||
|
||||
// Player position should be identical
|
||||
let baseline_player = baseline_snap.entities.iter().find(|e| e.kind == EntityKind::Player);
|
||||
let scaled_player = scaled_snap.entities.iter().find(|e| e.kind == EntityKind::Player);
|
||||
assert!(baseline_player.is_some(), "baseline should have player");
|
||||
assert!(scaled_player.is_some(), "scaled should have player");
|
||||
|
||||
let bp = baseline_player.unwrap();
|
||||
let sp = scaled_player.unwrap();
|
||||
assert_eq!(bp.x, sp.x, "player x should match");
|
||||
assert_eq!(bp.y, sp.y, "player y should match");
|
||||
|
||||
// Original entities (entity_id <= max Gauntlet StableId) visible in baseline
|
||||
// should still be visible in scaled run. Extra NPCs may add to the visible
|
||||
// set, but shouldn't remove baseline visibility.
|
||||
let max_baseline_id = settled_reach_server::test_world::constants::RESET_PLATE_STABLE_IDS.1;
|
||||
let baseline_original_ids: Vec<u64> = baseline_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_baseline_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
let scaled_original_ids: Vec<u64> = scaled_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_baseline_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
baseline_original_ids, scaled_original_ids,
|
||||
"Original Gauntlet entities (id <= max_baseline_id) should be identical in both runs"
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +205,7 @@ fn generate_msgpack_fixtures() {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_v2_full",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"blocked_entities": [
|
||||
2
|
||||
],
|
||||
"current_monologue": null,
|
||||
"dialogue_response": null,
|
||||
"entities": [
|
||||
@@ -62,7 +65,7 @@
|
||||
"player_inventory": [],
|
||||
"player_stance": "Sprint",
|
||||
"tick": 8,
|
||||
"version": 8,
|
||||
"version": 9,
|
||||
"visible_tiles": [
|
||||
{
|
||||
"tile_kind": "Wall",
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Performance benchmarks: tick timing and memory usage
|
||||
//!
|
||||
//! Run with: cargo test --release --test perf_bench -- --ignored --nocapture
|
||||
//! Output: PERF_RESULT:{json} lines for tooling/perf-baseline to parse.
|
||||
//!
|
||||
//! Tick budget target: 100ms (D-026)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use std::io::{BufReader, BufWriter};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::{BridgePlugin, BridgeResource};
|
||||
use settled_reach_server::content::{ContentConfig, ContentPlugin};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
|
||||
use settled_reach_server::simulation::listening::ListeningFocus;
|
||||
use settled_reach_server::simulation::monologue::{
|
||||
MonologueBuffer, MonologueState, SprintAnomalyQueue,
|
||||
};
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
const WARMUP_TICKS: usize = 5;
|
||||
const MEASURE_TICKS: usize = 50;
|
||||
const TOTAL_TICKS: usize = WARMUP_TICKS + MEASURE_TICKS;
|
||||
|
||||
fn content_root() -> PathBuf {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
PathBuf::from(manifest_dir).join("../content")
|
||||
}
|
||||
|
||||
fn read_rss_kb() -> Option<u64> {
|
||||
std::fs::read_to_string("/proc/self/status")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines()
|
||||
.find(|l| l.starts_with("VmRSS:"))
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|v| v.parse().ok())
|
||||
})
|
||||
}
|
||||
|
||||
/// Full plugin stack tick benchmark with real content.
|
||||
///
|
||||
/// Boots the server with production content, runs WARMUP_TICKS to stabilize,
|
||||
/// then measures MEASURE_TICKS of app.update() wall-clock time. Reports entity
|
||||
/// counts from observer snapshots and process RSS.
|
||||
///
|
||||
/// Protocol contract: BridgePlugin uses non-blocking receive (WouldBlock →
|
||||
/// empty input vec), so the server always advances even if the client hasn't
|
||||
/// sent input yet. The server sends a snapshot each tick; the client blocks
|
||||
/// on read until one arrives, then responds with (empty) input. No deadlock
|
||||
/// possible — see TcpBridge::receive_inputs and send_snapshot in bridge/tcp.rs.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn perf_tick_timing() {
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
eprintln!("Skipping: content directory not found at {:?}", root);
|
||||
return;
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let server_addr = listener.local_addr().expect("get local addr");
|
||||
|
||||
// Server thread: full plugin stack with real content, timed ticks
|
||||
let server_root = root.clone();
|
||||
let server_handle = thread::spawn(move || -> Vec<Duration> {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(settled_reach_server::npc::NpcPlugin);
|
||||
app.insert_resource(ContentConfig {
|
||||
content_root: server_root,
|
||||
..Default::default()
|
||||
});
|
||||
app.add_plugins(ContentPlugin);
|
||||
app.insert_resource(BridgeResource::new(bridge));
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
|
||||
let profile = MovementProfile::smuggler();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
SprintAnomalyQueue::default(),
|
||||
CognitiveDelay::default(),
|
||||
ListeningFocus::new(TilePosition::new(16, 16, 0)),
|
||||
profile,
|
||||
profile.initial_stance(),
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
app.insert_resource(registry);
|
||||
|
||||
let mut timings = Vec::with_capacity(TOTAL_TICKS);
|
||||
for _ in 0..TOTAL_TICKS {
|
||||
let start = Instant::now();
|
||||
app.update();
|
||||
timings.push(start.elapsed());
|
||||
}
|
||||
timings
|
||||
});
|
||||
|
||||
// Client: pump protocol — read snapshots, send empty inputs
|
||||
let stream = TcpStream::connect(server_addr).expect("client connect");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(30)))
|
||||
.expect("set read timeout");
|
||||
let mut reader = BufReader::new(stream.try_clone().expect("clone for reader"));
|
||||
let mut writer = BufWriter::new(stream);
|
||||
|
||||
let mut entity_counts: Vec<usize> = Vec::with_capacity(TOTAL_TICKS);
|
||||
for tick in 0..TOTAL_TICKS {
|
||||
// Server may close connection after its last tick — handle gracefully
|
||||
let payload = match read_framed(&mut reader) {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) | Err(_) => break,
|
||||
};
|
||||
|
||||
let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload)
|
||||
.unwrap_or_else(|e| panic!("deserialization error at tick {}: {}", tick, e));
|
||||
|
||||
entity_counts.push(snapshot.entities.len());
|
||||
|
||||
let empty: Vec<PlayerInput> = vec![];
|
||||
let input_payload = rmp_serde::to_vec(&empty).expect("serialize empty input");
|
||||
if write_framed(&mut writer, &input_payload).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Must have received enough measured snapshots for meaningful results.
|
||||
// Require all warmup ticks plus at least half the measurement window.
|
||||
let min_snapshots = WARMUP_TICKS + MEASURE_TICKS / 2;
|
||||
assert!(
|
||||
entity_counts.len() >= min_snapshots,
|
||||
"Only received {} snapshots, need at least {} ({} warmup + {} measured)",
|
||||
entity_counts.len(),
|
||||
min_snapshots,
|
||||
WARMUP_TICKS,
|
||||
MEASURE_TICKS / 2
|
||||
);
|
||||
|
||||
drop(reader);
|
||||
drop(writer);
|
||||
|
||||
let timings = server_handle
|
||||
.join()
|
||||
.expect("server thread panicked during tick benchmark");
|
||||
|
||||
// Analyze measured ticks (skip warmup)
|
||||
let measured_us: Vec<u64> = timings
|
||||
.iter()
|
||||
.skip(WARMUP_TICKS)
|
||||
.map(|d| d.as_micros() as u64)
|
||||
.collect();
|
||||
|
||||
let min = *measured_us.iter().min().unwrap();
|
||||
let max = *measured_us.iter().max().unwrap();
|
||||
let sum: u64 = measured_us.iter().sum();
|
||||
let mean = sum / measured_us.len() as u64;
|
||||
|
||||
let mut sorted = measured_us.clone();
|
||||
sorted.sort();
|
||||
// Nearest-rank p95: index = floor(0.95 * (N-1)) for 0-based indexing.
|
||||
let p95_idx = ((sorted.len() - 1) as f64 * 0.95).floor() as usize;
|
||||
let p95 = sorted[p95_idx.min(sorted.len() - 1)];
|
||||
|
||||
let entity_counts_measured: Vec<usize> =
|
||||
entity_counts.iter().skip(WARMUP_TICKS).copied().collect();
|
||||
let avg_entities =
|
||||
entity_counts_measured.iter().sum::<usize>() / entity_counts_measured.len().max(1);
|
||||
let max_entities = entity_counts_measured.iter().max().copied().unwrap_or(0);
|
||||
|
||||
let rss_kb = read_rss_kb();
|
||||
|
||||
let result = serde_json::json!({
|
||||
"tick_timing": {
|
||||
"warmup_ticks": WARMUP_TICKS,
|
||||
"measured_ticks": measured_us.len(),
|
||||
"min_us": min,
|
||||
"max_us": max,
|
||||
"mean_us": mean,
|
||||
"p95_us": p95,
|
||||
"all_us": measured_us,
|
||||
},
|
||||
"entities": {
|
||||
"avg_per_snapshot": avg_entities,
|
||||
"max_per_snapshot": max_entities,
|
||||
},
|
||||
"memory": {
|
||||
"rss_kb": rss_kb,
|
||||
},
|
||||
});
|
||||
|
||||
println!(
|
||||
"PERF_RESULT:{}",
|
||||
serde_json::to_string(&result).unwrap()
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +251,7 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
@@ -304,7 +306,7 @@ fn protocol_version_constant_matches_snapshot() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(
|
||||
PROTOCOL_VERSION, 8,
|
||||
PROTOCOL_VERSION, 9,
|
||||
"bump this assertion when protocol version changes"
|
||||
);
|
||||
}
|
||||
@@ -342,6 +344,7 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
@@ -469,6 +472,10 @@ fn v5_payload_deserializes_into_v6_struct() {
|
||||
decoded.pending_recognitions.is_empty(),
|
||||
"missing pending_recognitions should default to empty"
|
||||
);
|
||||
assert!(
|
||||
decoded.blocked_entities.is_empty(),
|
||||
"missing blocked_entities should default to empty"
|
||||
);
|
||||
}
|
||||
|
||||
/// Full 9-slot inventory roundtrip (D-065: 3x3 grid = 9 slots universal)
|
||||
@@ -1095,6 +1102,90 @@ fn gdscript_generated_fixtures_deserialize() {
|
||||
eprintln!("Verified {} GDScript-generated fixtures", count);
|
||||
}
|
||||
|
||||
/// blocked_entities Vec<u64> round-trips through MessagePack (#514).
|
||||
/// Guards the debug field survives serialization.
|
||||
#[test]
|
||||
fn blocked_entities_roundtrip() {
|
||||
let mut snapshot = test_snapshot(0, vec![]);
|
||||
snapshot.blocked_entities = vec![42, 99, 1024];
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(
|
||||
decoded.blocked_entities,
|
||||
vec![42, 99, 1024],
|
||||
"blocked_entities should survive roundtrip"
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty blocked_entities round-trips correctly (#514).
|
||||
#[test]
|
||||
fn blocked_entities_empty_roundtrip() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert!(snapshot.blocked_entities.is_empty());
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
assert!(
|
||||
decoded.blocked_entities.is_empty(),
|
||||
"empty blocked_entities should survive roundtrip"
|
||||
);
|
||||
}
|
||||
|
||||
/// v8 payloads (without blocked_entities) must deserialize into the v9 struct
|
||||
/// via #[serde(default)]. Guards backwards compat during migration (#514).
|
||||
#[test]
|
||||
fn v8_payload_deserializes_into_v9_struct() {
|
||||
#[derive(serde::Serialize)]
|
||||
struct ObserverSnapshotV8 {
|
||||
version: u8,
|
||||
tick: u64,
|
||||
game_time: GameTime,
|
||||
player_facing: FacingDirection,
|
||||
player_stance: MovementStance,
|
||||
player_inventory: Vec<InventoryItem>,
|
||||
entities: Vec<VisibleEntity>,
|
||||
visible_tiles: Vec<VisibleTile>,
|
||||
nearby_interactions: Vec<NearbyInteraction>,
|
||||
current_monologue: Option<MonologueEvent>,
|
||||
pending_recognitions: Vec<PendingRecognitionWire>,
|
||||
dialogue_response: Option<DialogueResponseEvent>,
|
||||
}
|
||||
|
||||
let v8 = ObserverSnapshotV8 {
|
||||
version: 8,
|
||||
tick: 100,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
player_stance: MovementStance::Walk,
|
||||
player_inventory: vec![],
|
||||
entities: vec![],
|
||||
visible_tiles: vec![],
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&v8).expect("serialize v8");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||
.expect("v8 payload should deserialize into v9 struct via serde(default)");
|
||||
|
||||
assert_eq!(decoded.version, 8, "version field preserved from v8");
|
||||
assert_eq!(decoded.tick, 100);
|
||||
assert!(
|
||||
decoded.blocked_entities.is_empty(),
|
||||
"missing blocked_entities should default to empty"
|
||||
);
|
||||
}
|
||||
|
||||
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
|
||||
/// Verifies object_type=Some(Container) survives the wire.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"timestamp": "2026-02-18T12:07:18.546680+00:00",
|
||||
"git": {
|
||||
"commit": "c1d7c07",
|
||||
"branch": "ci"
|
||||
},
|
||||
"tick_timing": {
|
||||
"max_us": 363,
|
||||
"mean_us": 332,
|
||||
"measured_ticks": 50,
|
||||
"min_us": 310,
|
||||
"p95_us": 356,
|
||||
"warmup_ticks": 5
|
||||
},
|
||||
"entities": {
|
||||
"avg_per_snapshot": 21,
|
||||
"max_per_snapshot": 21
|
||||
},
|
||||
"memory": {
|
||||
"rss_kb": 6900
|
||||
},
|
||||
"shadowcast": {
|
||||
"configs": [
|
||||
{
|
||||
"map_size": 32,
|
||||
"density": "open field",
|
||||
"range": 20,
|
||||
"iterations": 1000,
|
||||
"symmetric_total_ms": 57.86,
|
||||
"symmetric_per_call_us": 57.86,
|
||||
"recursive_total_ms": 97.97,
|
||||
"recursive_per_call_us": 97.97
|
||||
},
|
||||
{
|
||||
"map_size": 32,
|
||||
"density": "moderate corridors",
|
||||
"range": 20,
|
||||
"iterations": 1000,
|
||||
"symmetric_total_ms": 52.29,
|
||||
"symmetric_per_call_us": 52.29,
|
||||
"recursive_total_ms": 189.99,
|
||||
"recursive_per_call_us": 189.99
|
||||
},
|
||||
{
|
||||
"map_size": 32,
|
||||
"density": "dense rooms",
|
||||
"range": 20,
|
||||
"iterations": 1000,
|
||||
"symmetric_total_ms": 21.31,
|
||||
"symmetric_per_call_us": 21.31,
|
||||
"recursive_total_ms": 98.47,
|
||||
"recursive_per_call_us": 98.47
|
||||
},
|
||||
{
|
||||
"map_size": 64,
|
||||
"density": "open field",
|
||||
"range": 20,
|
||||
"iterations": 1000,
|
||||
"symmetric_total_ms": 60.5,
|
||||
"symmetric_per_call_us": 60.5,
|
||||
"recursive_total_ms": 98.33,
|
||||
"recursive_per_call_us": 98.33
|
||||
},
|
||||
{
|
||||
"map_size": 64,
|
||||
"density": "moderate corridors",
|
||||
"range": 20,
|
||||
"iterations": 1000,
|
||||
"symmetric_total_ms": 47.41,
|
||||
"symmetric_per_call_us": 47.41,
|
||||
"recursive_total_ms": 184.87,
|
||||
"recursive_per_call_us": 184.87
|
||||
},
|
||||
{
|
||||
"map_size": 64,
|
||||
"density": "dense rooms",
|
||||
"range": 20,
|
||||
"iterations": 1000,
|
||||
"symmetric_total_ms": 14.35,
|
||||
"symmetric_per_call_us": 14.35,
|
||||
"recursive_total_ms": 79.6,
|
||||
"recursive_per_call_us": 79.6
|
||||
},
|
||||
{
|
||||
"map_size": 150,
|
||||
"density": "open field",
|
||||
"range": 20,
|
||||
"iterations": 1000,
|
||||
"symmetric_total_ms": 56.65,
|
||||
"symmetric_per_call_us": 56.65,
|
||||
"recursive_total_ms": 97.8,
|
||||
"recursive_per_call_us": 97.8
|
||||
},
|
||||
{
|
||||
"map_size": 150,
|
||||
"density": "moderate corridors",
|
||||
"range": 20,
|
||||
"iterations": 1000,
|
||||
"symmetric_total_ms": 40.49,
|
||||
"symmetric_per_call_us": 40.49,
|
||||
"recursive_total_ms": 158.81,
|
||||
"recursive_per_call_us": 158.81
|
||||
},
|
||||
{
|
||||
"map_size": 150,
|
||||
"density": "dense rooms",
|
||||
"range": 20,
|
||||
"iterations": 1000,
|
||||
"symmetric_total_ms": 12.88,
|
||||
"symmetric_per_call_us": 12.88,
|
||||
"recursive_total_ms": 77.98,
|
||||
"recursive_per_call_us": 77.98
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[{"tick":0,"action":"MoveNorth"}]
|
||||
[{"tick":1,"action":"MoveNorth"}]
|
||||
[{"tick":2,"action":"MoveEast"}]
|
||||
[{"tick":3,"action":"TeleportToHub"}]
|
||||
[{"tick":4,"action":"MoveNorth"}]
|
||||
@@ -0,0 +1,5 @@
|
||||
[]
|
||||
[]
|
||||
[]
|
||||
[]
|
||||
[]
|
||||
@@ -0,0 +1,5 @@
|
||||
[{"tick":0,"action":"MoveNorth"}]
|
||||
[{"tick":1,"action":"MoveNorth"}]
|
||||
[{"tick":2,"action":"MoveNorth"}]
|
||||
[{"tick":3,"action":"MoveNorth"}]
|
||||
[{"tick":4,"action":"MoveNorth"}]
|
||||
Executable
+282
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Performance baseline tooling.
|
||||
|
||||
Runs the server benchmark suite, captures tick timing, memory usage, and entity
|
||||
count scaling metrics, outputs results to tests/perf/.
|
||||
|
||||
Usage:
|
||||
tooling/perf-baseline Run benchmarks and save baseline
|
||||
tooling/perf-baseline --compare Compare current run against saved baseline (no save)
|
||||
|
||||
Exit code 0 = success, 1 = failure or regression detected (--compare mode).
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PERF_DIR = ROOT / "tests" / "perf"
|
||||
BASELINE_FILE = PERF_DIR / "baseline.json"
|
||||
|
||||
# Tick budget from D-026: 100ms per tick at 10 tps floor (D-031).
|
||||
# If tick rate changes, update this constant.
|
||||
TICK_BUDGET_US = 100_000 # 100ms
|
||||
|
||||
|
||||
def run_command(cmd, **kwargs):
|
||||
"""Run a command in the server directory and return the result."""
|
||||
return subprocess.run(
|
||||
cmd, capture_output=True, text=True, cwd=ROOT / "server", **kwargs
|
||||
)
|
||||
|
||||
|
||||
def get_git_info():
|
||||
"""Get current git commit and branch."""
|
||||
commit = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True, text=True, cwd=ROOT,
|
||||
).stdout.strip()
|
||||
branch = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True, text=True, cwd=ROOT,
|
||||
).stdout.strip()
|
||||
return {"commit": commit, "branch": branch}
|
||||
|
||||
|
||||
def run_tick_benchmark():
|
||||
"""Run perf_tick_timing test and parse PERF_RESULT JSON."""
|
||||
print(" Running tick timing benchmark (release mode)...")
|
||||
result = run_command([
|
||||
"cargo", "test", "--release", "--test", "perf_bench",
|
||||
"--", "--ignored", "--nocapture", "perf_tick_timing",
|
||||
])
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f" FAILED: tick benchmark exited {result.returncode}")
|
||||
if result.stderr:
|
||||
# Print last 20 lines of stderr for diagnostics
|
||||
lines = result.stderr.strip().splitlines()
|
||||
for line in lines[-20:]:
|
||||
print(f" {line}")
|
||||
return None
|
||||
|
||||
# Parse PERF_RESULT: line from stdout
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("PERF_RESULT:"):
|
||||
json_str = line[len("PERF_RESULT:"):]
|
||||
return json.loads(json_str)
|
||||
|
||||
print(" WARNING: No PERF_RESULT found in test output")
|
||||
return None
|
||||
|
||||
|
||||
def run_shadowcast_benchmark():
|
||||
"""Run shadowcast benchmark and parse structured output."""
|
||||
print(" Running shadowcast benchmark (release mode)...")
|
||||
result = run_command([
|
||||
"cargo", "test", "--release", "--test", "shadowcast_bench",
|
||||
"--", "--ignored", "--nocapture", "benchmark_symmetric_vs_recursive",
|
||||
])
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f" FAILED: shadowcast benchmark exited {result.returncode}")
|
||||
return None
|
||||
|
||||
configs = []
|
||||
current = {}
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
|
||||
m = re.match(
|
||||
r"Map: (\d+)x(\d+), Density: (.+), Range: (\d+), Iterations: (\d+)",
|
||||
line,
|
||||
)
|
||||
if m:
|
||||
# New config block — flush previous if complete
|
||||
if current.get("map_size"):
|
||||
configs.append(current)
|
||||
current = {
|
||||
"map_size": int(m.group(1)),
|
||||
"density": m.group(3),
|
||||
"range": int(m.group(4)),
|
||||
"iterations": int(m.group(5)),
|
||||
}
|
||||
continue
|
||||
|
||||
m = re.match(r"Symmetric:\s+([0-9.]+)ms total, ([0-9.]+).s/call", line)
|
||||
if m:
|
||||
current["symmetric_total_ms"] = float(m.group(1))
|
||||
current["symmetric_per_call_us"] = float(m.group(2))
|
||||
continue
|
||||
|
||||
m = re.match(r"Recursive:\s+([0-9.]+)ms total, ([0-9.]+).s/call", line)
|
||||
if m:
|
||||
current["recursive_total_ms"] = float(m.group(1))
|
||||
current["recursive_per_call_us"] = float(m.group(2))
|
||||
continue
|
||||
|
||||
# Flush last config
|
||||
if current.get("map_size"):
|
||||
configs.append(current)
|
||||
|
||||
return {"configs": configs} if configs else None
|
||||
|
||||
|
||||
def compare_baselines(old, new):
|
||||
"""Compare two baselines and report regressions. Returns list of regression strings."""
|
||||
regressions = []
|
||||
improvements = []
|
||||
|
||||
old_tick = old.get("tick_timing", {})
|
||||
new_tick = new.get("tick_timing", {})
|
||||
|
||||
if old_tick and new_tick:
|
||||
# Mean tick time regression (>20% = warning)
|
||||
old_mean = old_tick.get("mean_us", 0)
|
||||
new_mean = new_tick.get("mean_us", 0)
|
||||
if old_mean > 0:
|
||||
change = (new_mean - old_mean) / old_mean * 100
|
||||
if change > 20:
|
||||
regressions.append(
|
||||
f"mean tick time {old_mean}us -> {new_mean}us (+{change:.1f}%)"
|
||||
)
|
||||
elif change < -20:
|
||||
improvements.append(
|
||||
f"mean tick time {old_mean}us -> {new_mean}us ({change:.1f}%)"
|
||||
)
|
||||
|
||||
# p95 tick time regression
|
||||
old_p95 = old_tick.get("p95_us", 0)
|
||||
new_p95 = new_tick.get("p95_us", 0)
|
||||
if old_p95 > 0:
|
||||
change = (new_p95 - old_p95) / old_p95 * 100
|
||||
if change > 20:
|
||||
regressions.append(
|
||||
f"p95 tick time {old_p95}us -> {new_p95}us (+{change:.1f}%)"
|
||||
)
|
||||
elif change < -20:
|
||||
improvements.append(
|
||||
f"p95 tick time {old_p95}us -> {new_p95}us ({change:.1f}%)"
|
||||
)
|
||||
|
||||
# Absolute budget check
|
||||
new_p95 = new.get("tick_timing", {}).get("p95_us", 0)
|
||||
if new_p95 > TICK_BUDGET_US:
|
||||
regressions.append(
|
||||
f"p95 {new_p95}us exceeds {TICK_BUDGET_US}us tick budget (D-026)"
|
||||
)
|
||||
|
||||
return regressions, improvements
|
||||
|
||||
|
||||
def main():
|
||||
compare_mode = "--compare" in sys.argv
|
||||
|
||||
print("=== Performance Baseline ===\n")
|
||||
|
||||
# Build in release mode first
|
||||
print("Building server (release)...")
|
||||
build = run_command(["cargo", "build", "--release"])
|
||||
if build.returncode != 0:
|
||||
print("BUILD FAILED")
|
||||
lines = build.stderr.strip().splitlines()
|
||||
for line in lines[-20:]:
|
||||
print(f" {line}")
|
||||
return 1
|
||||
|
||||
print("\nRunning benchmarks...\n")
|
||||
|
||||
tick_results = run_tick_benchmark()
|
||||
shadowcast_results = run_shadowcast_benchmark()
|
||||
|
||||
if not tick_results:
|
||||
print("\nFATAL: tick benchmark failed -- no baseline generated")
|
||||
return 1
|
||||
|
||||
# Assemble baseline
|
||||
git_info = get_git_info()
|
||||
baseline = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"git": git_info,
|
||||
"tick_timing": tick_results.get("tick_timing", {}),
|
||||
"entities": tick_results.get("entities", {}),
|
||||
"memory": tick_results.get("memory", {}),
|
||||
}
|
||||
if shadowcast_results:
|
||||
baseline["shadowcast"] = shadowcast_results
|
||||
|
||||
# Report
|
||||
tt = baseline["tick_timing"]
|
||||
print(f"\n--- Results ---")
|
||||
print(f"Git: {git_info['commit']} ({git_info['branch']})")
|
||||
print(f"Tick timing ({tt.get('measured_ticks', '?')} ticks, "
|
||||
f"{tt.get('warmup_ticks', '?')} warmup):")
|
||||
print(f" min: {tt.get('min_us', '?')}us")
|
||||
print(f" mean: {tt.get('mean_us', '?')}us")
|
||||
print(f" p95: {tt.get('p95_us', '?')}us")
|
||||
print(f" max: {tt.get('max_us', '?')}us")
|
||||
|
||||
ent = baseline["entities"]
|
||||
print(f"Entities: avg {ent.get('avg_per_snapshot', '?')}, "
|
||||
f"max {ent.get('max_per_snapshot', '?')}")
|
||||
|
||||
mem = baseline["memory"]
|
||||
rss = mem.get("rss_kb")
|
||||
if rss:
|
||||
print(f"Memory: {rss} KB RSS ({rss / 1024:.1f} MB)")
|
||||
|
||||
if shadowcast_results:
|
||||
n = len(shadowcast_results.get("configs", []))
|
||||
print(f"Shadowcast: {n} configurations benchmarked")
|
||||
|
||||
# Budget check
|
||||
p95 = tt.get("p95_us", 0)
|
||||
if p95 > TICK_BUDGET_US:
|
||||
print(f"\nBUDGET EXCEEDED: p95 {p95}us > {TICK_BUDGET_US}us (D-026)")
|
||||
else:
|
||||
budget_pct = p95 / TICK_BUDGET_US * 100 if TICK_BUDGET_US else 0
|
||||
print(f"\nBudget: {budget_pct:.1f}% of {TICK_BUDGET_US}us tick budget (D-026)")
|
||||
|
||||
# Compare with previous baseline if it exists
|
||||
if BASELINE_FILE.exists():
|
||||
with open(BASELINE_FILE) as f:
|
||||
old_baseline = json.load(f)
|
||||
old_commit = old_baseline.get("git", {}).get("commit", "?")
|
||||
print(f"\n--- Comparison vs {old_commit} ---")
|
||||
regressions, improvements = compare_baselines(old_baseline, baseline)
|
||||
for r in regressions:
|
||||
print(f" REGRESSION: {r}")
|
||||
for i in improvements:
|
||||
print(f" IMPROVEMENT: {i}")
|
||||
if not regressions and not improvements:
|
||||
print(" No significant changes.")
|
||||
if compare_mode and regressions:
|
||||
print(f"\n{len(regressions)} regression(s) detected.")
|
||||
return 1
|
||||
elif compare_mode:
|
||||
print(f"\nERROR: --compare requires a saved baseline at {BASELINE_FILE.relative_to(ROOT)}")
|
||||
print("Run `make perf-baseline` first to create one.")
|
||||
return 1
|
||||
|
||||
if compare_mode:
|
||||
return 0
|
||||
|
||||
# Save baseline (strip per-tick array — too noisy for git diffs)
|
||||
PERF_DIR.mkdir(parents=True, exist_ok=True)
|
||||
committed = json.loads(json.dumps(baseline))
|
||||
committed["tick_timing"].pop("all_us", None)
|
||||
|
||||
with open(BASELINE_FILE, "w") as f:
|
||||
json.dump(committed, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
print(f"\nBaseline written to {BASELINE_FILE.relative_to(ROOT)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Generated
+44
-1
@@ -556,6 +556,16 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
@@ -799,6 +809,12 @@ version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
@@ -1067,6 +1083,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -1143,7 +1172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.0"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
@@ -1168,6 +1197,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settled-reach-server",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1241,6 +1271,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
|
||||
@@ -14,3 +14,6 @@ clap = { version = "4", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
rmp-serde = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -6,6 +6,10 @@ use settled_reach_server::bridge::types::PlayerInput;
|
||||
use std::path::Path;
|
||||
|
||||
/// Load a JSONL replay file. Returns one Vec<PlayerInput> per tick.
|
||||
///
|
||||
/// Format: one JSON array per line. Each array contains PlayerInput objects
|
||||
/// for that tick. Blank lines are skipped. Returns Err with line number on
|
||||
/// parse failure.
|
||||
pub fn load_replay(path: &Path) -> Result<Vec<Vec<PlayerInput>>, String> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read replay file {}: {}", path.display(), e))?;
|
||||
@@ -22,3 +26,152 @@ pub fn load_replay(path: &Path) -> Result<Vec<Vec<PlayerInput>>, String> {
|
||||
}
|
||||
Ok(ticks)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use settled_reach_server::bridge::types::PlayerAction;
|
||||
use std::io::Write;
|
||||
|
||||
fn write_temp_file(content: &str) -> tempfile::NamedTempFile {
|
||||
let mut f = tempfile::NamedTempFile::new().unwrap();
|
||||
f.write_all(content.as_bytes()).unwrap();
|
||||
f.flush().unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_single_tick_single_action() {
|
||||
let f = write_temp_file(r#"[{"tick":0,"action":"MoveNorth"}]"#);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 1);
|
||||
assert_eq!(ticks[0].len(), 1);
|
||||
assert!(ticks[0][0].action.is_movement());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_multiple_ticks() {
|
||||
let content = r#"[{"tick":0,"action":"MoveNorth"}]
|
||||
[{"tick":1,"action":"MoveEast"}]
|
||||
[{"tick":2,"action":"MoveSouth"}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_multiple_actions_per_tick() {
|
||||
let content = r#"[{"tick":0,"action":"MoveNorth"},{"tick":0,"action":"Pause"}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 1);
|
||||
assert_eq!(ticks[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_empty_array_idle_tick() {
|
||||
let content = "[]";
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 1);
|
||||
assert!(ticks[0].is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_skipped() {
|
||||
let content = r#"[{"tick":0,"action":"MoveNorth"}]
|
||||
|
||||
[{"tick":2,"action":"MoveSouth"}]
|
||||
|
||||
"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 2, "blank lines should be skipped, not counted as ticks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_returns_empty_vec() {
|
||||
let f = write_temp_file("");
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert!(ticks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_file_returns_empty_vec() {
|
||||
let f = write_temp_file(" \n \n\n ");
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert!(ticks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_reports_line_number() {
|
||||
let content = r#"[{"tick":0,"action":"MoveNorth"}]
|
||||
not valid json
|
||||
[{"tick":2,"action":"MoveSouth"}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let err = load_replay(f.path()).unwrap_err();
|
||||
assert!(err.contains("replay line 2"), "error should reference line 2, got: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_returns_error() {
|
||||
let err = load_replay(Path::new("/nonexistent/replay.jsonl")).unwrap_err();
|
||||
assert!(err.contains("failed to read replay file"), "got: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interact_action_parses() {
|
||||
let content =
|
||||
r#"[{"tick":0,"action":{"Interact":{"target_entity_id":42,"verb":"Talk"}}}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 1);
|
||||
match &ticks[0][0].action {
|
||||
PlayerAction::Interact {
|
||||
target_entity_id,
|
||||
verb,
|
||||
} => {
|
||||
assert_eq!(*target_entity_id, Some(42));
|
||||
assert_eq!(verb.as_deref(), Some("Talk"));
|
||||
}
|
||||
other => panic!("expected Interact, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleport_to_hub_parses() {
|
||||
let content = r#"[{"tick":0,"action":"TeleportToHub"}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 1);
|
||||
assert!(matches!(ticks[0][0].action, PlayerAction::TeleportToHub));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_away_parses() {
|
||||
let content = r#"[{"tick":0,"action":"WalkAway"}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert!(matches!(ticks[0][0].action, PlayerAction::WalkAway));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_replay_scenario() {
|
||||
// Simulates a realistic Gauntlet replay: move, idle, interact, move, teleport
|
||||
let content = r#"[{"tick":0,"action":"MoveNorth"}]
|
||||
[{"tick":1,"action":"MoveNorth"}]
|
||||
[]
|
||||
[{"tick":3,"action":{"Interact":{"target_entity_id":100,"verb":"Talk"}}}]
|
||||
[{"tick":4,"action":"MoveEast"}]
|
||||
[{"tick":5,"action":"TeleportToHub"}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 6);
|
||||
assert_eq!(ticks[0].len(), 1); // MoveNorth
|
||||
assert_eq!(ticks[1].len(), 1); // MoveNorth
|
||||
assert_eq!(ticks[2].len(), 0); // Idle
|
||||
assert_eq!(ticks[3].len(), 1); // Interact
|
||||
assert_eq!(ticks[4].len(), 1); // MoveEast
|
||||
assert_eq!(ticks[5].len(), 1); // TeleportToHub
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,8 +472,8 @@ class ContentIndex:
|
||||
if pair in checked:
|
||||
continue
|
||||
checked.add(pair)
|
||||
if target in npc_rels and cid not in npc_rels.get(target, set()):
|
||||
print(f"XREF WARNING: {cid} has relationship to {target} but no reciprocal found")
|
||||
if target in self.npcs and cid not in npc_rels.get(target, set()):
|
||||
print(f"XREF WARNING: {cid} has relationship to {target} but {target} has no reciprocal entry")
|
||||
warnings += 1
|
||||
return warnings
|
||||
|
||||
|
||||
Reference in New Issue
Block a user