diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 50318fad5..979008f4a 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -240,16 +240,16 @@ func apply_snapshot(snapshot: Dictionary) -> void: medium_sound_events = [] close_sound_events = [] - # D-073 (#529): Extract zone_id from the player's current tile (server-authoritative). - # O(1) via visible_positions dict would be ideal, but tiles are arrays without - # positional indexing — use the same tile iteration below instead. - current_zone_id = "" - var _px := int(player_position.x) - var _py := int(player_position.y) - for _ztile in visible_tiles: - if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py: - current_zone_id = _ztile.get("zone_id", "") - break + # D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles + # (populated above from either "tiles" test-mode key or "visible_tiles" live key). + # Must use the member var, not snapshot.visible_tiles, so test mode is covered. + var _tile_by_coord: Dictionary = {} + for vtile in visible_tiles: + if vtile is Dictionary and vtile.has("x") and vtile.has("y"): + _tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile + var player_pos_key := Vector2i(int(player_position.x), int(player_position.y)) + var player_tile = _tile_by_coord.get(player_pos_key, null) + current_zone_id = player_tile.get("zone_id", "") if player_tile else "" # v2: visible_tiles with visibility sectors # Derives visible_positions when not explicitly provided (real server mode) diff --git a/client/tests/test_snapshot_zone_id.gd b/client/tests/test_snapshot_zone_id.gd new file mode 100644 index 000000000..6f5abf710 --- /dev/null +++ b/client/tests/test_snapshot_zone_id.gd @@ -0,0 +1,211 @@ +## Sprint 16 #543: GameState.current_zone_id population tests. +## Verifies that apply_snapshot() correctly extracts zone_id from the player's +## current tile after the O(N) → O(1) refactor. Behavior must be identical +## before and after the change (net-zero behavioral change per ticket spec). +## Spec refs: D-073, #543, #529. +class_name TestSnapshotZoneId +extends GdUnitTestSuite + + +func before_test() -> void: + GameState.current_zone_id = "" + GameState.player_position = Vector2.ZERO + GameState.visible_tiles = [] + GameState.visible_positions = {} + GameState.visibility_sectors = {} + + +# -- Happy path (S16-Z01) ------------------------------------------------------ + +func test_zone_id_populated_when_player_on_zone_tile() -> void: + # S16-Z01: Player at (5,5), visible_tiles has zone_id "zone_alpha" at (5,5). + # D-073: current_zone_id must be set to the server-authoritative zone_id. + GameState.apply_snapshot({ + "tick": 1, + "entities": [ + {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}, + ], + "visible_tiles": [ + {"x": 3, "y": 3, "z": 0, "type": "floor", "zone_id": "zone_foyer"}, + {"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"}, + {"x": 7, "y": 7, "z": 0, "type": "floor", "zone_id": "zone_beta"}, + ], + }) + assert_that(GameState.current_zone_id).override_failure_message( + "current_zone_id must match zone_id at player tile (5,5)" + ).is_equal("zone_alpha") + assert_that(GameState.current_zone_id.is_empty()).override_failure_message( + "current_zone_id must be non-empty when player is on a zone-tagged tile" + ).is_false() + + +# -- Missing zone_id field (S16-Z02) ------------------------------------------- + +func test_zone_id_empty_when_tile_lacks_zone_field() -> void: + # S16-Z02: Player's tile exists but has no zone_id key → empty string. + # Server may send tiles without zone_id when tile is unzoned. + GameState.apply_snapshot({ + "tick": 1, + "entities": [ + {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}, + ], + "visible_tiles": [ + {"x": 5, "y": 5, "z": 0, "type": "floor"}, + ], + }) + assert_that(GameState.current_zone_id).override_failure_message( + "Tile without zone_id field → current_zone_id must default to empty string" + ).is_equal("") + + +# -- Player off-tile (S16-Z03) ------------------------------------------------- + +func test_zone_id_empty_when_player_not_on_any_tile() -> void: + # S16-Z03: Player at (99,99) but no tile at that position → empty string. + GameState.apply_snapshot({ + "tick": 1, + "entities": [ + {"entity_id": 1, "x": 99.0, "y": 99.0, "z": 0, "kind": {"variant": "Player", "data": null}}, + ], + "visible_tiles": [ + {"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"}, + ], + }) + assert_that(GameState.current_zone_id).override_failure_message( + "Player at (99,99) with no matching tile → current_zone_id must be empty" + ).is_equal("") + + +# -- Empty tiles (S16-Z04) ----------------------------------------------------- + +func test_zone_id_empty_on_empty_visible_tiles() -> void: + # S16-Z04: No visible tiles at all → empty string, no crash. + GameState.apply_snapshot({ + "tick": 1, + "entities": [ + {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}, + ], + "visible_tiles": [], + }) + assert_that(GameState.current_zone_id).override_failure_message( + "Empty visible_tiles → current_zone_id must be empty string (no crash)" + ).is_equal("") + + +# -- Correct tile selected among many (S16-Z05) -------------------------------- + +func test_zone_id_selects_correct_tile_among_many() -> void: + # S16-Z05: 10x10 tile grid, player at (6,4). Only zone_6_4 must be selected. + # Tests that the lookup doesn't accidentally match a neighboring tile. + var tiles: Array = [] + for tx in range(10): + for ty in range(10): + tiles.append({"x": tx, "y": ty, "z": 0, "type": "floor", + "zone_id": "zone_%d_%d" % [tx, ty]}) + GameState.apply_snapshot({ + "tick": 1, + "entities": [ + {"entity_id": 1, "x": 6.0, "y": 4.0, "z": 0, "kind": {"variant": "Player", "data": null}}, + ], + "visible_tiles": tiles, + }) + assert_that(GameState.current_zone_id).override_failure_message( + "Player at (6,4) must get zone_6_4, not a neighboring tile" + ).is_equal("zone_6_4") + + +# -- Zone transition (S16-Z06) ------------------------------------------------- + +func test_zone_id_updates_on_zone_transition() -> void: + # S16-Z06: Player moves from zone_alpha (5,5) to zone_beta (6,5). + # current_zone_id must update on each snapshot. + GameState.apply_snapshot({ + "tick": 1, + "entities": [ + {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}, + ], + "visible_tiles": [ + {"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"}, + {"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"}, + ], + }) + assert_that(GameState.current_zone_id).is_equal("zone_alpha") + + GameState.apply_snapshot({ + "tick": 2, + "entities": [ + {"entity_id": 1, "x": 6.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}, + ], + "visible_tiles": [ + {"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"}, + {"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"}, + ], + }) + assert_that(GameState.current_zone_id).override_failure_message( + "After moving to (6,5), current_zone_id must update to zone_beta" + ).is_equal("zone_beta") + + +# -- Regression: visible_positions unaffected (S16-Z07) ----------------------- + +func test_visible_positions_unaffected_by_zone_id_refactor() -> void: + # S16-Z07: The O(1) refactor must not break visible_positions population. + # Both zone_id and visible_positions derive from the same visible_tiles loop — + # verify both are correctly populated after a single apply_snapshot(). + GameState.apply_snapshot({ + "tick": 1, + "entities": [ + {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}, + ], + "visible_tiles": [ + {"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha", "visibility": "Forward"}, + {"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta", "visibility": "Peripheral"}, + ], + }) + assert_that(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message( + "visible_positions must still contain (5,5) after zone_id refactor" + ).is_true() + assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_true() + assert_that(GameState.current_zone_id).override_failure_message( + "current_zone_id must be zone_alpha after same apply_snapshot call" + ).is_equal("zone_alpha") + + +# -- Fractional player position (S16-Z08) -------------------------------------- + +func test_zone_id_uses_int_truncation_of_player_position() -> void: + # S16-Z08: Player at (5.7, 5.9) → int(5.7)=5, int(5.9)=5 → matches tile (5,5). + # Server sends player coords as floats; zone lookup must truncate to tile index. + GameState.apply_snapshot({ + "tick": 1, + "entities": [ + {"entity_id": 1, "x": 5.7, "y": 5.9, "z": 0, "kind": {"variant": "Player", "data": null}}, + ], + "visible_tiles": [ + {"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"}, + ], + }) + assert_that(GameState.current_zone_id).override_failure_message( + "Player at (5.7,5.9) must match tile (5,5) — int() truncation applies" + ).is_equal("zone_alpha") + + +# -- Test-mode tiles key (S16-Z09) -------------------------------------------- + +func test_zone_id_works_with_test_mode_tiles_key() -> void: + # S16-Z09: Test mode sends "tiles" key, not "visible_tiles". + # The O(1) refactor uses member visible_tiles (covers both paths). + # Regression guard: if _tile_by_coord is moved into the snapshot.visible_tiles + # block only, this test fails — catching the exact regression Tyre flagged. + GameState.apply_snapshot({ + "tick": 1, + "entities": [ + {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}, + ], + "tiles": [ + {"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"}, + ], + }) + assert_that(GameState.current_zone_id).override_failure_message( + "Test-mode 'tiles' key must populate zone_id via member visible_tiles" + ).is_equal("zone_alpha")