Repository navigation
cardkit-net + cardkit-server: extract the game-agnostic online stack - #19
Conversation
Extracts the transport-independent half of the 500 app's `:net` module, the first of the two modules 500's docs/multiplayer-architecture.md deferred "until a second game uses cardkit". Euchre is that second game. The one design problem worth explaining: kotlinx sealed hierarchies are closed at their declaring module, so a shared `ClientMessage`/`ServerMessage` could never accept a game's own payload types. They are open polymorphic interfaces here instead, with every concrete message registered in a SerializersModule, and the three payload-carrying messages (ViewUpdate, SubmitAction, LobbyState) generic over the game's view/action/config types. A game combines commonWireModule with gameWireModule(...) and gets its Json from wireJson(...). Open polymorphism emits byte-identical JSON to a sealed hierarchy — same "type" discriminator, same property order, same omission of defaults — which is what lets an already-released game adopt this without a protocol change. WireShapeTest pins that claim with the exact golden strings 500 shipped while these types were sealed, plus the generic-payload shapes (a game's payload inlines with no wrapper) and the behaviours the server depends on: unknown keys ignored, unknown enums coerced to UNKNOWN, an unregistered "type" still failing to decode so the MALFORMED path is unchanged. Registering one instantiation of each generic message per module means a Json instance speaks exactly one game. That is deliberate: two games are two processes, never one ambiguous wire. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019EH6XkA2oEhv4YnMXt8hbJ Signed-off-by: rotund_tapir <rotund_tapir@protonmail.com>
The second half of the extraction 500's docs/multiplayer-architecture.md deferred "until a second game uses cardkit". Everything that made 500's server work — the single-writer room actor, seat hosting with bot substitution and turn-boundary reclaim, session-token reconnect, restart-surviving room snapshots, join-code registry, per-IP and per-socket limiting with the fail2ban abuse format, drain/health/metrics, and the Ktor transport — is now generic over four type parameters and one seam. That seam is GameDescriptor<S, A, V, C>: a game supplies its rules for a lobby config, a fresh deal from a seed (cardkit's GameRules deliberately has no newGame), a bot, the terminal scoreline, serializers for snapshots, and its wire Json. Nothing else in the module names a game type. cardkit-server is the first JVM-only module here and depends only on cardkit-core and cardkit-net; it is a library, not an application, so each game keeps its own thin main(). Exercised through a deliberately trivial two-seat toy game, which is enough to drive the room actor end to end: a full game to its terminal state, view redaction, illegal- and stale-action rejection, bot substitution with seat reclaim, snapshot adoption at boot with a mid-game reclaim, config refusal and idle disband. Three real bugs surfaced while getting those tests green. All three predate this change and are live in 500's deployed server: 1. SeatHost drained its action buffer when the driver entered decide(), on the stated assumption that a client cannot have answered yet because the view "is delivered after the driver has already entered decide()". That is false: the driver only queues its state for the room, so the room can fan the view out — and a quick client can answer it — first. The action was then discarded as stale, and since the room had already recorded the move as accepted, the client's resend was deduplicated into silence and the player's turn did nothing until it timed out to a bot. Draining now happens at the turn boundary the room actually knows about (SeatHost.beginTurn, called from Room.onStateProduced before any view goes out), which keeps the stale-action protection and stops eating good moves. 2. FileRoomPersistence used one "<gameId>.tmp" per room, but flushSync writes on the calling thread and can run while the writer coroutine is mid-write for the same room. Their bytes interleaved and the atomic rename published the mixture — exactly the corruption the rename exists to prevent. The quarantined file then cost that room its game at the next boot. Temp names are now unique per write. 3. flushSync iterated `pending.keys`, which throws NoSuchElementException when the writer removes a key mid-iteration. That runs on the graceful-shutdown path, where throwing abandons every snapshot still queued. It now drains the work queue through the same claim protocol the writer uses. Regression tests cover all three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019EH6XkA2oEhv4YnMXt8hbJ Signed-off-by: rotund_tapir <rotund_tapir@protonmail.com>
The two rules worth writing down are the ones a future change could quietly break: game specifics enter cardkit-net/cardkit-server only through GameDescriptor and serializer registration, and nothing may depend on cardkit-server except a game's own :server module. Also records why the message hierarchies are open rather than sealed, and that the golden strings in WireShapeTest are a released-client compatibility contract. CI now verifies coverage on both new modules alongside cardkit-core. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019EH6XkA2oEhv4YnMXt8hbJ Signed-off-by: rotund_tapir <rotund_tapir@protonmail.com>
CI's kotlinWasmStoreYarnLock refuses a stale lock, and the browser target of cardkit-net pulls in the ktor JS client. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019EH6XkA2oEhv4YnMXt8hbJ Signed-off-by: rotund_tapir <rotund_tapir@protonmail.com>
rotundtapir
left a comment
There was a problem hiding this comment.
Reviewed against 500's deployed code and the inbox thread. Verdict: the design is right and two of the three bug claims are real — one correction needed before merge.
Verified:
- Bug 1 (SeatHost eats a fast submit): confirmed real in 500. The drain-on-entry comment's premise is falsified by
GameDriver.play { submit(StateProduced(it)) }— the room actor can fan views out before the driver reachesdecide(). The fix is correctly placed:onStateProducedbumps the version,beginTurn()s every slot (drainingresponsesANDinterrupts) before any view leaves, andonSubmit's version+actor match keeps PR-#7's wedge protection. Regression tests present, including the arrives-before-decide mirror and the spent-interrupt case. - Bug 2 (same-name tmp interleave): confirmed real in 500 — the claim protocol prevents double-claiming one entry, not two claims for the same room writing one
<gameId>.tmp.tmpCounterfix + the racing-flushSync regression test are right. - Wire compat: the envelope goldens match 500's
ProtocolGoldenTeststrings verbatim, and the generic-payload assertions pin no-extra-nesting forSubmitAction/ViewUpdate/LobbyState. The open-polymorphism argument holds; 500's adoption PR widening its own goldens to exact strings is the final gate, as planned. GameDescriptoris a clean seam (no suspension on the actor path, per-game wireJson/serializers/metrics prefix); library-not-application is the right call.
Correction required — bug 3's justification is false as written. RoomPersistence.flushSync's comment says a ConcurrentHashMap key iterator "can throw NoSuchElementException when the writer removes a key between its hasNext and next" — CHM iterators are weakly consistent and never throw on concurrent modification, and 500's original (pending.keys.toList()) was additionally snapshotting. So bug 3 was never live on the VPS. The token-drain mechanism you replaced it with is fine — arguably better (symmetric with the writer, no missed-entry window) — but reword the comment to the true rationale and drop the NSEE claim, or a future maintainer will "fix" something that was never broken. Same correction applies to the runbook/release-notes wording: two live bugs, not three.
Authorship verified (4 commits + push by rotundtapir). Merging once CI is green and the comment is reworded.
Review was right that the comment's stated mechanism was wrong, and it matters: a maintainer reading "the ConcurrentHashMap iterator can throw" would correctly object that CHM iterators are weakly consistent, and might then revert a fix that is load-bearing. The actual culprit is `Iterable.toList()`, whose single-element fast path calls `iterator().next()` without `hasNext()`. With exactly one room pending — the ordinary case on a small server, and the likely one at SIGTERM — the writer removing that entry between the size check and the next() throws NoSuchElementException out of the shutdown path, abandoning every queued snapshot. Two or more pending rooms take `toMutableList()` and are safe, which is why this stayed hidden. No behaviour change; comment only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019EH6XkA2oEhv4YnMXt8hbJ Signed-off-by: rotund_tapir <rotund_tapir@protonmail.com>
|
Comment reworded in On whether bug 3 was live, though: I think it was, and the disagreement is worth public fun <T> Iterable<T>.toList(): List<T> {
if (this is Collection) {
return when (size) {
0 -> emptyList()
1 -> listOf(if (this is List) get(0) else iterator().next()) // <-- no hasNext()
else -> this.toMutableList()
}
}
...
}So Note the frame is The reason I'd still call it live: it needs exactly one pending room, which on a box Entirely your call how to word the runbook and release notes; I'll follow whatever you |
|
Conceded, and verified: I pulled So the record stands at three live bugs, and the wording follows: release notes say three; the runbook gets both operational notes (pre-fix CI on the reworded head and this merges. |
Extracts the online stack from the 500 app into two new modules, the extraction
500's
docs/multiplayer-architecture.mdand ROADMAP deferred "until a second gameuses cardkit". Euchre is now that second game, so this is the enabling change for
its online multiplayer — and 500 adopts the same modules with no wire change.
Sequenced deliberately: this lands first, then 500 adopts it behind its full
integration suite, then euchre consumes the same tip.
cardkit-net(KMP jvm + wasmJs)The wire protocol minus game payloads, the
wireJsonfactory,Names, and theKtor
GameClient.The one design decision worth review:
ClientMessage/ServerMessageare openpolymorphic interfaces, not sealed. A sealed hierarchy is closed at its declaring
module, so it could never accept a game's own payload types; instead every concrete
message is registered in a
SerializersModule, and the three payload-carryingmessages (
ViewUpdate<V>,SubmitAction<A>,LobbyState<C>) are generic over thegame's view/action/config types. A game calls
wireJson(gameWireModule(actionSer, viewSer, configSer, CreateLobby::class, …)).CreateLobby/LobbyConfigstay per-game, behind a sharedCreateLobbyRequestinterface, because their bodies are that game's house rules.
Open polymorphism emits byte-identical JSON to a sealed hierarchy — same
"type"discriminator, same property order, same omission of defaults — which is what makes
this safe for an already-released protocol.
WireShapeTestpins that claim with theexact golden strings 500 ships today, plus the generic-payload shapes (a game's
payload inlines with no wrapper) and the behaviours the server relies on: unknown
keys ignored, unknown enums coerced to
UNKNOWN, and an unregistered"type"stillfailing to decode so the MALFORMED path is unchanged.
Registering one instantiation per module means a
Jsonspeaks exactly one game.That is deliberate: two games are two processes, never one ambiguous wire.
cardkit-server(JVM only — the first such module here)Room actor, seat hosting with bot substitution and reclaim, session tokens,
restart-surviving snapshots, join-code registry, per-IP/per-socket limiting with the
ABUSE event=…fail2ban format unchanged, drain/health/metrics, Ktor transport.All of it generic over
<S, A, V, C>behind one seam,GameDescriptor: rules for alobby config, a fresh deal from a seed (cardkit's
GameRulesdeliberately has nonewGame), a bot, the terminal scoreline, snapshot serializers, and the game's wireJson.Metricstakes a prefix so two servers' series never collide;ServerConfig.fromEnv(defaults)lets each game supply its own identity.It is a library, not an application: each game keeps its own
main().Exercised through a trivial two-seat toy game — enough to drive the room actor end to
end (full game to terminal, view redaction, illegal/stale rejection, bot substitution
with reclaim, snapshot adoption at boot with a mid-game reclaim, config refusal, idle
disband) without depending on any real game.
Three pre-existing bugs found, all live in 500's deployed server
These surfaced while getting the toy-game tests green. Each has a regression test.
SeatHostcould silently eat a player's move. It drained its action buffer onentry to
decide(), on the stated assumption that a client cannot have answeredyet because the view "is delivered after the driver has already entered
decide()". That is false — the driver only queues its state for the room, sothe room can fan the view out, and a quick client can answer it, first. The action
was discarded as stale; and because the room had already recorded the move as
accepted, the client's resend was deduplicated into silence. The player's turn did
nothing until it timed out to a bot. Draining now happens at the turn boundary the
room actually knows about (
SeatHost.beginTurn, called fromRoom.onStateProducedbefore any view goes out), which keeps the originalstale-action protection and stops eating good moves. This is a behaviour change
vs 500's current server, and the one thing here that most wants your eyes.
Concurrent snapshot writes could corrupt a room's file.
FileRoomPersistenceused one
<gameId>.tmpper room, butflushSyncwrites on the calling thread andcan run while the writer coroutine is mid-write for that same room. Their bytes
interleaved and the atomic rename published the mixture — the exact corruption the
rename exists to prevent — after which the file was quarantined as
.corruptandthat room lost its game at the next boot. Temp names are now unique per write.
(I hit this for real: a snapshot ending
…"savedAtMillis":1785350119742}},"savedAtMillis":1785350119801}.)Graceful shutdown could abandon every queued snapshot.
flushSynciteratedpending.keys, and aConcurrentHashMapkey iterator throwsNoSuchElementExceptionwhen the writer removes a key mid-iteration. On theshutdown path that means the flush dies and nothing else reaches disk. It now
drains the work queue through the same claim protocol the writer uses.
Verification
qualityCheck,build, andkoverVerifyon both new modules (ratchets carried overfrom 500: net ≥70%, server ≥60%). The server suite was run 12× consecutively to
confirm it is not timing-dependent.
🤖 Generated with Claude Code
https://claude.ai/code/session_019EH6XkA2oEhv4YnMXt8hbJ