Implements the StartupMessage protocol: client generates world_seed in SessionManager.new_game(), sends it after handshake, server uses it to seed SimRng and sample EntanglementConfig. EntanglementConfig samples flat ∈ [25,35]%, intrigue ∈ [15,25]%, mundane as remainder (D-029). Same seed produces identical config (D-010 determinism). Different seeds produce distinct configs in ≥90% of pairs. Protocol flow: HandshakeMessage (server→client) → StartupMessage with world_seed (client→server) → SimRng initialization → tick loop. 10 Rust tests (determinism, variation, bounds, sum invariant). 9 GDScript test stubs + 2 encode tests for client-side pipeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
239 lines
9.9 KiB
GDScript
239 lines
9.9 KiB
GDScript
## 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
|