b6c6ddfbc4
Implements docs/architecture/world_persistence_impl.md §9's backend task: chunk/room/revision Postgres schema (migration 0005), a Python port of the world.md §3.6 binary chunk codec with golden-file tests, a WorldStore service (world.md §5.1 minus flush_dirty/rebuild_index), the public world-content router (rooms/chunks CRUD, optimistic concurrency, batch writes) and the internal read-only API for dedicated servers, and game_servers.py changes: a per-spawn bearer token, --api-url/--server-token args, and a real "ready"-field wait replacing _await_port, which bound the UDP port before content existed and failed open (no error) past its deadline. create_world now auto-creates the "main" room. Deviations/findings, not silently decided: - chunk_codec.py implements real IEEE-754 float32 packing rather than mirroring a confirmed bug in scripts/chunk_data_decoder.gd's _read_float32/_write_float32, which do a lossy int<->float cast instead of a bit reinterpretation (verified against Godot 4.6.2 headless) and will corrupt any non-integer position/rotation/scale/bounds value. Not fixed here (client-side, out of scope); flagged for the client task. - world_persistence_impl.md references a world's monotonic content_revision (§6/§7.2/§8.1) but never defines where it's stored. Resolved as MAX(id) over the append-only world_chunk_revisions table, scoped to world_id, rather than adding an undocumented column. - No existing Godot-side golden fixture/test vectors were found to reuse, so tests/fixtures/chunks/*.bin are authored fresh from this implementation (see generate_fixtures.py). Verification: 132/132 backend pytest tests pass against a real ephemeral Postgres (both reusing an already-running instance and cold via conftest.py's own docker-compose bootstrap), including migration upgrade/downgrade/upgrade, codec round-trip + validation-error tests, and full HTTP round trips for PUT/GET/batch/delete/revisions/rollback and the internal API's token auth. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
220 lines
9.0 KiB
Python
220 lines
9.0 KiB
Python
"""Tests for app.services.chunk_codec -- the Python port of world.md §3.6.
|
|
|
|
Pure unit tests: no database, no app, no network. Golden fixtures live in
|
|
tests/fixtures/chunks/ (see generate_fixtures.py there for how/why).
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.services import chunk_codec as cc
|
|
|
|
FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "chunks"
|
|
|
|
|
|
def _read_fixture(name: str) -> bytes:
|
|
return (FIXTURES_DIR / name).read_bytes()
|
|
|
|
|
|
class TestGoldenFixtures:
|
|
"""Byte-stability: decode a committed fixture, re-encode it, and the
|
|
bytes must match exactly (world.md §3.6.3)."""
|
|
|
|
@pytest.mark.parametrize("filename", [
|
|
"empty_chunk.bin", "simple_tile_chunk.bin", "chunk_with_objects.bin",
|
|
])
|
|
def test_round_trip_is_byte_identical(self, filename):
|
|
original = _read_fixture(filename)
|
|
decoded = cc.decode_binary(original)
|
|
re_encoded = cc.encode_binary(decoded)
|
|
assert re_encoded == original
|
|
|
|
def test_simple_tile_chunk_logical_values(self):
|
|
decoded = cc.decode_binary(_read_fixture("simple_tile_chunk.bin"))
|
|
assert decoded.world_id == "12"
|
|
assert decoded.room_id == "main"
|
|
assert decoded.coord == (3, -1, 0)
|
|
assert decoded.bounds.min == (96.0, -32.0, 0.0)
|
|
assert decoded.bounds.size == (32.0, 32.0, 1.0)
|
|
assert len(decoded.tile_layers) == 1
|
|
layer = decoded.tile_layers[0]
|
|
assert layer.name == "ground"
|
|
assert (layer.width, layer.height, layer.depth) == (4, 4, 1)
|
|
assert len(layer.palette) == 3
|
|
assert layer.tiles == [0, 1, 1, 0, 1, 2, 2, 1, 1, 2, 2, 1, 0, 1, 1, 0]
|
|
assert decoded.revision == 2
|
|
assert decoded.author == "fixture-gen"
|
|
|
|
def test_chunk_with_objects_logical_values(self):
|
|
decoded = cc.decode_binary(_read_fixture("chunk_with_objects.bin"))
|
|
assert len(decoded.objects) == 2
|
|
obj1, obj2 = decoded.objects
|
|
assert obj1.id == "obj_0001"
|
|
assert obj1.type == "interactable"
|
|
assert obj1.pos == (101.5, -110.25, 0.5)
|
|
assert obj1.properties == {"locked": False, "loot_table": "chest_basic"}
|
|
assert obj2.id == "obj_0002"
|
|
assert obj2.dynamic is True
|
|
assert obj2.flags.violence == 1
|
|
assert obj2.flags.sources == ["asset:props/chest"]
|
|
assert decoded.flags.violence == 1
|
|
assert decoded.flags.sources == ["asset:props/chest"]
|
|
|
|
def test_empty_chunk_has_no_layers_or_objects(self):
|
|
decoded = cc.decode_binary(_read_fixture("empty_chunk.bin"))
|
|
assert decoded.tile_layers == []
|
|
assert decoded.objects == []
|
|
|
|
|
|
class TestChecksum:
|
|
def test_checksum_is_sha256_hex(self):
|
|
import hashlib
|
|
assert cc.checksum(b"") == hashlib.sha256(b"").hexdigest()
|
|
assert len(cc.checksum(b"")) == 64
|
|
|
|
def test_header_checksum_matches_recomputed_for_valid_fixture(self):
|
|
data = _read_fixture("simple_tile_chunk.bin")
|
|
header_checksum_hex = data[16:48].hex()
|
|
body = data[cc.HEADER_SIZE:]
|
|
assert header_checksum_hex == cc.checksum(body)
|
|
|
|
|
|
class TestBuildKey:
|
|
def test_matches_world_persistence_impl_4_5_format(self):
|
|
assert cc.build_key(12, "main", 3, -1, 0) == "12:main:3:-1:0"
|
|
assert cc.build_key("12", "main", 3, -1, 0) == "12:main:3:-1:0"
|
|
|
|
|
|
class TestFloat32Fidelity:
|
|
"""This is the concrete regression test for the cross-codec divergence
|
|
documented at the bottom of chunk_codec.py: this module must encode
|
|
real IEEE-754 float32, including fractional and negative values,
|
|
round-tripping exactly (up to float32 precision) -- unlike
|
|
scripts/chunk_data_decoder.gd's current int-truncating implementation."""
|
|
|
|
def test_fractional_and_negative_floats_survive_round_trip(self):
|
|
payload = cc.ChunkPayload(
|
|
world_id="1", room_id="main", coord=(0, 0, 0),
|
|
bounds=cc.ChunkBounds(min=(-17.25, 0.125, -3.0), size=(32.0, 32.0, 1.0)),
|
|
objects=[cc.WorldObject(
|
|
id="o1", type="prop", asset_id="a",
|
|
pos=(1.5, -2.75, 0.001), rot=(0.0, 0.7071068, 0.0, 0.7071068),
|
|
scale=(1.0, 1.0, 1.0),
|
|
)],
|
|
)
|
|
encoded = cc.encode_binary(payload)
|
|
decoded = cc.decode_binary(encoded)
|
|
assert decoded.bounds.min == pytest.approx((-17.25, 0.125, -3.0), abs=1e-6)
|
|
assert decoded.objects[0].pos == pytest.approx((1.5, -2.75, 0.001), abs=1e-6)
|
|
assert decoded.objects[0].rot == pytest.approx(
|
|
(0.0, 0.7071068, 0.0, 0.7071068), abs=1e-6
|
|
)
|
|
|
|
|
|
class TestMalformedInput:
|
|
def test_bad_magic_raises_codec_error(self):
|
|
data = bytearray(_read_fixture("empty_chunk.bin"))
|
|
data[0:4] = b"XXXX"
|
|
with pytest.raises(cc.ChunkCodecError):
|
|
cc.decode_binary(bytes(data))
|
|
|
|
def test_too_short_raises_codec_error(self):
|
|
with pytest.raises(cc.ChunkCodecError):
|
|
cc.decode_binary(b"AGPC\x01\x00")
|
|
|
|
def test_unsupported_version_raises_codec_error(self):
|
|
data = bytearray(_read_fixture("empty_chunk.bin"))
|
|
data[4:6] = (99).to_bytes(2, "little")
|
|
with pytest.raises(cc.ChunkCodecError):
|
|
cc.decode_binary(bytes(data))
|
|
|
|
def test_payload_len_mismatch_raises_codec_error(self):
|
|
data = bytearray(_read_fixture("empty_chunk.bin"))
|
|
# Corrupt the declared payload_len (offset 12..16).
|
|
data[12:16] = (99999).to_bytes(4, "little")
|
|
with pytest.raises(cc.ChunkCodecError):
|
|
cc.decode_binary(bytes(data))
|
|
|
|
|
|
class TestValidation:
|
|
"""decode_and_validate: the server-side "don't trust the client"
|
|
boundary (world_persistence_impl.md §5.5)."""
|
|
|
|
def _valid_args(self):
|
|
return {
|
|
"expected_key": "12:main:3:-1:0",
|
|
"grid_size": (4, 4, 1),
|
|
"max_objects": 512,
|
|
}
|
|
|
|
def test_valid_payload_passes(self):
|
|
data = _read_fixture("simple_tile_chunk.bin")
|
|
payload = cc.decode_and_validate(data, **self._valid_args())
|
|
assert payload.coord == (3, -1, 0)
|
|
|
|
def test_checksum_mismatch_detected(self):
|
|
data = bytearray(_read_fixture("simple_tile_chunk.bin"))
|
|
# Corrupt one byte of the header's advisory checksum field
|
|
# (offset 16..48) only -- magic/version/payload_len and the whole
|
|
# payload body stay untouched, so this exercises exactly the
|
|
# "recomputed checksum disagrees with the header" path rather than
|
|
# also breaking structural decoding.
|
|
data[16] ^= 0xFF
|
|
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
|
cc.decode_and_validate(bytes(data), **self._valid_args())
|
|
assert exc_info.value.reason == "checksum_mismatch"
|
|
|
|
def test_chunk_key_mismatch_detected(self):
|
|
data = _read_fixture("simple_tile_chunk.bin")
|
|
args = self._valid_args()
|
|
args["expected_key"] = "12:main:99:99:0" # doesn't match the payload's own coord
|
|
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
|
cc.decode_and_validate(data, **args)
|
|
assert exc_info.value.reason == "chunk_key_mismatch"
|
|
|
|
def test_layer_dims_mismatch_detected(self):
|
|
data = _read_fixture("simple_tile_chunk.bin")
|
|
args = self._valid_args()
|
|
args["grid_size"] = (8, 8, 1) # fixture's layer is 4x4x1
|
|
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
|
cc.decode_and_validate(data, **args)
|
|
assert exc_info.value.reason == "layer_dims"
|
|
|
|
def test_too_many_objects_detected(self):
|
|
data = _read_fixture("chunk_with_objects.bin") # has 2 objects
|
|
args = self._valid_args()
|
|
args["expected_key"] = "12:main:3:-4:0"
|
|
args["grid_size"] = (2, 2, 1)
|
|
args["max_objects"] = 1
|
|
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
|
cc.decode_and_validate(data, **args)
|
|
assert exc_info.value.reason == "too_many_objects"
|
|
|
|
def test_object_out_of_bounds_detected(self):
|
|
payload = cc.ChunkPayload(
|
|
world_id="1", room_id="main", coord=(0, 0, 0),
|
|
bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(32.0, 32.0, 1.0)),
|
|
objects=[cc.WorldObject(id="o1", type="prop", asset_id="a", pos=(9999.0, 0.0, 0.0))],
|
|
)
|
|
data = cc.encode_binary(payload)
|
|
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
|
cc.decode_and_validate(
|
|
data, expected_key="1:main:0:0:0", grid_size=(32, 32, 1), max_objects=10
|
|
)
|
|
assert exc_info.value.reason == "object_out_of_bounds"
|
|
|
|
def test_tile_out_of_palette_detected(self):
|
|
layer = cc.TileLayer(
|
|
name="ground", width=2, height=2, depth=1,
|
|
palette=[cc.TileDef(id=1)], # tile 5 below isn't in this palette
|
|
tiles=[1, 5, 0, 1],
|
|
)
|
|
payload = cc.ChunkPayload(world_id="1", room_id="main", coord=(0, 0, 0), tile_layers=[layer])
|
|
data = cc.encode_binary(payload)
|
|
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
|
cc.decode_and_validate(
|
|
data, expected_key="1:main:0:0:0", grid_size=(2, 2, 1), max_objects=10
|
|
)
|
|
assert exc_info.value.reason == "tile_out_of_palette"
|