From 907f7df41c149a26bc7449db7f13ce96093d9c4e Mon Sep 17 00:00:00 2001 From: Zulu Date: Mon, 7 Sep 2026 22:41:42 +0100 Subject: [PATCH 1/2] feat: separate NXS runtime counts and optional public endpoint metadata --- docs/external-signalling/README.md | 3 +- docs/external-signalling/fixtures.mjs | 14 +++++ docs/external-signalling/nxs-v1.fixtures.json | 51 +++++++++++++++++- docs/external-signalling/nxs-v1.schema.json | 46 ++++++++++++++-- docs/external-signalling/wire-reference.md | 36 ++++++++++++- external-signalling/README.md | 3 ++ .../netty/signalling/ProviderClient.java | 22 +++++++- .../signalling/IndependentProviderStub.java | 5 +- .../netty/signalling/ProviderBench.java | 2 +- .../netty/signalling/ProviderClientTest.java | 54 +++++++++++++++++++ .../admission/ProviderNativeBench.java | 5 +- 11 files changed, 226 insertions(+), 15 deletions(-) diff --git a/docs/external-signalling/README.md b/docs/external-signalling/README.md index 3d3ab69..4d57e27 100644 --- a/docs/external-signalling/README.md +++ b/docs/external-signalling/README.md @@ -40,7 +40,8 @@ issuance, ownership claims and fleet administration belong to the provider. Send a signed `heartbeat` immediately after startup and whenever its returned schedule says to check in. The request carries: -- Health, capacity, load and optional public server status. +- Health, admission capacity, load, optional actual player counts with sample time, + and independent optional public server status. - `hostProfile` when endpoint details change; otherwise `hostProfileRevision`. - `installedKeyIds`, listing installed admission epochs with the active one last. - Local `state` (`serving`, `draining` or `closed`), the applied provider-state diff --git a/docs/external-signalling/fixtures.mjs b/docs/external-signalling/fixtures.mjs index 7c6b765..7e195a0 100644 --- a/docs/external-signalling/fixtures.mjs +++ b/docs/external-signalling/fixtures.mjs @@ -51,3 +51,17 @@ const provenance = {specification:'urn:nethernet:external-signalling:v1', files: if (update) write('provenance.json',provenance); assert.deepEqual(read('provenance.json'),provenance); console.log('NXS canonical signing, stateless encryption, and fixture hashes verified.'); + +// Fleet examples keep public endpoint metadata optional and runtime counts independent. +const schema = read('nxs-v1.schema.json'); +for (const [document, value] of Object.entries(f.fleetExamples)) { + for (const field of schema.$defs[document].required) assert(field in value, document + ' omitted ' + field); +} +assert(!schema.$defs.registration.required.includes('serviceId')); +assert(!schema.$defs.registration.required.includes('publicAddress')); +assert(!('serviceId' in f.fleetExamples.registration)); +assert(!('publicAddress' in f.fleetExamples.registration)); +assert.equal(schema.$defs.heartbeat.properties.playerCount.$ref, '#/$defs/playerCount'); +assert.equal(f.fleetExamples.heartbeat.playerCount.connectedPlayers, 3); +assert.equal(f.fleetExamples.heartbeat.serverStatus.players, 25000); +assert(f.fleetExamples.heartbeat.playerCount.sampledAt <= f.fleetExamples.heartbeat.clockUnixMillis); diff --git a/docs/external-signalling/nxs-v1.fixtures.json b/docs/external-signalling/nxs-v1.fixtures.json index f46fb48..1ca6218 100644 --- a/docs/external-signalling/nxs-v1.fixtures.json +++ b/docs/external-signalling/nxs-v1.fixtures.json @@ -32,7 +32,7 @@ "context": { "mode": "attach-instance", "profile": "nxs-admission-v1", - "label": "EU café 🦊\n", + "label": "EU caf\u00e9 \ud83e\udd8a\n", "authorizationId": "auth_fixture", "serviceId": "service_neutral", "region": "EU", @@ -69,9 +69,56 @@ "idempotencyKey": "intent_fixture_0001", "generation": 2, "sequence": 17, - "body": "{\"name\":\"café 🦊\",\"players\":0}\n" + "body": "{\"name\":\"caf\u00e9 \ud83e\udd8a\",\"players\":0}\n" }, "payload": "[\"nethernet-external-signalling-v1\",\"nxs-es384-v1\",\"https://provider.example\",\"POST\",\"/renew?region=EU&label=caf%C3%A9\",1788484200123,\"machine_neutral\",\"key_fixture\",\"intent_fixture_0001\",2,17,\"hW1_XbRZsu7XCnVbFpxNepqnGsOafEGkN_VFWwKb4jQ\"]", "signature": "SMg1I5sJM8nQMSLcGMx8ajKG2HuF2b0qXolYIyPNtuC4jRT89G_MKemggBUGNw1_CtwBhEJiZfdQsM5OTkIDZTFukS2pQdFmLYw8x8GkWeouETOl7CbRnVCAA3SpV3Un" + }, + "fleetExamples": { + "registration": { + "protocol": "nethernet-external-signalling-v1", + "provider": "https://provider.example", + "registrationId": "reg_fleet_1", + "instanceId": "instance_fleet_1", + "keyId": "key_fleet_1", + "profile": "nxs-admission-v1", + "placement": { + "region": "EU", + "pool": "proxy" + }, + "heartbeatIntervalMs": 10000, + "leaseGeneration": 1, + "leaseDeadline": 1788800000000, + "readiness": { + "routable": false, + "reasons": [ + "no_public_endpoint" + ] + } + }, + "heartbeat": { + "healthy": true, + "capacity": 20, + "load": 0.9, + "protocolVersion": "nethernet", + "clockUnixMillis": 1788799970100, + "checkInVersion": 1, + "state": "serving", + "appliedStateRevision": 1, + "gameOutcomes": "unavailable", + "playerCount": { + "connectedPlayers": 3, + "sampledAt": 1788799970000 + }, + "serverStatus": { + "name": "Network listing", + "networkId": 1234, + "levelName": "world", + "version": "fixture", + "players": 25000, + "maxPlayers": 30000, + "gameType": 0 + } + } } } diff --git a/docs/external-signalling/nxs-v1.schema.json b/docs/external-signalling/nxs-v1.schema.json index 36ce731..40650bf 100644 --- a/docs/external-signalling/nxs-v1.schema.json +++ b/docs/external-signalling/nxs-v1.schema.json @@ -299,11 +299,9 @@ "protocol", "provider", "registrationId", - "serviceId", "instanceId", "keyId", "profile", - "publicAddress", "placement", "heartbeatIntervalMs", "leaseGeneration", @@ -316,14 +314,30 @@ }, "extensions": { "$ref": "#/$defs/extensions" + }, + "serviceId": { + "type": "string", + "minLength": 1 + }, + "publicAddress": { + "type": "string", + "minLength": 1 } - } + }, + "dependentRequired": { + "serviceId": [ + "publicAddress" + ], + "publicAddress": [ + "serviceId" + ] + }, + "description": "An independent runtime identity. Optional serviceId/publicAddress describe a public endpoint at this observation; they are not stable instance identity. Pool attachments may have no public endpoint or serve several endpoints." }, "readiness": { "type": "object", "required": [ "serverTime", - "serviceId", "instanceId", "serviceAvailable", "instanceRoutable", @@ -594,6 +608,9 @@ }, "extensions": { "$ref": "#/$defs/extensions" + }, + "playerCount": { + "$ref": "#/$defs/playerCount" } } }, @@ -787,6 +804,27 @@ } }, "additionalProperties": false + }, + "playerCount": { + "type": "object", + "additionalProperties": false, + "required": [ + "connectedPlayers", + "sampledAt" + ], + "properties": { + "connectedPlayers": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "sampledAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "description": "Actual players connected to this runtime, including still-connected draining players. sampledAt is Unix milliseconds and covers this count and the heartbeat capacity from the same host observation. Absence means unknown. Never derive this value from public serverStatus, load, tickets or reservations." } }, "x-context-order": [ diff --git a/docs/external-signalling/wire-reference.md b/docs/external-signalling/wire-reference.md index 06811c7..8ecc293 100644 --- a/docs/external-signalling/wire-reference.md +++ b/docs/external-signalling/wire-reference.md @@ -145,8 +145,8 @@ and single-use completion atomically with resource creation. Retrying completion MUST NOT return one-time key secrets again. If completion was interrupted, recover the registration by proving ownership of the same key. -Completion returns `protocol,provider,registrationId,serviceId,instanceId,keyId, -profile,publicAddress,placement,heartbeatIntervalMs,leaseGeneration,leaseDeadline, +Completion returns `protocol,provider,registrationId,instanceId,keyId, +profile,placement,heartbeatIntervalMs,leaseGeneration,leaseDeadline, readiness`, plus optional one-time `ticketKey` and `extensions`. Completion atomically starts a new generation, clears previous readiness and resets the operational sequence to zero. Save the IDs and key material before heartbeat. Recovery uses `register {registrationId,protocol,profile}` and the @@ -193,6 +193,15 @@ to identify which key is current. key. `deregister` carries `{}` and permanently ends registration. Neither is an admission-key rotation or an ordinary graceful drain. +Registration may also return `serviceId` and `publicAddress`, always together. +They describe a public endpoint at that observation and are not stable runtime +identity. Pool attachments can return neither: a pool may have zero or several +public endpoints. Providers bind the token's permitted placement to their pool; +clients cannot choose arbitrary provider-owned resource IDs. Recovery preserves +`instanceId` and `registrationId`; changes to public endpoints do not require a +new runtime identity. The default standalone registration still creates a public +endpoint alongside the runtime. + ## `heartbeat` Required fields: `healthy,capacity,load,protocolVersion,clockUnixMillis, @@ -216,6 +225,29 @@ installedKeyIds,keyRequestId,extensions`. routing capacity/load. Omitted or failed status publication does not refresh a previous status snapshot. +### Actual player counts + +Optional `playerCount: {connectedPlayers, sampledAt}` reports the actual number of +players connected to this runtime, including existing players while it is draining. +`connectedPlayers` is an integer from 0 to 1000000; `sampledAt` is Unix milliseconds +from the host clock. The heartbeat's admission `capacity` must come from the same +observation. Count may exceed capacity after a capacity reduction. Capacity zero +means no admission. `load` remains a separate health/load observation. + +This count is independent of the public `serverStatus.players` and its advertised +`maxPlayers`. A public/global override must never change the count or admission +capacity. Do not estimate connected players from load, successful tickets, reserved +slots or public listing totals. Omit `playerCount` when it is unknown; omission and +zero are distinct. Omission does not refresh the previous count. Providers fence +samples by the authenticated lease generation and sequence, track sample and receipt +times separately, and exclude stale/unknown samples from count-dependent routing +unless an explicit fallback policy applies. A retry must not freshen a sample. + +The Java `Health` supplier accepts an optional `PlayerCount`. Sample the runtime and +capacity together; preserve an old sample's timestamp if returning cached values. +A changed connected count can wake a scheduled check-in even if public status is +unchanged. A new timestamp alone does not cause extra network traffic. + ### Publish the host profile `heartbeat.hostProfile` contains `candidates`, `dtlsFingerprint`, `credentialKeyId`, diff --git a/external-signalling/README.md b/external-signalling/README.md index 4e5ac6f..7d97952 100644 --- a/external-signalling/README.md +++ b/external-signalling/README.md @@ -15,6 +15,9 @@ or bearer token, token-authorized instance attachment, durable recovery, generat activation, status/profile publication, scheduled heartbeats, key rotation, drain, and asynchronous outcomes. Tokens are enrollment-only and excluded from durable state/logs. One instance owns one private state directory; restarts preserve that directory. +A pool attachment may have no public address. Public endpoints can change without +changing the runtime identity. `Health` accepts an optional `PlayerCount` with actual +connected players and sample time, separate from the public `ServerStatus` supplier. `ProtocolExtensions` carries bounded optional metadata. Applications explicitly interpret known namespaces and invoke only their advertised same-origin operations. The core never diff --git a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java index 56764e8..79e2e82 100644 --- a/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java +++ b/external-signalling/src/main/java/org/cloudburstmc/netty/signalling/ProviderClient.java @@ -36,8 +36,17 @@ public Configuration(URI provider, String profile, String label) { } @Override public String toString() { return "Configuration[provider=" + provider + ", profile=" + profile + ", registrationMode=" + registrationMode + ", authorizationScheme=" + authorizationScheme + "]"; } } - public record Health(boolean healthy, int capacity, double load, String protocolVersion, String build) { + /** Sampled actual players on this runtime; keep counting existing players while draining. */ + public record PlayerCount(int connectedPlayers, long sampledAt) { + public PlayerCount { if (connectedPlayers < 0 || connectedPlayers > 1000000 || sampledAt < 0 || sampledAt > 9007199254740991L) throw new IllegalArgumentException("Invalid player count sample"); } + } + /** Capacity and playerCount describe the same observation. Public server status is independent. */ + public record Health(boolean healthy, int capacity, double load, String protocolVersion, String build, PlayerCount playerCount) { public Health { if (capacity < 0 || capacity > 1000000 || !Double.isFinite(load) || load < 0 || load > 1 || protocolVersion == null) throw new IllegalArgumentException("Invalid health"); } + /** Hosts without actual player telemetry report unknown, never a synthetic zero. */ + public Health(boolean healthy, int capacity, double load, String protocolVersion, String build) { + this(healthy, capacity, load, protocolVersion, build, null); + } } public static final class ProviderException extends IOException { private final int status; @@ -160,7 +169,7 @@ private void completeRecovery(JsonObject challenge, String registrationId) throw completion.addProperty("proofNonce", "0"); completion.addProperty("idempotencyKey", intent); completion.addProperty("signature", ProviderCrypto.sign(key, ProviderCrypto.proof(challenge, "0", intent))); JsonObject recovered = unsigned("complete", completion); validateRegistration(recovered); if (!registrationId.equals(recovered.get("registrationId").getAsString())) throw new IOException("Recovered registration changed"); - if (state.has("registration")) for (String field : List.of("instanceId", "serviceId", "registrationId")) + if (state.has("registration")) for (String field : List.of("instanceId", "registrationId")) if (!state.getAsJsonObject("registration").get(field).equals(recovered.get(field))) throw new IOException("Recovered instance identity changed"); registrationExtensions = ProtocolExtensions.copy(recovered); recovered.remove("extensions"); recovered.remove("ticketKey"); state.add("registration", recovered); state.addProperty("generation", recovered.get("leaseGeneration").getAsLong()); @@ -216,6 +225,12 @@ private void enroll() throws Exception { private void validateRegistration(JsonObject registration) throws IOException { ProviderContract.require("registration", registration); ProtocolExtensions.validate(registration); + boolean hasService = registration.has("serviceId"), hasAddress = registration.has("publicAddress"); + if (hasService != hasAddress) throw new IOException("Incomplete public endpoint metadata"); + if (hasService) for (String field : List.of("serviceId", "publicAddress")) { + JsonElement value = registration.get(field); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank()) throw new IOException("Invalid public endpoint metadata"); + } if (!origin.equals(registration.get("provider").getAsString()) || !config.profile().equals(registration.get("profile").getAsString())) throw new IOException("Registration provider or profile changed"); JsonObject placement = registration.getAsJsonObject("placement"); String expectedRegion = config.region() == null ? "" : config.region(), expectedPool = config.pool() == null ? "" : config.pool(); @@ -258,8 +273,10 @@ private boolean statusChanged() { ServerStatus status = currentStatus(); Health health = healthSupplier.get(); return !Objects.equals(status, lastReportedStatus) || lastReportedHealth == null || health.healthy() != lastReportedHealth.healthy() || health.capacity() != lastReportedHealth.capacity() + || !Objects.equals(connectedPlayers(health), connectedPlayers(lastReportedHealth)) || !Objects.equals(health.protocolVersion(), lastReportedHealth.protocolVersion()) || !Objects.equals(health.build(), lastReportedHealth.build()); } + private static Integer connectedPlayers(Health health) { return health.playerCount() == null ? null : health.playerCount().connectedPlayers(); } private void heartbeat() throws Exception { // Key delivery and application acknowledgements can need an immediate second exchange. for (int exchange = 0; exchange < 3; exchange++) { @@ -279,6 +296,7 @@ private void heartbeat() throws Exception { Health health = healthSupplier.get(); body.addProperty("healthy", health.healthy() && installedKeyId != null && hostState.equals("serving")); body.addProperty("capacity", health.capacity()); body.addProperty("load", health.load()); + if (health.playerCount() != null) body.add("playerCount", JSON.toJsonTree(health.playerCount())); body.addProperty("protocolVersion", health.protocolVersion()); body.addProperty("build", health.build()); if (config.region() != null) body.addProperty("region", config.region()); snapshotClock = Math.max(System.currentTimeMillis(), snapshotClock + 1); diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java index 5ddf96f..c1eeee4 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/IndependentProviderStub.java @@ -22,6 +22,7 @@ public final class IndependentProviderStub implements AutoCloseable { volatile int controlPolls; final java.util.List events = new java.util.concurrent.CopyOnWriteArrayList<>(); long generation, sequence; + volatile boolean poolOnlyRegistration; volatile int registrations, heartbeats, acknowledgements; volatile String challengeAuthorization; volatile int challengeDifficulty = -1; @@ -83,9 +84,9 @@ private JsonObject dispatch(HttpExchange e) throws Exception { String proof = ProviderCrypto.proof(c, body.get("proofNonce").getAsString(), body.get("idempotencyKey").getAsString()); if (!ProviderCrypto.verify(keys.get(id), body.get("signature").getAsString(), proof) || !ProviderCrypto.meetsDifficulty(ProviderCrypto.digest(proof), c.getAsJsonObject("pow").get("difficulty").getAsInt())) throw new Failure(401, "proof_invalid"); challenges.remove(id); generation++; sequence = 0; draining = false; appliedRevision = 0; profileRevision = 0; - if (c.getAsJsonObject("context").get("mode").getAsString().equals("recover")) { JsonObject r = registration.deepCopy(); r.remove("ticketKey"); r.addProperty("leaseGeneration", generation); return r; } + if (c.getAsJsonObject("context").get("mode").getAsString().equals("recover")) { JsonObject r = registration.deepCopy(); r.remove("ticketKey"); if (poolOnlyRegistration) { r.remove("serviceId"); r.remove("publicAddress"); } r.addProperty("leaseGeneration", generation); return r; } if (registration != null) throw new Failure(409, "already_registered"); registrations++; - registration = new JsonObject(); registration.addProperty("protocol", ProviderCrypto.PROTOCOL); registration.addProperty("provider", origin); registration.addProperty("registrationId", id); registration.addProperty("instanceId", "example-machine-1"); registration.addProperty("serviceId", "example-service-1"); registration.addProperty("keyId", "example-key-1"); registration.addProperty("publicAddress", "https://play.example.invalid"); registration.addProperty("profile", "nxs-admission-v1"); registration.addProperty("leaseGeneration", generation); registration.addProperty("leaseDeadline", System.currentTimeMillis() + 30000); registration.addProperty("heartbeatIntervalMs", 1000); JsonObject ready = new JsonObject(); ready.addProperty("routable", false); ready.add("reasons", new JsonArray()); registration.add("readiness", ready); JsonObject place = placements.getOrDefault(id, new JsonObject()).deepCopy(); if (!place.has("region")) place.addProperty("region", ""); if (!place.has("pool")) place.addProperty("pool", ""); registration.add("placement", place); registration.add("ticketKey", ticket()); if (extensionMetadata != null) registration.add("extensions", extensionMetadata.deepCopy()); keys.put("example-key-1", keys.get(id)); if (loseCompletionResponse) { loseCompletionResponse = false; e.close(); throw new Failure(503, "completion_response_lost"); } return registration.deepCopy(); + registration = new JsonObject(); registration.addProperty("protocol", ProviderCrypto.PROTOCOL); registration.addProperty("provider", origin); registration.addProperty("registrationId", id); registration.addProperty("instanceId", "example-machine-1"); if (!poolOnlyRegistration) registration.addProperty("serviceId", "example-service-1"); registration.addProperty("keyId", "example-key-1"); if (!poolOnlyRegistration) registration.addProperty("publicAddress", "https://play.example.invalid"); registration.addProperty("profile", "nxs-admission-v1"); registration.addProperty("leaseGeneration", generation); registration.addProperty("leaseDeadline", System.currentTimeMillis() + 30000); registration.addProperty("heartbeatIntervalMs", 1000); JsonObject ready = new JsonObject(); ready.addProperty("routable", false); ready.add("reasons", new JsonArray()); registration.add("readiness", ready); JsonObject place = placements.getOrDefault(id, new JsonObject()).deepCopy(); if (!place.has("region")) place.addProperty("region", ""); if (!place.has("pool")) place.addProperty("pool", ""); registration.add("placement", place); registration.add("ticketKey", ticket()); if (extensionMetadata != null) registration.add("extensions", extensionMetadata.deepCopy()); keys.put("example-key-1", keys.get(id)); if (loseCompletionResponse) { loseCompletionResponse = false; e.close(); throw new Failure(503, "completion_response_lost"); } return registration.deepCopy(); } if (path.equals("/example/heartbeat") && failHeartbeats-- > 0) throw new Failure(503, "fixture_transient"); authenticate(e, raw); diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java index 6310ee4..bba1571 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderBench.java @@ -39,7 +39,7 @@ public CompletionStage hostProfile() { JsonObject registration = client.start().get(30, TimeUnit.SECONDS); String extensionsFile = System.getProperty("providerExtensionsFile"); if (extensionsFile != null) ExtensionFixtureFile.write(Path.of(extensionsFile), client.extensions().get(10, TimeUnit.SECONDS)); - System.out.println("instance=" + registration.get("instanceId").getAsString() + " service=" + registration.get("serviceId").getAsString()); + System.out.println("instance=" + registration.get("instanceId").getAsString() + " service=" + (registration.has("serviceId") ? registration.get("serviceId").getAsString() : "unassigned")); JsonObject readiness = client.readiness().get(10, TimeUnit.SECONDS); readiness.remove("extensions"); System.out.println(readiness); long hold = Long.parseLong(System.getProperty("providerHoldSeconds", "0")); String stopFile = System.getProperty("providerStopFile"); diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java index 0016552..2b2a4da 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/ProviderClientTest.java @@ -8,9 +8,63 @@ import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; class ProviderClientTest { + @Test void poolAttachmentsAndRemovedPublicEndpointsPreserveRuntimeIdentity(@TempDir Path path) throws Exception { + for (boolean standalone : List.of(false, true)) { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + stub.poolOnlyRegistration = !standalone; + var config = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Fleet host", + standalone ? ProviderClient.NEW_SERVICE : ProviderClient.ATTACH_INSTANCE, ProviderClient.BEARER_TOKEN, + "independent-provider-token", standalone ? null : "EU", standalone ? null : "proxy", Map.of()); + JsonObject first = null; + for (int generation = 1; generation <= 2; generation++) { + ProviderClient client = new ProviderClient(config, new ProviderStateStore(path.resolve(standalone ? "standalone" : "pool")), new FakeTransport(), () -> null, + () -> new ProviderClient.Health(true, 20, 0, "nethernet", "fixture"), message -> {}); + try { + JsonObject current = client.start().get(20, TimeUnit.SECONDS); + assertEquals(standalone && generation == 1, current.has("serviceId")); + assertEquals(standalone && generation == 1, current.has("publicAddress")); + assertEquals(generation, current.get("leaseGeneration").getAsInt()); + if (first == null) first = current; + else for (String field : List.of("instanceId", "registrationId")) assertEquals(first.get(field), current.get(field)); + assertFalse(stub.lastHeartbeat.has("playerCount"), "Missing runtime telemetry is unknown, not zero"); + } finally { client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + stub.poolOnlyRegistration = true; + } + assertEquals(1, stub.registrations); + } + } + } + + @Test void runtimeCountsWakeCheckInsWithoutChangingPublicTotalsAndRemainCountedDuringDrain(@TempDir Path path) throws Exception { + try (IndependentProviderStub stub = new IndependentProviderStub()) { + stub.checkInMillis = 900000; + AtomicReference sample = new AtomicReference<>(new ProviderClient.PlayerCount(3, System.currentTimeMillis())); + var transport = new FakeTransport(); transport.stateless = true; + ProviderClient client = new ProviderClient(new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Counts"), + new ProviderStateStore(path), transport, () -> new ServerStatus("Global listing", 1234, "fixture", "world", 25000, 30000, 0), + () -> new ProviderClient.Health(true, 20, .9, "nethernet", "fixture", sample.get()), message -> {}); + try { + client.start().get(20, TimeUnit.SECONDS); + assertEquals(3, stub.lastHeartbeat.getAsJsonObject("playerCount").get("connectedPlayers").getAsInt()); + assertEquals(sample.get().sampledAt(), stub.lastHeartbeat.getAsJsonObject("playerCount").get("sampledAt").getAsLong()); + assertEquals(20, stub.lastHeartbeat.get("capacity").getAsInt()); + assertEquals(25000, stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt()); + int before = stub.heartbeats; + sample.set(new ProviderClient.PlayerCount(3, System.currentTimeMillis())); client.requestStatusRefresh(); + Thread.sleep(1200); assertEquals(before, stub.heartbeats, "Timestamp-only changes use the ordinary schedule"); + sample.set(new ProviderClient.PlayerCount(4, System.currentTimeMillis())); client.requestStatusRefresh(); + eventually(() -> stub.lastHeartbeat.getAsJsonObject("playerCount").get("connectedPlayers").getAsInt() == 4); + assertEquals(25000, stub.lastHeartbeat.getAsJsonObject("serverStatus").get("players").getAsInt()); + } finally { client.stop().toCompletableFuture().get(10, TimeUnit.SECONDS); } + assertTrue(stub.draining); + assertEquals(4, stub.lastHeartbeat.getAsJsonObject("playerCount").get("connectedPlayers").getAsInt()); + } + } + @Test void usesProviderNeutralBearerAuthorizationWithoutPowOrPersistingTheToken(@TempDir Path path) throws Exception { try (IndependentProviderStub stub = new IndependentProviderStub()) { var config = new ProviderClient.Configuration(URI.create(stub.origin), "nxs-admission-v1", "Hosted customer", diff --git a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java index 0c8a237..8937785 100644 --- a/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java +++ b/external-signalling/src/test/java/org/cloudburstmc/netty/signalling/admission/ProviderNativeBench.java @@ -52,7 +52,10 @@ public static void main(String[] args) throws Exception { JsonObject registration = provider.start().get(45, TimeUnit.SECONDS); if (args.length > 4) ExtensionFixtureFile.write(Path.of(args[4]), provider.extensions().get(10, TimeUnit.SECONDS)); // Emit assigned IDs only; optional metadata and credentials are excluded. - emit("registered", Map.of("serviceId", registration.get("serviceId").getAsString(), "instanceId", registration.get("instanceId").getAsString())); + var assignedIds = new java.util.LinkedHashMap(); + assignedIds.put("instanceId", registration.get("instanceId").getAsString()); + if (registration.has("serviceId")) assignedIds.put("serviceId", registration.get("serviceId").getAsString()); + emit("registered", assignedIds); emit("profile", nativeHost.hostProfile().toCompletableFuture().get()); JsonObject readiness = provider.readiness().get(10, TimeUnit.SECONDS); readiness.remove("extensions"); emit("readiness", readiness); long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(3); From fe09633206d09a7d03bece25112689009b3ee693 Mon Sep 17 00:00:00 2001 From: Zulu Date: Mon, 7 Sep 2026 22:58:18 +0100 Subject: [PATCH 2/2] test: align fleet public status example with canonical field names --- docs/external-signalling/fixtures.mjs | 4 +++- docs/external-signalling/nxs-v1.fixtures.json | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/external-signalling/fixtures.mjs b/docs/external-signalling/fixtures.mjs index 7e195a0..29f9a44 100644 --- a/docs/external-signalling/fixtures.mjs +++ b/docs/external-signalling/fixtures.mjs @@ -50,7 +50,6 @@ assert.deepEqual(v.expected,expected); const provenance = {specification:'urn:nethernet:external-signalling:v1', files:Object.fromEntries(['stateless-admission-v1.fixtures.json','cloudburst-protocol-vectors.v1.json'].map(name => [name,digest(readFileSync(path(name))).toString('hex')]))}; if (update) write('provenance.json',provenance); assert.deepEqual(read('provenance.json'),provenance); -console.log('NXS canonical signing, stateless encryption, and fixture hashes verified.'); // Fleet examples keep public endpoint metadata optional and runtime counts independent. const schema = read('nxs-v1.schema.json'); @@ -65,3 +64,6 @@ assert.equal(schema.$defs.heartbeat.properties.playerCount.$ref, '#/$defs/player assert.equal(f.fleetExamples.heartbeat.playerCount.connectedPlayers, 3); assert.equal(f.fleetExamples.heartbeat.serverStatus.players, 25000); assert(f.fleetExamples.heartbeat.playerCount.sampledAt <= f.fleetExamples.heartbeat.clockUnixMillis); + +assert.deepEqual(Object.keys(f.fleetExamples.heartbeat.serverStatus).sort(), ['name','protocol','version','level','players','maxPlayers','gameType'].sort()); +console.log('NXS canonical signing, stateless encryption, fleet examples, and fixture hashes verified.'); diff --git a/docs/external-signalling/nxs-v1.fixtures.json b/docs/external-signalling/nxs-v1.fixtures.json index 1ca6218..891f6bf 100644 --- a/docs/external-signalling/nxs-v1.fixtures.json +++ b/docs/external-signalling/nxs-v1.fixtures.json @@ -112,12 +112,12 @@ }, "serverStatus": { "name": "Network listing", - "networkId": 1234, - "levelName": "world", "version": "fixture", "players": 25000, "maxPlayers": 30000, - "gameType": 0 + "gameType": 0, + "protocol": 1234, + "level": "world" } } }