Merge remote-tracking branch 'origin/client'
This commit is contained in:
+12
-5
@@ -6,6 +6,18 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- World seed protocol — StartupMessage carries world_seed from client to server after handshake, enabling deterministic NPC population seeding (D-010, D-029)
|
||||
- EntanglementConfig — per-seed NPC population ratios (flat/mundane/intrigue) sampled from seeded RNG with D-029 bounds, ensuring same seed = same world (#175, #178)
|
||||
- Fog debug mode — toggle FogState.debug_exploration to render raw exploration texture as colored overlay for diagnostic use
|
||||
- D-110 through D-112: z-level addressing, subterranean architecture, no instancing decisions
|
||||
- Q-051: speech bubble indicator over speaking NPCs
|
||||
- Sprint 22 "Wire" briefings (server, client, visual, CI, planning, joint)
|
||||
|
||||
### Fixed
|
||||
- Fog system: blocky stair-stepped edges at vision cone boundary — doubled Gaussian blur step size for D-066 compliant 6-8 tile smooth gradient (#569)
|
||||
- Fog system: zero visibility in explored areas — switched bounds calculation from visible_tiles (empty in live server mode) to visible_positions, and removed shader guard that cut off gradient bleed into unexplored tiles (#569)
|
||||
|
||||
### Changed
|
||||
- Simplified vision cone from 3-sector (forward/peripheral/blind) to forward-only 120° arc — server sends only forward-cone tiles, client renders explored tiles behind the player with light fog overlay
|
||||
- Simplified fog shader from 5-layer to 3-layer model (clear, explored, unexplored)
|
||||
@@ -16,11 +28,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
### Removed
|
||||
- db/connectors symlink — all references now use tooling/db/ directly (#568)
|
||||
|
||||
### Added
|
||||
- D-110 through D-112: z-level addressing, subterranean architecture, no instancing decisions
|
||||
- Q-051: speech bubble indicator over speaking NPCs
|
||||
- Sprint 22 "Wire" briefings (server, client, visual, CI, planning, joint)
|
||||
|
||||
## [v0.1.20] — 2026-02-25
|
||||
|
||||
### Added
|
||||
|
||||
@@ -8,7 +8,6 @@ extends Node
|
||||
# Used by fog shader to distinguish visual treatment per tile.
|
||||
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
|
||||
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
|
||||
const VIS_PERIPHERAL: int = 180 # In LOS, peripheral sector — light fog dimming
|
||||
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
|
||||
|
||||
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
|
||||
@@ -29,6 +28,11 @@ var _width: int = 1
|
||||
var _height: int = 1
|
||||
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
|
||||
|
||||
## Debug flag — when true, fog.gdshader renders raw exploration texture
|
||||
## as colored overlay (green=visible, blue=explored, red=unexplored).
|
||||
## Toggle via FogState.debug_exploration = true in the console.
|
||||
var debug_exploration: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_resize(Rect2i(0, 0, 64, 64))
|
||||
@@ -84,15 +88,17 @@ func update_from_state() -> void:
|
||||
# Grow bounds to include newly visible tiles — never shrink, so explored
|
||||
# tiles behind the player stay in the texture and render as deep fog
|
||||
# instead of black. Exploration data is preserved across resizes.
|
||||
var tiles := GameState.visible_tiles
|
||||
if tiles.size() > 0:
|
||||
var new_bounds := _grow_bounds(tiles)
|
||||
# Use visible_positions (always populated from server snapshots) instead of
|
||||
# visible_tiles, which stays empty in live server mode because the server
|
||||
# sends tile_kind but game_state.gd's population check expects "type".
|
||||
var positions: Dictionary = GameState.visible_positions
|
||||
if positions.size() > 0:
|
||||
var new_bounds := _grow_bounds_from_positions(positions)
|
||||
if new_bounds != map_bounds:
|
||||
_resize(new_bounds)
|
||||
|
||||
var ox: int = map_bounds.position.x
|
||||
var oy: int = map_bounds.position.y
|
||||
var positions: Dictionary = GameState.visible_positions
|
||||
|
||||
# TODO(v0.2): gradual decay over game-time instead of immediate EXP_VISIBLE→EXP_EXPLORED
|
||||
|
||||
@@ -130,27 +136,22 @@ func update_from_state() -> void:
|
||||
_prev_visible = positions.duplicate()
|
||||
|
||||
|
||||
func _grow_bounds(tiles: Array) -> Rect2i:
|
||||
## Compute bounds that include all currently visible tiles, merged with
|
||||
## existing bounds so the texture only grows — never shrinks. Explored
|
||||
## tiles always stay within bounds and render as deep fog, not black.
|
||||
func _grow_bounds_from_positions(positions: Dictionary) -> Rect2i:
|
||||
## Compute bounds from visible_positions (Dictionary[Vector2i, bool]).
|
||||
var min_x := 999999
|
||||
var min_y := 999999
|
||||
var max_x := -999999
|
||||
var max_y := -999999
|
||||
for tile in tiles:
|
||||
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
|
||||
continue
|
||||
min_x = mini(min_x, int(tile.x))
|
||||
min_y = mini(min_y, int(tile.y))
|
||||
max_x = maxi(max_x, int(tile.x))
|
||||
max_y = maxi(max_y, int(tile.y))
|
||||
# Guard: all tiles invalid (no x/y) — sentinels would produce negative Rect2i
|
||||
for pos in positions:
|
||||
min_x = mini(min_x, pos.x)
|
||||
min_y = mini(min_y, pos.y)
|
||||
max_x = maxi(max_x, pos.x)
|
||||
max_y = maxi(max_y, pos.y)
|
||||
if min_x > max_x:
|
||||
return map_bounds
|
||||
# Margin for fog gradient bleed at edges
|
||||
var tile_bounds := Rect2i(min_x - 4, min_y - 4, max_x - min_x + 9, max_y - min_y + 9)
|
||||
# Merge with existing bounds — grow only
|
||||
var tile_bounds := Rect2i(min_x - 8, min_y - 8, max_x - min_x + 17, max_y - min_y + 17)
|
||||
if map_bounds.size.x <= 1 and map_bounds.size.y <= 1:
|
||||
return tile_bounds
|
||||
return map_bounds.merge(tile_bounds)
|
||||
|
||||
|
||||
|
||||
@@ -66,6 +66,12 @@ var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode fla
|
||||
# the server's "insert_active" snapshot field, disabling all z-layer-6 UI.
|
||||
var insert_active: bool = true
|
||||
|
||||
# #175: World seed for deterministic simulation (D-010, D-029).
|
||||
# Set by SessionManager.new_game(), sent to server via StartupMessage in SimBridge.
|
||||
# Same seed → same EntanglementConfig → same NPC population across playthroughs.
|
||||
# Persists for the session lifetime; not overwritten by apply_snapshot().
|
||||
var world_seed: int = 0
|
||||
|
||||
# #507: RNG seed for replay determinism — populated from snapshot "rng_seed" field.
|
||||
# Null in v0.1 (server does not yet send this field; protocol change required).
|
||||
var rng_seed: Variant = null
|
||||
|
||||
@@ -31,12 +31,27 @@ func new_game() -> String:
|
||||
save_path, error_string(err)])
|
||||
return ""
|
||||
GameState.current_game_id = game_id
|
||||
|
||||
# #175: Generate world_seed for deterministic simulation (D-010, D-029).
|
||||
# Combines two randi() calls (u32 each) into 63-bit entropy range.
|
||||
# Mask bit 31 of the upper word before shifting to prevent signed overflow:
|
||||
# GDScript int is i64 — if bit 63 is set, MessagePack encodes as negative,
|
||||
# and Rust rmp_serde rejects negative values when deserializing as u64.
|
||||
GameState.world_seed = ((rng.randi() & 0x7FFFFFFF) << 32) | rng.randi()
|
||||
|
||||
# Persist world_seed to save directory so resume_game() can restore it.
|
||||
# Without this, loaded sessions would send seed=0, breaking D-010 determinism.
|
||||
_write_seed_file(save_path, GameState.world_seed)
|
||||
|
||||
return game_id
|
||||
|
||||
|
||||
## Resume an existing game session by setting the active game-id.
|
||||
## Restores world_seed from the save directory for D-010 deterministic replay.
|
||||
func resume_game(game_id: String) -> void:
|
||||
GameState.current_game_id = game_id
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
GameState.world_seed = _read_seed_file(save_path)
|
||||
|
||||
|
||||
## List all game directories under user://saves/ sorted by last-modified (most recent first).
|
||||
@@ -111,6 +126,26 @@ func _cleanup_quit_dialog() -> void:
|
||||
_quit_dialog = null
|
||||
|
||||
|
||||
## Write world_seed to a file in the save directory for session persistence.
|
||||
func _write_seed_file(save_path: String, seed: int) -> void:
|
||||
var file := FileAccess.open(save_path + "world_seed", FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("SessionManager: failed to write seed file: %s" % error_string(FileAccess.get_open_error()))
|
||||
return
|
||||
file.store_64(seed)
|
||||
|
||||
|
||||
## Read world_seed from save directory. Returns 0 if file missing (legacy saves).
|
||||
## Masks the sign bit on read: save files written before the signed-overflow fix
|
||||
## may contain negative i64 values that Rust rmp_serde rejects as u64.
|
||||
func _read_seed_file(save_path: String) -> int:
|
||||
var file := FileAccess.open(save_path + "world_seed", FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("SessionManager: no seed file in %s — using seed=0 (legacy save)" % save_path)
|
||||
return 0
|
||||
return file.get_64() & 0x7FFFFFFFFFFFFFFF
|
||||
|
||||
|
||||
func _find_newest_save(dir_path: String) -> String:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
|
||||
@@ -231,6 +231,26 @@ func _process(delta: float) -> void:
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
# Send startup message with world_seed (#175, D-010/D-029).
|
||||
# Server blocks waiting for this before entering the tick loop.
|
||||
var startup_bytes := Protocol.encode_startup_message(GameState.world_seed)
|
||||
if startup_bytes.size() > 0:
|
||||
var send_err := _bridge.send_message(startup_bytes)
|
||||
if send_err != OK:
|
||||
var reason := "Failed to send startup message: %s" % error_string(send_err)
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
else:
|
||||
var reason := "Failed to encode startup message"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
handshake_complete.emit(server_version)
|
||||
_set_state(ConnectionState.CONNECTED)
|
||||
return
|
||||
|
||||
@@ -427,6 +427,18 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
||||
|
||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||
|
||||
## Encode a StartupMessage to MessagePack bytes (#175).
|
||||
## Sent by the client immediately after handshake validation.
|
||||
## Server reads this to initialize SimRng with the world seed (D-010, D-029).
|
||||
static func encode_startup_message(world_seed: int) -> PackedByteArray:
|
||||
var msg := {"world_seed": world_seed}
|
||||
var result = Messagepack.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: startup message encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
## Encode a PlayerInput to MessagePack bytes.
|
||||
## action_name: one of "MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
|
||||
## "Interact", "UsePerceptionMode", "Pause", "Unpause"
|
||||
|
||||
@@ -69,3 +69,4 @@ func update_fog() -> void:
|
||||
_shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position))
|
||||
_shader_mat.set_shader_parameter("map_size", Vector2(FogState.map_bounds.size))
|
||||
_shader_mat.set_shader_parameter("time", Time.get_ticks_msec() / 1000.0)
|
||||
_shader_mat.set_shader_parameter("debug_exploration", FogState.debug_exploration)
|
||||
|
||||
+59
-40
@@ -2,11 +2,11 @@ shader_type canvas_item;
|
||||
|
||||
// D-059: 3-layer fog shader. Composites over world content.
|
||||
// Layer 1: Clear (forward cone) — transparent, soft gradient edge
|
||||
// Layer 2: Explored fog — light overlay indicating "not fresh", art preserved
|
||||
// Layer 2: Explored fog — light overlay (~25-30%), art fully preserved
|
||||
// Layer 3: Unexplored — solid near-black #12141a
|
||||
|
||||
uniform sampler2D visibility_tex : filter_linear, repeat_disable;
|
||||
uniform sampler2D exploration_tex : filter_linear, repeat_disable;
|
||||
uniform sampler2D exploration_tex : filter_nearest, repeat_disable;
|
||||
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable;
|
||||
uniform sampler2D noise_tex : filter_linear, repeat_enable;
|
||||
uniform vec2 rect_pos; // World-space position of the ColorRect (pixels)
|
||||
@@ -15,14 +15,17 @@ uniform vec2 map_offset; // map_bounds.position (tiles)
|
||||
uniform vec2 map_size; // map_bounds.size (tiles)
|
||||
uniform float tile_size; // Pixels per sim tile
|
||||
uniform float time; // Seconds since start
|
||||
uniform bool debug_exploration = false; // When true, render raw exploration texture
|
||||
|
||||
const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a
|
||||
const vec3 DARK_OVERLAY = vec3(0.02, 0.02, 0.05);
|
||||
const vec3 FOG_OVERLAY_COLOR = vec3(0.06, 0.07, 0.12); // Dark blue-grey fog tint
|
||||
|
||||
// Soft gradient via 7x7 Gaussian blur on visibility (sigma 2.0).
|
||||
// Spreads the cone boundary into a 3-4 tile radius gradient for soft edges.
|
||||
// D-066: 6-8 sim tile soft gradient at cone boundary.
|
||||
// 7x7 Gaussian kernel sampling at 2-texel intervals spreads across ±6 tiles.
|
||||
// Effective sigma = 4 tiles in world space (sigma_kernel=2.0 * step=2.0).
|
||||
// At 2-sigma (8 tiles): weight drops to 0.14, giving ~6-8 tile visible gradient.
|
||||
float sample_visibility(vec2 uv) {
|
||||
vec2 t = 1.0 / map_size;
|
||||
vec2 t = 2.0 / map_size;
|
||||
float sum = 0.0;
|
||||
float weight = 0.0;
|
||||
for (float dy = -3.0; dy <= 3.0; dy += 1.0) {
|
||||
@@ -44,39 +47,55 @@ void fragment() {
|
||||
// Outside known map -> unexplored
|
||||
if (tex_uv.x < 0.0 || tex_uv.x > 1.0 || tex_uv.y < 0.0 || tex_uv.y > 1.0) {
|
||||
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
|
||||
} else {
|
||||
float vis_raw = texture(visibility_tex, tex_uv).r;
|
||||
float vis = sample_visibility(tex_uv);
|
||||
float explored = texture(exploration_tex, tex_uv).r;
|
||||
|
||||
// Don't bleed gradient into never-explored tiles
|
||||
if (explored < 0.01 && vis_raw < 0.01) {
|
||||
vis = 0.0;
|
||||
}
|
||||
|
||||
if (explored < 0.01 && vis < 0.01) {
|
||||
// Unexplored: solid near-black
|
||||
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
|
||||
} else {
|
||||
// Continuous blend: clear vision (vis=1) -> light fog (vis=0).
|
||||
// The Gaussian blur creates a smooth 3-4 tile soft gradient
|
||||
// at the cone edge — no hard boundary.
|
||||
|
||||
// Light fog: subtle animated overlay, preserves all art/info
|
||||
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
|
||||
float fog_alpha = 0.28 + noise_val * 0.04;
|
||||
|
||||
// Clarity ramp from the blurred visibility
|
||||
float clarity = smoothstep(0.0, 0.85, vis);
|
||||
float alpha = mix(fog_alpha, 0.0, clarity);
|
||||
vec3 color = mix(DARK_OVERLAY, vec3(0.0), clarity);
|
||||
|
||||
// Soft edge between explored and unexplored
|
||||
float exp_fade = smoothstep(0.0, 0.3, explored);
|
||||
alpha = mix(1.0, alpha, exp_fade);
|
||||
color = mix(UNEXPLORED_COLOR, color, exp_fade);
|
||||
|
||||
COLOR = vec4(color, alpha);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
float vis = sample_visibility(tex_uv);
|
||||
float explored = texture(exploration_tex, tex_uv).r;
|
||||
|
||||
// Debug mode: render raw exploration texture (bypass fog rendering).
|
||||
// Green = EXP_VISIBLE (255), blue = EXP_EXPLORED (128), red = EXP_UNEXPLORED (0).
|
||||
if (debug_exploration) {
|
||||
if (explored > 0.9) {
|
||||
COLOR = vec4(0.0, explored, 0.0, 0.8); // Green: currently visible
|
||||
} else if (explored > 0.1) {
|
||||
COLOR = vec4(0.0, 0.0, explored * 2.0, 0.8); // Blue: explored
|
||||
} else {
|
||||
COLOR = vec4(0.5, 0.0, 0.0, 0.8); // Red: unexplored
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// D-066: 6-8 sim tile gradient at the cone boundary.
|
||||
// smoothstep(0.02, 0.95, vis) maps the Gaussian-blurred visibility into a
|
||||
// smooth clarity ramp spanning the full blur radius — no tile-stepping.
|
||||
float clarity = smoothstep(0.02, 0.95, vis);
|
||||
|
||||
if (explored < 0.01) {
|
||||
// Unexplored: smooth fade from transparent (inside cone) to solid black (beyond).
|
||||
// The Gaussian vis gradient drives alpha — same ramp as explored fog — so the
|
||||
// cone edge looks seamless regardless of whether adjacent tiles are explored.
|
||||
// World art is NOT preserved in this region: alpha approaches 1.0 outside the cone.
|
||||
COLOR = vec4(UNEXPLORED_COLOR, 1.0 - clarity);
|
||||
return;
|
||||
}
|
||||
|
||||
// Explored fog: light overlay preserving all art and information (D-059).
|
||||
// fog_alpha ~0.27-0.31 -> world appears at ~70% brightness with blue-grey tint.
|
||||
// 8-10s Perlin noise adds atmosphere without obscuring content.
|
||||
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
|
||||
float fog_alpha = 0.27 + noise_val * 0.04;
|
||||
|
||||
// Blend from fog (clarity=0, out of cone) to clear (clarity=1, in cone)
|
||||
float alpha = mix(fog_alpha, 0.0, clarity);
|
||||
vec3 color = mix(FOG_OVERLAY_COLOR, vec3(0.0), clarity);
|
||||
|
||||
// Hard step at the explored/unexplored tile boundary.
|
||||
// exploration_tex uses filter_nearest: explored is exactly 0.0, ~0.5, or 1.0.
|
||||
// smoothstep maps these to 0.0 or 1.0 — no sub-tile blending.
|
||||
float exp_fade = smoothstep(0.0, 0.2, explored);
|
||||
alpha = mix(1.0, alpha, exp_fade);
|
||||
color = mix(UNEXPLORED_COLOR, color, exp_fade);
|
||||
|
||||
COLOR = vec4(color, alpha);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@ func test_fog_visibility_forward_tile() -> void:
|
||||
|
||||
|
||||
func test_fog_visibility_peripheral_tile() -> void:
|
||||
# P1 #4: Peripheral-sector tile writes VIS_PERIPHERAL (180) to _vis_bytes.
|
||||
# P1 #4: Server simplified to forward-only (Sprint 22, #569). All visible
|
||||
# tiles are now written as VIS_FORWARD regardless of visibility_sectors value.
|
||||
# Peripheral sector is no longer a distinct visual state.
|
||||
var fog = _get_fog_state()
|
||||
if fog == null:
|
||||
return
|
||||
@@ -65,8 +67,8 @@ func test_fog_visibility_peripheral_tile() -> void:
|
||||
GameState.visibility_sectors = {Vector2i(10, 8): "Peripheral"}
|
||||
fog.update_from_state()
|
||||
assert_that(fog._vis_bytes[8 * 64 + 10]).override_failure_message(
|
||||
"Peripheral tile at (10,8) should be VIS_PERIPHERAL=%d" % FogState.VIS_PERIPHERAL
|
||||
).is_equal(FogState.VIS_PERIPHERAL)
|
||||
"All visible tiles write VIS_FORWARD after forward-only simplification"
|
||||
).is_equal(FogState.VIS_FORWARD)
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
## Sprint 22 — Entanglement ratio configuration acceptance tests (#175, #178)
|
||||
##
|
||||
## Test-first stubs for the client-side surface of the world_seed feature.
|
||||
## These tests will warn-and-skip until the implementation lands (Tyre, #175).
|
||||
##
|
||||
## Client-side acceptance criteria (#175):
|
||||
## - GameState carries a world_seed field (stores the seed for this session)
|
||||
## - SessionManager.new_game() generates and stores a world_seed
|
||||
## - The IPC startup payload carries world_seed so the server can seed SimRng
|
||||
##
|
||||
## Server-side acceptance criteria (#178) are in:
|
||||
## - server/src/content/entanglement.rs (Rust unit tests)
|
||||
##
|
||||
## Spec: D-029 (30/50/20 entanglement ratio, variable per seed), D-010 (deterministic sim)
|
||||
## Tickets: #175, #178
|
||||
class_name TestEntanglementSprint22
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- Client-side: GameState.world_seed field (#175) ---------------------------
|
||||
|
||||
func test_game_state_has_world_seed_field() -> void:
|
||||
# #175 client-side: GameState must store the world_seed for this session.
|
||||
# The seed is set by SessionManager.new_game() and read by SimBridge to
|
||||
# carry it in the session startup IPC message.
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed not found — test-first stub (awaiting #175)")
|
||||
return
|
||||
# Field exists — verify it is numeric (int or null are both acceptable initial states)
|
||||
var seed_val = GameState.get("world_seed")
|
||||
assert_bool(seed_val == null or seed_val is int).override_failure_message(
|
||||
"GameState.world_seed must be int or null"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_game_state_world_seed_can_be_set_and_read() -> void:
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped (#175 not yet implemented)")
|
||||
return
|
||||
var orig = GameState.get("world_seed")
|
||||
GameState.world_seed = 0xDEADBEEF
|
||||
assert_int(GameState.world_seed).is_equal(0xDEADBEEF)
|
||||
# Restore
|
||||
GameState.world_seed = orig
|
||||
|
||||
|
||||
func test_game_state_world_seed_default_is_null_or_zero() -> void:
|
||||
# Before a session starts, world_seed should be null (no session) or 0 (unset).
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped")
|
||||
return
|
||||
var seed_val = GameState.get("world_seed")
|
||||
assert_bool(seed_val == null or seed_val == 0).override_failure_message(
|
||||
"GameState.world_seed should be null or 0 before any session starts"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Client-side: SessionManager seed generation (#175) -----------------------
|
||||
|
||||
func test_session_manager_exists() -> void:
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager autoload not found — skipped")
|
||||
return
|
||||
assert_that(sm).is_not_null()
|
||||
|
||||
|
||||
func test_session_manager_new_game_generates_world_seed() -> void:
|
||||
# #175: new_game() must generate and store world_seed in GameState.
|
||||
# The seed is a non-zero u64 that will be sent to the server on startup.
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||
return
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
|
||||
# Call new_game() (will create a save dir — acceptable in test environment)
|
||||
var orig_seed = GameState.get("world_seed")
|
||||
var orig_game_id: String = GameState.current_game_id
|
||||
sm.new_game()
|
||||
var generated_seed = GameState.get("world_seed")
|
||||
|
||||
# world_seed must have been set to a non-null, non-zero value
|
||||
assert_bool(generated_seed != null).override_failure_message(
|
||||
"SessionManager.new_game() must set GameState.world_seed (#175)"
|
||||
).is_true()
|
||||
if generated_seed != null:
|
||||
assert_bool(generated_seed != 0).override_failure_message(
|
||||
"Generated world_seed must be non-zero"
|
||||
).is_true()
|
||||
|
||||
# Restore state
|
||||
GameState.current_game_id = orig_game_id
|
||||
GameState.world_seed = orig_seed
|
||||
|
||||
|
||||
func test_session_manager_same_game_id_has_same_seed() -> void:
|
||||
# Resuming a session must restore the original world_seed (not generate a new one).
|
||||
# This ensures deterministic replays work correctly (D-010).
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||
return
|
||||
if not sm.has_method("resume_game"):
|
||||
push_warning("TestEntanglementSprint22: resume_game() missing — skipped")
|
||||
return
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
|
||||
# Set a known seed and game_id, then resume — seed must not be clobbered
|
||||
GameState.world_seed = 12345678
|
||||
var orig_game_id: String = GameState.current_game_id
|
||||
sm.resume_game("20260228-120000-abc123")
|
||||
# resume_game() must NOT overwrite world_seed
|
||||
assert_int(GameState.world_seed).override_failure_message(
|
||||
"resume_game() must not overwrite world_seed — seed is loaded from the save, not regenerated"
|
||||
).is_equal(12345678)
|
||||
GameState.current_game_id = orig_game_id
|
||||
|
||||
|
||||
# -- IPC startup message: world_seed field (#175) ----------------------------
|
||||
|
||||
func test_protocol_encode_startup_message_has_world_seed_field() -> void:
|
||||
# #175 acceptance: startup IPC message must carry "world_seed" key.
|
||||
# Verifies Protocol.encode_startup_message encodes the seed so the server
|
||||
# can deserialize it as StartupMessage { world_seed: u64 }.
|
||||
var seed: int = 0xDEADBEEF # 3735928559 — fits in u32, safely maps to Rust u64
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(seed)
|
||||
assert_bool(bytes.size() > 0).override_failure_message(
|
||||
"Protocol.encode_startup_message must return non-empty bytes"
|
||||
).is_true()
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).override_failure_message(
|
||||
"encode_startup_message output must be valid msgpack: %s" % str(decoded.status)
|
||||
).is_null()
|
||||
var msg = decoded.value
|
||||
assert_bool(msg is Dictionary and msg.has("world_seed")).override_failure_message(
|
||||
"StartupMessage wire payload must contain 'world_seed' key, got: %s" % str(msg)
|
||||
).is_true()
|
||||
assert_int(msg["world_seed"]).override_failure_message(
|
||||
"world_seed must round-trip through msgpack unchanged"
|
||||
).is_equal(seed)
|
||||
|
||||
|
||||
func test_protocol_encode_startup_message_zero_seed() -> void:
|
||||
# Edge case: seed=0 must still encode a valid payload (world_seed: 0).
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(0)
|
||||
assert_bool(bytes.size() > 0).is_true()
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).is_null()
|
||||
assert_int(decoded.value["world_seed"]).is_equal(0)
|
||||
|
||||
|
||||
func test_sim_bridge_can_send_world_seed_in_startup() -> void:
|
||||
# #175 acceptance: "startup IPC message carries a world_seed field"
|
||||
# The client must be able to include world_seed in the session startup payload.
|
||||
# Test-first: verify the API exists (method or field), else warn-and-skip.
|
||||
var sim_bridge = get_node_or_null("/root/SimBridge")
|
||||
if sim_bridge == null:
|
||||
push_warning("TestEntanglementSprint22: SimBridge not found — skipped")
|
||||
return
|
||||
|
||||
# Option A: SimBridge has a world_seed property that is sent during startup
|
||||
if "world_seed" in sim_bridge:
|
||||
sim_bridge.world_seed = 99999
|
||||
assert_int(sim_bridge.world_seed).is_equal(99999)
|
||||
sim_bridge.world_seed = 0
|
||||
return
|
||||
|
||||
# Option B: SimBridge has a set_world_seed() method
|
||||
if sim_bridge.has_method("set_world_seed"):
|
||||
# Method exists — this is the expected API
|
||||
sim_bridge.set_world_seed(99999)
|
||||
return
|
||||
|
||||
# Neither found — test-first stub
|
||||
push_warning(
|
||||
"TestEntanglementSprint22: SimBridge has no world_seed field or set_world_seed() — " +
|
||||
"test-first stub awaiting #175 implementation"
|
||||
)
|
||||
|
||||
|
||||
# -- Protocol: world_seed flows from client to server (#175) ------------------
|
||||
|
||||
func test_apply_snapshot_does_not_clobber_world_seed() -> void:
|
||||
# world_seed is set at session start and must persist across all subsequent snapshots.
|
||||
# Snapshots must not overwrite or clear the world_seed that was set at startup.
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
GameState.world_seed = 42000
|
||||
GameState.apply_snapshot({"tick": 5, "visible_tiles": []})
|
||||
assert_int(GameState.world_seed).override_failure_message(
|
||||
"apply_snapshot() must not clear or overwrite world_seed — seed is set once at session start"
|
||||
).is_equal(42000)
|
||||
GameState.world_seed = null
|
||||
|
||||
|
||||
# -- Seed variation property (#178, informational — full test is Rust-side) ---
|
||||
|
||||
func test_different_seeds_produce_different_configs_informational() -> void:
|
||||
# D-029: "entanglement rate varies per seed to prevent metagaming calibration"
|
||||
# The definitive acceptance test for this is Rust-side (server/src/content/entanglement.rs):
|
||||
# - EntanglementConfig::from_rng(seed_A) == EntanglementConfig::from_rng(seed_A) [deterministic]
|
||||
# - EntanglementConfig::from_rng(seed_A) != EntanglementConfig::from_rng(seed_B) [variable, >=90%]
|
||||
#
|
||||
# This test only verifies the client side: world_seed is a u64 large enough to
|
||||
# have sufficient entropy. A 24-bit game_id hex component alone has 16M combinations;
|
||||
# the full u64 seed provides 2^64 possibilities.
|
||||
#
|
||||
# We verify that two calls to new_game() produce different seeds.
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||
return
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
|
||||
var orig_game_id: String = GameState.current_game_id
|
||||
sm.new_game()
|
||||
var seed_a = GameState.get("world_seed")
|
||||
sm.new_game()
|
||||
var seed_b = GameState.get("world_seed")
|
||||
|
||||
if seed_a == null or seed_b == null:
|
||||
push_warning("TestEntanglementSprint22: new_game() did not set world_seed — test-first stub")
|
||||
GameState.current_game_id = orig_game_id
|
||||
return
|
||||
|
||||
# Two different sessions should produce different seeds
|
||||
assert_bool(seed_a != seed_b).override_failure_message(
|
||||
"Two calls to new_game() must produce different world_seeds (D-029 anti-metagaming)"
|
||||
).is_true()
|
||||
GameState.current_game_id = orig_game_id
|
||||
@@ -0,0 +1,517 @@
|
||||
## Sprint 22 — Fog system acceptance tests (#569)
|
||||
##
|
||||
## Validates FogState data management against the Sprint 22 acceptance criteria:
|
||||
## - Explored tiles never revert to unexplored black (EXP_EXPLORED persistence)
|
||||
## - Bounds grow-only invariant (explored tiles behind player stay in texture)
|
||||
## - All visible tiles written as Forward (server simplified to Forward-only)
|
||||
## - Exploration data survives texture resize (grow-only bounds copy)
|
||||
## - Shader file present with correct fog_alpha constant
|
||||
##
|
||||
## Spec: D-059 (fog shader), D-015 (vision cone), D-066 (dual-scale grid, 6-8 tile gradient)
|
||||
## Ticket: #569
|
||||
class_name TestFogSprint22
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func _get_fog_state() -> Node:
|
||||
var node = get_node_or_null("/root/FogState")
|
||||
if node == null:
|
||||
push_warning("TestFogSprint22: FogState autoload not found — test skipped (awaiting #569)")
|
||||
return node
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
# -- Spec constants (D-059) ---------------------------------------------------
|
||||
|
||||
func test_exp_explored_constant_is_128() -> void:
|
||||
# EXP_EXPLORED = 128 → shader reads this as ~0.502.
|
||||
# smoothstep(0.0, 0.2, 0.502) = 1.0 → exp_fade fully applied.
|
||||
# If EXP_EXPLORED were 0, explored tiles would render as solid unexplored black.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.EXP_EXPLORED).override_failure_message(
|
||||
"EXP_EXPLORED must be 128 — shader exp_fade requires explored value > 0.2 to avoid unexplored-black rendering"
|
||||
).is_equal(128)
|
||||
|
||||
|
||||
func test_exp_unexplored_constant_is_0() -> void:
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.EXP_UNEXPLORED).is_equal(0)
|
||||
|
||||
|
||||
func test_exp_visible_constant_is_255() -> void:
|
||||
# EXP_VISIBLE = 255 → shader reads 1.0, full art visibility (currently in LOS)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.EXP_VISIBLE).is_equal(255)
|
||||
|
||||
|
||||
func test_vis_forward_constant_is_255() -> void:
|
||||
# D-059: VIS_FORWARD = 255 → clear vision, nearly transparent fog overlay
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.VIS_FORWARD).is_equal(255)
|
||||
|
||||
|
||||
func test_vis_hidden_constant_is_0() -> void:
|
||||
# D-059: VIS_HIDDEN = 0 → no vision, fog fully opaque
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.VIS_HIDDEN).is_equal(0)
|
||||
|
||||
|
||||
func test_unexplored_color_spec_value() -> void:
|
||||
# D-059: Unexplored = solid near-black #12141a
|
||||
# Verify the hex value decodes to the expected channel values.
|
||||
var c := Color("#12141a")
|
||||
assert_float(c.r).is_equal_approx(18.0 / 255.0, 0.003)
|
||||
assert_float(c.g).is_equal_approx(20.0 / 255.0, 0.003)
|
||||
assert_float(c.b).is_equal_approx(26.0 / 255.0, 0.003)
|
||||
# Sanity: it IS very dark (all channels < 0.12)
|
||||
assert_float(c.r).is_less(0.12)
|
||||
assert_float(c.g).is_less(0.12)
|
||||
assert_float(c.b).is_less(0.12)
|
||||
|
||||
|
||||
# -- Acceptance: explored tiles persist after leaving LOS (criterion 3) ------
|
||||
|
||||
func test_explored_tile_becomes_exp_explored_after_leaving_los() -> void:
|
||||
# ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black"
|
||||
# When tile (5,5) was in LOS (frame 1) and then leaves LOS (frame 2),
|
||||
# its exploration byte must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
push_warning("TestFogSprint22: update_from_state missing — skipped")
|
||||
return
|
||||
|
||||
# Frame 1: tile (5,5) is visible
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Frame 2: tile (5,5) leaves LOS
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles = []
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Internal state check: _exp_bytes[tile(5,5)] must be EXP_EXPLORED (128)
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
push_warning("TestFogSprint22: _exp_bytes not accessible — data path untestable headlessly")
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
push_warning("TestFogSprint22: _width inaccessible — data path untestable")
|
||||
return
|
||||
var px := 5 - ox
|
||||
var py := 5 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
push_warning("TestFogSprint22: tile (5,5) out of bounds after update — check grow_bounds margin")
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx < 0 or idx >= exp_bytes.size():
|
||||
push_warning("TestFogSprint22: idx %d out of exp_bytes range %d" % [idx, exp_bytes.size()])
|
||||
return
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"Tile (5,5) must be EXP_EXPLORED=128 after leaving LOS — not EXP_UNEXPLORED=0 (#569 regression)"
|
||||
).is_equal(fog_state.EXP_EXPLORED)
|
||||
|
||||
|
||||
func test_explored_tile_is_exp_visible_while_in_los() -> void:
|
||||
# While in LOS, tile exploration byte must be EXP_VISIBLE (255)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(3, 3): true}
|
||||
GameState.visible_tiles = [{"x": 3, "y": 3, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 3 - ox
|
||||
var py := 3 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_VISIBLE)
|
||||
|
||||
|
||||
func test_unexplored_tile_stays_exp_unexplored() -> void:
|
||||
# Tile (7, 8) was never seen — must remain EXP_UNEXPLORED (0)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# See only (5, 5) — tile (7, 8) is not in LOS
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 7 - ox
|
||||
var py := 8 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_UNEXPLORED)
|
||||
|
||||
|
||||
# -- Acceptance: bounds grow-only invariant ------------------------------------
|
||||
|
||||
func test_bounds_never_shrink() -> void:
|
||||
# ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black"
|
||||
# Requires grow-only bounds: once a tile is in the texture, it stays there.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# Frame 1: see (10, 10) → establishes initial bounds
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
var b1: Rect2i = fog_state.map_bounds
|
||||
|
||||
# Frame 2: see (30, 30) → bounds must expand to include both
|
||||
GameState.visible_positions = {Vector2i(30, 30): true}
|
||||
GameState.visible_tiles = [{"x": 30, "y": 30, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
var b2: Rect2i = fog_state.map_bounds
|
||||
|
||||
# Frame 3: back to (10, 10) → bounds must NOT shrink
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
var b3: Rect2i = fog_state.map_bounds
|
||||
|
||||
assert_bool(b2.size.x >= b1.size.x).override_failure_message(
|
||||
"Bounds must grow when player moves to larger region"
|
||||
).is_true()
|
||||
assert_bool(b2.size.y >= b1.size.y).is_true()
|
||||
assert_bool(b3.size.x >= b2.size.x).override_failure_message(
|
||||
"Bounds must not shrink when player returns to previous position (grow-only invariant)"
|
||||
).is_true()
|
||||
assert_bool(b3.size.y >= b2.size.y).is_true()
|
||||
|
||||
|
||||
func test_bounds_include_margin_for_gradient_bleed() -> void:
|
||||
# D-066: 6-8 tile gradient at cone edge requires texture margin.
|
||||
# _grow_bounds adds 8-tile margin on each side (accommodates 7x7 Gaussian
|
||||
# kernel at 2-texel intervals = ±6 tile reach). After seeing (10,10),
|
||||
# bounds should extend at least 4 tiles beyond the visible tile.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var b: Rect2i = fog_state.map_bounds
|
||||
# With 4-tile margin: bounds.position.x <= 10 - 4 = 6
|
||||
assert_bool(b.position.x <= 6).override_failure_message(
|
||||
"FogState bounds must include 4-tile margin for gradient bleed (D-066 gradient spec)"
|
||||
).is_true()
|
||||
assert_bool(b.position.y <= 6).is_true()
|
||||
|
||||
|
||||
# -- Acceptance: Forward-only visibility (Sprint 22 server simplification) ----
|
||||
|
||||
func test_visible_tiles_written_as_vis_forward() -> void:
|
||||
# Sprint 22: server sends only Forward tiles (Peripheral sector removed).
|
||||
# FogState writes VIS_FORWARD (255) for all tiles in visible_positions.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
|
||||
GameState.visible_tiles = [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "Forward"},
|
||||
]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var vis_bytes = fog_state.get("_vis_bytes")
|
||||
if vis_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
for pos in [Vector2i(5, 5), Vector2i(6, 5)]:
|
||||
var px := pos.x - ox
|
||||
var py := pos.y - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
continue
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < vis_bytes.size():
|
||||
assert_int(vis_bytes[idx]).override_failure_message(
|
||||
"All visible tiles should be VIS_FORWARD=255 — server is Forward-only in Sprint 22"
|
||||
).is_equal(fog_state.VIS_FORWARD)
|
||||
|
||||
|
||||
func test_tiles_outside_los_written_as_vis_hidden() -> void:
|
||||
# Tiles in bounds but not in visible_positions must be VIS_HIDDEN (0)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# (5, 7) is inside the padded bounds but not visible — must be VIS_HIDDEN
|
||||
var vis_bytes = fog_state.get("_vis_bytes")
|
||||
if vis_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 5 - ox
|
||||
var py := 7 - oy
|
||||
if px >= 0 and py >= 0 and px < w:
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < vis_bytes.size():
|
||||
assert_int(vis_bytes[idx]).is_equal(fog_state.VIS_HIDDEN)
|
||||
|
||||
|
||||
# -- Acceptance: exploration survives texture resize --------------------------
|
||||
|
||||
func test_exploration_data_preserved_across_bounds_growth() -> void:
|
||||
# D-059: Texture resize must copy old exploration bytes into new texture.
|
||||
# Without this, tiles seen before a resize appear as EXP_UNEXPLORED (black).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# Frame 1: see (5, 5), then leave
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles = []
|
||||
fog_state.update_from_state() # (5,5) → EXP_EXPLORED
|
||||
|
||||
# Frame 2: move far away — forces bounds growth (resize)
|
||||
GameState.visible_positions = {Vector2i(80, 80): true}
|
||||
GameState.visible_tiles = [{"x": 80, "y": 80, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# (5,5) must still be EXP_EXPLORED after the resize
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 5 - ox
|
||||
var py := 5 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
push_warning("TestFogSprint22: (5,5) not in bounds after resize — is copy-on-resize working?")
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"Exploration data at (5,5) must survive bounds growth — EXP_EXPLORED (128) expected after resize"
|
||||
).is_greater_equal(fog_state.EXP_EXPLORED)
|
||||
|
||||
|
||||
# -- Shader file checks (D-059) -----------------------------------------------
|
||||
|
||||
func test_fog_gdshader_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
|
||||
"fog.gdshader must exist — fog rendering requires this shader file (#569)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_defines_fog_alpha() -> void:
|
||||
# D-059: explored fog overlay must be ~25-30% opacity.
|
||||
# fog_alpha constant controls this. Verify the shader defines it.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — shader check skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
push_warning("TestFogSprint22: fog.gdshader is empty or unreadable")
|
||||
return
|
||||
assert_bool(source.contains("fog_alpha")).override_failure_message(
|
||||
"fog.gdshader must define fog_alpha for the 25-30%% explored-tile overlay (D-059)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_defines_smoothstep_clarity_ramp() -> void:
|
||||
# D-059/D-066: smooth gradient requires a clarity ramp (smoothstep).
|
||||
# The blurred visibility → clarity ramp must use smoothstep for smooth gradients.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
return
|
||||
assert_bool(source.contains("smoothstep")).override_failure_message(
|
||||
"fog.gdshader must use smoothstep for the clarity ramp — hard steps violate D-066 gradient spec"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_defines_unexplored_color() -> void:
|
||||
# D-059: unexplored = solid near-black #12141a.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
return
|
||||
assert_bool(source.contains("UNEXPLORED_COLOR")).override_failure_message(
|
||||
"fog.gdshader must define UNEXPLORED_COLOR constant (D-059 #12141a spec)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_uses_gaussian_blur_for_gradient() -> void:
|
||||
# D-066: 6-8 tile soft gradient requires Gaussian blur on visibility texture.
|
||||
# Current implementation: 7x7 kernel at 2-texel intervals (±6 tiles), sigma 2.0
|
||||
# in kernel space = 4.0 tiles effective. At 2-sigma (8 tiles), weight drops to 0.14.
|
||||
# This covers the D-066 "6-8 tile" gradient spec.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
return
|
||||
# 7x7 Gaussian uses dy from -3 to 3
|
||||
assert_bool(source.contains("sample_visibility")).override_failure_message(
|
||||
"fog.gdshader must call sample_visibility() for Gaussian-blurred visibility (D-066 gradient)"
|
||||
).is_true()
|
||||
assert_bool(source.contains("-3.0")).override_failure_message(
|
||||
"fog.gdshader sample_visibility must use 7x7 kernel (±3 tiles) for 6-tile gradient coverage (D-066)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Regression: GameState visible_positions (existing contract) ---------------
|
||||
|
||||
func test_visible_positions_derived_from_visible_tiles_in_server_mode() -> void:
|
||||
# D-020: In real server mode, visible_positions derives from visible_tiles.
|
||||
# Fog rendering depends on this derivation being correct.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 10,
|
||||
"visible_tiles": [
|
||||
{"x": 7, "y": 7, "z": 0, "visibility": "Forward"},
|
||||
{"x": 8, "y": 7, "z": 0, "visibility": "Forward"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(7, 7))).override_failure_message(
|
||||
"visible_positions must be derived from visible_tiles when no explicit visible_positions key"
|
||||
).is_true()
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(8, 7))).is_true()
|
||||
|
||||
|
||||
func test_visibility_sectors_populated_forward_only() -> void:
|
||||
# D-015: visibility_sectors must be populated from visible_tiles.
|
||||
# In Forward-only mode, all sectors are "Forward".
|
||||
GameState.apply_snapshot({
|
||||
"tick": 11,
|
||||
"visible_tiles": [
|
||||
{"x": 4, "y": 4, "z": 0, "visibility": "Forward"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.visibility_sectors.has(Vector2i(4, 4))).is_true()
|
||||
assert_str(GameState.visibility_sectors[Vector2i(4, 4)]).is_equal("Forward")
|
||||
|
||||
|
||||
func test_visible_positions_cleared_on_new_snapshot() -> void:
|
||||
# Old positions from tick N must not persist to tick N+1
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"visible_tiles": [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}],
|
||||
})
|
||||
assert_int(GameState.visible_positions.size()).is_equal(1)
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}],
|
||||
})
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message(
|
||||
"Old visible positions must be cleared when new visible_tiles arrive"
|
||||
).is_false()
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
|
||||
|
||||
|
||||
# -- Performance (D-059) -------------------------------------------------------
|
||||
|
||||
func test_fog_state_update_under_2ms_for_400_tiles() -> void:
|
||||
# D-059: <1ms/frame CPU budget for fog update. Allow 2x margin for test env.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
var positions: Dictionary = {}
|
||||
var tiles: Array = []
|
||||
for x in range(20):
|
||||
for y in range(20):
|
||||
positions[Vector2i(x, y)] = true
|
||||
tiles.append({"x": x, "y": y, "z": 0, "visibility": "Forward"})
|
||||
GameState.visible_positions = positions
|
||||
GameState.visible_tiles = tiles
|
||||
|
||||
var start := Time.get_ticks_usec()
|
||||
fog_state.update_from_state()
|
||||
var elapsed_ms := (Time.get_ticks_usec() - start) / 1000.0
|
||||
|
||||
assert_float(elapsed_ms).override_failure_message(
|
||||
"FogState.update_from_state() must complete in <2ms for 400 tiles (spec: <1ms D-059)"
|
||||
).is_less(2.0)
|
||||
@@ -80,6 +80,21 @@ impl LocalBridge {
|
||||
}
|
||||
|
||||
impl SimBridge for LocalBridge {
|
||||
fn receive_startup(&self) -> Result<super::StartupMessage, BridgeError> {
|
||||
let mut reader = self
|
||||
.reader
|
||||
.lock()
|
||||
.map_err(|e| BridgeError::MutexPoisoned(format!("reader: {}", e)))?;
|
||||
match read_framed(reader.get_mut())? {
|
||||
Some(payload) => {
|
||||
let msg: super::StartupMessage = rmp_serde::from_slice(&payload)?;
|
||||
tracing::info!("received startup message: world_seed={}", msg.world_seed);
|
||||
Ok(msg)
|
||||
}
|
||||
None => Err(BridgeError::Disconnected),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_handshake(&self) -> Result<(), BridgeError> {
|
||||
use super::types::{HandshakeMessage, PROTOCOL_VERSION};
|
||||
let msg = HandshakeMessage {
|
||||
|
||||
@@ -40,6 +40,11 @@ pub trait SimBridge: Send + Sync {
|
||||
/// any ObserverSnapshot is sent.
|
||||
fn send_handshake(&self) -> Result<(), BridgeError>;
|
||||
|
||||
/// Receive the client's startup message containing the world seed (#175).
|
||||
/// Called exactly once, after send_handshake(), before entering the tick loop.
|
||||
/// Blocks until the client sends the message.
|
||||
fn receive_startup(&self) -> Result<StartupMessage, BridgeError>;
|
||||
|
||||
/// Send an observer snapshot to the client
|
||||
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError>;
|
||||
|
||||
@@ -64,6 +69,10 @@ impl BridgeResource {
|
||||
self.inner.send_handshake()
|
||||
}
|
||||
|
||||
pub fn receive_startup(&self) -> Result<StartupMessage, BridgeError> {
|
||||
self.inner.receive_startup()
|
||||
}
|
||||
|
||||
pub fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
||||
self.inner.send_snapshot(snapshot)
|
||||
}
|
||||
|
||||
@@ -129,6 +129,28 @@ impl TcpBridge {
|
||||
}
|
||||
|
||||
impl SimBridge for TcpBridge {
|
||||
fn receive_startup(&self) -> Result<super::StartupMessage, BridgeError> {
|
||||
let mut reader = self
|
||||
.reader
|
||||
.lock()
|
||||
.map_err(|e| BridgeError::MutexPoisoned(format!("reader: {}", e)))?;
|
||||
// Toggle to blocking for reliable startup message read.
|
||||
// The client sends StartupMessage immediately after handshake validation,
|
||||
// so this read should complete quickly.
|
||||
reader.get_mut().set_nonblocking(false).map_err(BridgeError::Io)?;
|
||||
let result = read_framed(reader.get_mut());
|
||||
// Restore non-blocking for the tick loop
|
||||
reader.get_mut().set_nonblocking(true).map_err(BridgeError::Io)?;
|
||||
match result? {
|
||||
Some(payload) => {
|
||||
let msg: super::StartupMessage = rmp_serde::from_slice(&payload)?;
|
||||
tracing::info!("received startup message: world_seed={}", msg.world_seed);
|
||||
Ok(msg)
|
||||
}
|
||||
None => Err(BridgeError::Disconnected),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_handshake(&self) -> Result<(), BridgeError> {
|
||||
use super::types::{HandshakeMessage, PROTOCOL_VERSION};
|
||||
let msg = HandshakeMessage {
|
||||
|
||||
@@ -29,6 +29,25 @@ pub struct HandshakeMessage {
|
||||
pub protocol_version: u8,
|
||||
}
|
||||
|
||||
/// Startup message sent by the client after receiving HandshakeMessage (#175).
|
||||
/// Contains the world seed for deterministic simulation (D-010, D-029).
|
||||
///
|
||||
/// Protocol flow:
|
||||
/// 1. Server sends HandshakeMessage (server → client)
|
||||
/// 2. Client validates protocol_version
|
||||
/// 3. Client sends StartupMessage (client → server)
|
||||
/// 4. Server reads world_seed, initializes SimRng
|
||||
/// 5. Normal tick loop begins
|
||||
///
|
||||
/// Wire format: MessagePack, same 4-byte length-prefixed framing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct StartupMessage {
|
||||
/// World seed for SimRng initialization.
|
||||
/// Generated by SessionManager.new_game() on the client.
|
||||
/// Same seed → same EntanglementConfig → same NPC population (D-029).
|
||||
pub world_seed: u64,
|
||||
}
|
||||
|
||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||
/// Contains all information visible to the observer at a given tick.
|
||||
///
|
||||
@@ -814,6 +833,31 @@ mod tests {
|
||||
assert_ne!(decoded.protocol_version, wrong_version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_roundtrip() {
|
||||
let msg = StartupMessage { world_seed: 0xDEADBEEF };
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded, msg);
|
||||
assert_eq!(decoded.world_seed, 0xDEADBEEF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_zero_seed() {
|
||||
let msg = StartupMessage { world_seed: 0 };
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded.world_seed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_max_seed() {
|
||||
let msg = StartupMessage { world_seed: u64::MAX };
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded.world_seed, u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_is_distinct_from_snapshot() {
|
||||
// HandshakeMessage and ObserverSnapshot are different types on the wire.
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! EntanglementConfig — per-seed NPC population entanglement ratios (D-029, #175, #178).
|
||||
//!
|
||||
//! Per D-029: NPC population split is ~30% flat / ~50% mundane / ~20% intrigue.
|
||||
//! The entanglement rate varies per world seed to prevent player metagaming calibration
|
||||
//! across playthroughs. Two runs with the same seed must produce identical ratios;
|
||||
//! two runs with different seeds must (in ≥90% of cases) produce different ratios.
|
||||
//!
|
||||
//! ## Acceptance criteria (#175 / #178)
|
||||
//!
|
||||
//! 1. `EntanglementConfig::from_seed(seed_a) == EntanglementConfig::from_seed(seed_a)` (deterministic)
|
||||
//! 2. `EntanglementConfig::from_seed(seed_a) != EntanglementConfig::from_seed(seed_b)` for ≥90% of random pairs
|
||||
//! 3. `flat_ratio + mundane_ratio + intrigue_ratio == 100`
|
||||
//! 4. Ratios stay within bounds: flat ∈ [25,35], mundane ∈ [45,55], intrigue ∈ [15,25]
|
||||
//!
|
||||
//! ## Wire format (#175)
|
||||
//!
|
||||
//! The world seed flows: client new_game() → world_seed field in session startup IPC →
|
||||
//! server reads seed → SimRng::from_seed(seed) → EntanglementConfig::from_rng(&mut rng).
|
||||
//! This means two clients using the same seed produce identical NPC populations.
|
||||
|
||||
use crate::simulation::rng::SimRng;
|
||||
use rand::Rng;
|
||||
|
||||
/// NPC population entanglement ratios for one world seed.
|
||||
///
|
||||
/// All ratios are percentages (integer, sum to 100).
|
||||
/// Ranges per D-029: flat 25-35%, mundane 45-55%, intrigue 15-25%.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EntanglementConfig {
|
||||
/// % of NPCs with purely flat routines — social wallpaper, no triangle involvement
|
||||
pub flat_ratio: u8,
|
||||
/// % of NPCs in mundane triangles — neighbor disputes, workplace rivalries, no conspiracy
|
||||
pub mundane_ratio: u8,
|
||||
/// % of NPCs entangled with intrigue content — connected to conspiracy modules
|
||||
pub intrigue_ratio: u8,
|
||||
}
|
||||
|
||||
impl EntanglementConfig {
|
||||
/// Sample entanglement ratios from the given RNG.
|
||||
///
|
||||
/// Must be called exactly once at session start after `SimRng::new(world_seed)`.
|
||||
/// Subsequent calls to the same seeded RNG will produce different values
|
||||
/// (the RNG state advances), so `from_seed()` is the canonical API for tests.
|
||||
pub fn from_rng(rng: &mut SimRng) -> Self {
|
||||
// Sample flat_ratio ∈ [25, 35] — step of 1%
|
||||
let flat: u8 = rng.rng.random_range(25u8..=35u8);
|
||||
// Constrain intrigue range so mundane = 100 - flat - intrigue stays in [45, 55].
|
||||
// mundane ≥ 45 → intrigue ≤ 55 - flat; mundane ≤ 55 → intrigue ≥ 45 - flat.
|
||||
// Intersect with D-029 base range [15, 25].
|
||||
let intrigue_min: u8 = (45u8.saturating_sub(flat)).max(15);
|
||||
let intrigue_max: u8 = (55u8.saturating_sub(flat)).min(25);
|
||||
let intrigue: u8 = rng.rng.random_range(intrigue_min..=intrigue_max);
|
||||
// Mundane fills the remainder (ensures sum = 100, stays in [45, 55])
|
||||
let mundane: u8 = 100 - flat - intrigue;
|
||||
Self {
|
||||
flat_ratio: flat,
|
||||
mundane_ratio: mundane,
|
||||
intrigue_ratio: intrigue,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: create EntanglementConfig from a raw seed value.
|
||||
///
|
||||
/// Equivalent to `EntanglementConfig::from_rng(&mut SimRng::new(seed))`.
|
||||
/// Use in tests for determinism assertions.
|
||||
pub fn from_seed(seed: u64) -> Self {
|
||||
let mut rng = SimRng::new(seed);
|
||||
Self::from_rng(&mut rng)
|
||||
}
|
||||
|
||||
/// Verify internal consistency: ratios must sum to 100.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.flat_ratio as u16 + self.mundane_ratio as u16 + self.intrigue_ratio as u16 == 100
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Acceptance criterion 1: Determinism (#178)
|
||||
// EntanglementConfig::from_seed(seed_A) == EntanglementConfig::from_seed(seed_A)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn same_seed_produces_same_config() {
|
||||
// D-010 / D-029: deterministic simulation must produce identical NPC populations
|
||||
// for the same world seed across all playthroughs.
|
||||
let config_a = EntanglementConfig::from_seed(42);
|
||||
let config_b = EntanglementConfig::from_seed(42);
|
||||
assert_eq!(
|
||||
config_a, config_b,
|
||||
"Same world seed must produce identical EntanglementConfig (D-010 determinism)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism_holds_for_multiple_seeds() {
|
||||
// Spot-check several seeds to ensure the determinism invariant holds broadly.
|
||||
for seed in [0u64, 1, 100, 9999, u64::MAX / 2, u64::MAX] {
|
||||
let c1 = EntanglementConfig::from_seed(seed);
|
||||
let c2 = EntanglementConfig::from_seed(seed);
|
||||
assert_eq!(
|
||||
c1, c2,
|
||||
"Seed {seed}: EntanglementConfig must be deterministic"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Acceptance criterion 2: Variation (#178)
|
||||
// from_seed(A) != from_seed(B) for ≥90% of random seed pairs
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn different_seeds_produce_different_configs_at_least_90_percent() {
|
||||
// D-029: entanglement rate varies per seed to prevent metagaming calibration.
|
||||
// ≥90% of random seed pairs must produce distinct EntanglementConfig values.
|
||||
let test_seeds: Vec<u64> = (0u64..100).collect();
|
||||
let configs: Vec<EntanglementConfig> =
|
||||
test_seeds.iter().map(|&s| EntanglementConfig::from_seed(s)).collect();
|
||||
|
||||
let mut distinct_pairs: usize = 0;
|
||||
let mut total_pairs: usize = 0;
|
||||
for i in 0..configs.len() {
|
||||
for j in (i + 1)..configs.len() {
|
||||
total_pairs += 1;
|
||||
if configs[i] != configs[j] {
|
||||
distinct_pairs += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ratio = distinct_pairs as f64 / total_pairs as f64;
|
||||
assert!(
|
||||
ratio >= 0.90,
|
||||
"Only {}/{} ({:.1}%) seed pairs produced distinct EntanglementConfig — need ≥90% (D-029)",
|
||||
distinct_pairs,
|
||||
total_pairs,
|
||||
ratio * 100.0
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Acceptance criterion 3: Ratios sum to 100
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ratios_sum_to_100() {
|
||||
// Invariant: flat + mundane + intrigue == 100 for any seed.
|
||||
for seed in [0u64, 1, 42, 12345, u64::MAX] {
|
||||
let c = EntanglementConfig::from_seed(seed);
|
||||
assert!(
|
||||
c.is_valid(),
|
||||
"Seed {seed}: ratios must sum to 100, got {}+{}+{}={}",
|
||||
c.flat_ratio,
|
||||
c.mundane_ratio,
|
||||
c.intrigue_ratio,
|
||||
c.flat_ratio as u16 + c.mundane_ratio as u16 + c.intrigue_ratio as u16
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Acceptance criterion 4: Ratios within D-029 bounds
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn flat_ratio_within_bounds() {
|
||||
// D-029: flat ∈ [25, 35]%
|
||||
for seed in 0u64..200 {
|
||||
let c = EntanglementConfig::from_seed(seed);
|
||||
assert!(
|
||||
c.flat_ratio >= 25 && c.flat_ratio <= 35,
|
||||
"Seed {seed}: flat_ratio {} out of [25, 35] bounds",
|
||||
c.flat_ratio
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mundane_ratio_within_bounds() {
|
||||
// D-029: mundane ∈ [45, 55]%
|
||||
// Achieved by constraining intrigue range based on flat value so that
|
||||
// mundane = 100 - flat - intrigue always stays within spec bounds.
|
||||
for seed in 0u64..200 {
|
||||
let c = EntanglementConfig::from_seed(seed);
|
||||
assert!(
|
||||
c.is_valid(),
|
||||
"Seed {seed}: ratios must sum to 100"
|
||||
);
|
||||
assert!(
|
||||
c.mundane_ratio >= 45 && c.mundane_ratio <= 55,
|
||||
"Seed {seed}: mundane_ratio {} out of D-029 [45, 55] bounds",
|
||||
c.mundane_ratio
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intrigue_ratio_within_bounds() {
|
||||
// D-029: intrigue ∈ [15, 25]%
|
||||
for seed in 0u64..200 {
|
||||
let c = EntanglementConfig::from_seed(seed);
|
||||
assert!(
|
||||
c.intrigue_ratio >= 15 && c.intrigue_ratio <= 25,
|
||||
"Seed {seed}: intrigue_ratio {} out of [15, 25] bounds",
|
||||
c.intrigue_ratio
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn seed_zero_produces_valid_config() {
|
||||
let c = EntanglementConfig::from_seed(0);
|
||||
assert!(c.is_valid(), "Seed 0 must produce valid config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_max_produces_valid_config() {
|
||||
let c = EntanglementConfig::from_seed(u64::MAX);
|
||||
assert!(c.is_valid(), "Seed u64::MAX must produce valid config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_rng_and_from_seed_are_consistent() {
|
||||
// from_seed() is the canonical API; from_rng() is the runtime API.
|
||||
// When given a freshly-seeded SimRng, from_rng() must match from_seed().
|
||||
let seed = 999u64;
|
||||
let via_seed = EntanglementConfig::from_seed(seed);
|
||||
let mut rng = SimRng::new(seed);
|
||||
let via_rng = EntanglementConfig::from_rng(&mut rng);
|
||||
assert_eq!(
|
||||
via_seed, via_rng,
|
||||
"from_seed() and from_rng(SimRng::new(seed)) must produce identical results"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
//! Content schema is decoupled from ECS components. The spawn module
|
||||
//! handles the mapping between the two representations.
|
||||
|
||||
pub mod entanglement;
|
||||
pub mod hot_reload;
|
||||
pub mod instantiation;
|
||||
pub mod line_pool;
|
||||
|
||||
+12
-3
@@ -131,10 +131,19 @@ fn main() {
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
tracing::info!("Handshake sent, initializing simulation");
|
||||
// Read client's startup message containing world_seed (#175).
|
||||
// Client sends this immediately after validating the handshake.
|
||||
let startup = bridge.receive_startup().unwrap_or_else(|e| {
|
||||
tracing::error!("Failed to receive startup message: {}", e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// RNG seed: test-mode defaults to 42 for deterministic replay
|
||||
let seed = seed_flag.unwrap_or(if test_mode { 42 } else { 0 });
|
||||
tracing::info!("Handshake complete, initializing simulation");
|
||||
|
||||
// RNG seed: --seed flag overrides client's world_seed (useful for testing).
|
||||
// Production: client sends world_seed via StartupMessage (#175).
|
||||
// Test mode default: 42 for deterministic replay.
|
||||
let seed = seed_flag.unwrap_or(if test_mode { 42 } else { startup.world_seed });
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
|
||||
@@ -84,7 +84,12 @@ fn server_subprocess_sends_snapshot_on_connect() {
|
||||
handshake.protocol_version, PROTOCOL_VERSION
|
||||
);
|
||||
|
||||
// 5. Send one PlayerInput (idle tick 0)
|
||||
// 5. Send StartupMessage with world_seed (#175)
|
||||
let startup = StartupMessage { world_seed: 42 };
|
||||
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize StartupMessage");
|
||||
write_framed(&mut writer, &startup_payload).expect("send StartupMessage to server");
|
||||
|
||||
// 6. Send one PlayerInput (idle tick 0)
|
||||
let inputs = vec![PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
@@ -92,14 +97,14 @@ fn server_subprocess_sends_snapshot_on_connect() {
|
||||
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize PlayerInput");
|
||||
write_framed(&mut writer, &payload).expect("send PlayerInput to server");
|
||||
|
||||
// 6. Read one ObserverSnapshot
|
||||
// 7. Read one ObserverSnapshot
|
||||
let response = read_framed(&mut reader)
|
||||
.expect("read snapshot frame")
|
||||
.expect("server closed connection before sending snapshot");
|
||||
let snapshot: ObserverSnapshot =
|
||||
rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot");
|
||||
|
||||
// 7. Assert protocol correctness (D-020)
|
||||
// 8. Assert protocol correctness (D-020)
|
||||
assert_eq!(
|
||||
snapshot.version, PROTOCOL_VERSION,
|
||||
"protocol version mismatch: got {}, expected {}",
|
||||
@@ -117,7 +122,7 @@ fn server_subprocess_sends_snapshot_on_connect() {
|
||||
.any(|e| matches!(e.kind, EntityKind::Player));
|
||||
assert!(has_player, "snapshot must contain a Player entity");
|
||||
|
||||
// 8. Clean up: drop connection so the server exits its game loop
|
||||
// 9. Clean up: drop connection so the server exits its game loop
|
||||
drop(reader);
|
||||
drop(writer);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user