diff --git a/README.md b/README.md index 113de58..8fb2180 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,9 @@ See [`docs/API.md`](docs/API.md) for the versioned HTTP contract. ## Repository relationship -This is deliberately a separate repository from the Godot game. The game and service can version, -deploy, and roll back independently. The discovery API version is independent from NETfishing's -save schema and gameplay network protocol. +This is deliberately a separate repository from the Godot game so the service can deploy and roll +back independently. Production tags follow the coordinated NETfishing release train: a +`vX.Y.Z-alpha` tag identifies the discovery snapshot tested for the same game and dedicated-server +release. The versioned HTTP contract remains independent from NETfishing's save schema and gameplay +network protocol. Each tagged deployment accepts room advertisements only from its matching game +release so incompatible rooms are never presented as publicly available. diff --git a/docs/API.md b/docs/API.md index d8b132e..d925d0d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -10,7 +10,7 @@ All request and response bodies use `application/json`. Production clients must Returns service status, package version, configured build revision, and the number of active, unexpired room leases. Deployments should set `NETFISHING_DISCOVERY_BUILD_REVISION` to the exact -Git commit they are running. +Git commit they are running. The package version follows the coordinated NETfishing release tag. ## `GET /v1/rooms` @@ -32,7 +32,7 @@ trusting an address supplied by the game client. "port": 7777, "current_players": 0, "max_players": 8, - "game_version": "0.6.4-alpha", + "game_version": "0.6.7-alpha", "protocol_version": 3 } ``` @@ -47,6 +47,10 @@ excluded from public listings until the service observes that endpoint. The service applies configured global and per-observed-address active-room limits. Exceeding one returns `429 room_limit`; expired leases stop counting automatically. +The discovery release accepts room advertisements only from the matching NETfishing game release. +A mismatched `game_version` returns `409 game_version_mismatch` with `required_game_version`; the +room is not created or updated. + ## `PUT /v1/rooms/{room_id}` Renews and updates a room lease. Send the complete room payload and: diff --git a/netfishing_discovery/__init__.py b/netfishing_discovery/__init__.py index ace3363..dbb4ff3 100644 --- a/netfishing_discovery/__init__.py +++ b/netfishing_discovery/__init__.py @@ -1,3 +1,3 @@ """NETfishing discovery service.""" -__version__ = "0.2.0" +__version__ = "0.6.7-alpha" diff --git a/netfishing_discovery/server.py b/netfishing_discovery/server.py index 1308e09..4537e41 100644 --- a/netfishing_discovery/server.py +++ b/netfishing_discovery/server.py @@ -184,6 +184,8 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler): payload = self._read_json_object() if payload is None: return + if self._reject_incompatible_game_version(payload): + return try: room, token, verification_token = self.server.registry.create( self._client_address(), payload @@ -211,6 +213,8 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler): payload = self._read_json_object() if payload is None: return + if self._reject_incompatible_game_version(payload): + return try: room = self.server.registry.update( room_id, @@ -300,6 +304,22 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler): except ValueError: return peer_text + def _reject_incompatible_game_version(self, payload: dict[str, Any]) -> bool: + game_version = payload.get("game_version") + if not isinstance(game_version, str) or game_version.strip() == __version__: + return False + self._send_error( + HTTPStatus.CONFLICT, + "game_version_mismatch", + ( + f"This discovery server only lists NETfishing {__version__} rooms. " + "Your room will not be listed until the game and discovery server " + "use the same version." + ), + {"required_game_version": __version__}, + ) + return True + def _room_id_from_path(self) -> str | None: path = urlsplit(self.path).path prefix = "/v1/rooms/" @@ -341,8 +361,17 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler): raise ValueError return values[0] - def _send_error(self, status: HTTPStatus, code: str, message: str) -> None: - self._send_json(status, {"error": {"code": code, "message": message}}) + def _send_error( + self, + status: HTTPStatus, + code: str, + message: str, + details: dict[str, Any] | None = None, + ) -> None: + error: dict[str, Any] = {"code": code, "message": message} + if details is not None: + error.update(details) + self._send_json(status, {"error": error}) def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None: body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") diff --git a/pyproject.toml b/pyproject.toml index e64bba3..3d6e1e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netfishing-discovery-server" -version = "0.2.0" +version = "0.6.7-alpha" description = "Ephemeral public-room directory for NETfishing" requires-python = ">=3.11" dependencies = [] diff --git a/tests/test_http_api.py b/tests/test_http_api.py index e09411f..2794a64 100644 --- a/tests/test_http_api.py +++ b/tests/test_http_api.py @@ -19,7 +19,7 @@ VALID_ROOM = { "port": 7777, "current_players": 1, "max_players": 8, - "game_version": "0.6.4-alpha", + "game_version": "0.6.7-alpha", "protocol_version": 3, } @@ -91,6 +91,19 @@ class DiscoveryHTTPTests(unittest.TestCase): listed_rooms = listing["rooms"] self.assertTrue(any(candidate["room_id"] == room_id for candidate in listed_rooms)) + status, updated = self.request( + "PUT", + f"/v1/rooms/{room_id}", + dict(VALID_ROOM, game_version="0.6.6-alpha"), + str(token), + ) + self.assertEqual(status, 409) + assert updated is not None + self.assertEqual(updated["error"]["code"], "game_version_mismatch") + self.assertEqual( + updated["error"]["required_game_version"], "0.6.7-alpha" + ) + status, updated = self.request( "PUT", f"/v1/rooms/{room_id}", @@ -110,6 +123,20 @@ class DiscoveryHTTPTests(unittest.TestCase): assert body is not None self.assertEqual(body["error"]["code"], "invalid_room") + def test_outdated_game_cannot_list_a_room(self) -> None: + status, body = self.request( + "POST", + "/v1/rooms", + dict(VALID_ROOM, game_version="0.6.6-alpha"), + ) + self.assertEqual(status, 409) + assert body is not None + error = body["error"] + assert isinstance(error, dict) + self.assertEqual(error["code"], "game_version_mismatch") + self.assertEqual(error["required_game_version"], "0.6.7-alpha") + self.assertIn("will not be listed", str(error["message"])) + def test_empty_dedicated_room_can_be_listed(self) -> None: status, created = self.request( "POST", "/v1/rooms", dict(VALID_ROOM, current_players=0) @@ -173,7 +200,7 @@ class DiscoveryHTTPTests(unittest.TestCase): self.assertEqual(status, 200) assert body is not None self.assertEqual(body["status"], "ok") - self.assertEqual(body["version"], "0.2.0") + self.assertEqual(body["version"], "0.6.7-alpha") self.assertEqual(body["build_revision"], "test-build") def test_udp_rendezvous_does_not_spawn_per_packet_threads(self) -> None: diff --git a/tests/test_registry.py b/tests/test_registry.py index 5bdc356..01fb9d8 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -16,7 +16,7 @@ VALID_ROOM = { "port": 7777, "current_players": 1, "max_players": 8, - "game_version": "0.6.4-alpha", + "game_version": "0.6.7-alpha", "protocol_version": 3, }