From ca0a4ef2e3ac548ae7dc1dda2539fca1c4f612c0 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Thu, 27 Aug 2026 23:45:26 -0400 Subject: [PATCH] Forced drops bank honestly; half-dead sockets confess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DROP OBJECT's forced drop hardcoded onHomeOf null and skipped the victory check — a treasure shaken loose over a home square neither reported the banking nor triggered scoring or the captured-treasures clause until something else prodded the engine. It now lands like any drop: homeOwnerAt reported, checkVictory immediate. And the stuck-game mystery from 38YP: a deploy restart left the browser holding a half-dead socket that never fired onclose, so the existing reconnect loop and masthead banner never engaged and a live game wore a frozen face. The client now pings every 15s and force- closes a socket silent for 45s, which wakes the reconnect machinery; the server answers pong. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 6 +++++- packages/engine/test/casting.test.ts | 31 ++++++++++++++++++++++++++++ packages/server/src/index.ts | 6 ++++++ packages/web/src/net.svelte.ts | 16 ++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index ec06d0b..adb5ff0 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -1460,8 +1460,12 @@ const CARD_EFFECTS: Record ctx.defender.carriedTreasureId = null; ctx.events.push({ type: "treasureDropped", player: ctx.defender.id, treasureId: t.id, - at: ctx.defender.position, onHomeOf: null, + at: ctx.defender.position, onHomeOf: homeOwnerAt(ctx.state, ctx.defender.position), }); + // A forced drop can land on a home square: bankings and the + // captured-treasures clause take effect at once, as they would + // for a willing drop. + checkVictory(ctx.state, ctx.events); return; } const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted); diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 77a56b0..a4cbc0f 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -1265,3 +1265,34 @@ describe("the tear is an attack (rev 6)", () => { expect(r.events.some((e) => e.type === "treasureTorn" || e.type === "tearResisted")).toBe(true); }); }); + +describe("a forced drop lands like any drop", () => { + it("DROP OBJECT onto a home square reports the banking and checks victory", () => { + let { state } = createGame({ playerIds: ["att", "def"], seed: 42, sets: ["basic", "expansion1"] }); + state = toRound2(state); + while (activePlayer(state).id !== "att") { + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + } + const att = state.players.find((p) => p.id === "att")!; + const def = state.players.find((p) => p.id === "def")!; + // The defender stands on their OWN home carrying the attacker's gold — + // the forced drop banks it for them, cruel as that is for the caster. + def.position = { ...def.home }; + att.position = { ...def.home }; + const gold = state.treasures.find((t) => t.owner === "att" && t.position)!; + gold.position = null; + gold.carriedBy = "def"; + def.carriedTreasureId = gold.id; + const dob = giveCard(state, "att", "drop-object", "D", 0); + let r = applyCommand(state, "att", { + type: "cast", instanceId: dob.instanceId, + target: { kind: "player", playerId: "def" }, params: { cardId: "treasure" }, + }); + if (!r.ok) throw new Error(r.error); + r = applyCommand(r.state, "def", { type: "pass" }); + if (!r.ok) throw new Error(r.error); + const drop = r.events.find((e) => e.type === "treasureDropped"); + expect(drop).toBeTruthy(); + if (drop?.type === "treasureDropped") expect(drop.onHomeOf).toBe("def"); + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 0d36076..29d59c2 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -475,6 +475,12 @@ wss.on("connection", (socket) => { try { switch (msg.type) { + case "ping": + // Liveness probe: the reply is the point. A restart can leave + // browsers holding half-dead sockets that never fire onclose; + // silence answered by silence is how the client finds out. + send(socket, { type: "pong" }); + break; case "create": { const name = cleanName(msg.name); if (!name) return send(socket, { type: "error", message: "name required" }); diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index ebc1738..540724a 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -277,6 +277,11 @@ function saveSeats(seats: Seat[]): void { class Net { status = $state<"disconnected" | "connected">("disconnected"); + /** When the socket last spoke. A server restart can leave a half-dead + * socket that never fires onclose; the watchdog closes it by hand so + * the reconnect loop (and the masthead banner) actually engage. */ + private lastHeard = Date.now(); + private watchdog: ReturnType | null = null; roomId = $state(null); players = $state([]); hostId = $state(null); @@ -339,6 +344,14 @@ class Net { if (this.ws) return; const ws = new WebSocket(SERVER_URL); this.ws = ws; + this.lastHeard = Date.now(); + if (!this.watchdog) { + this.watchdog = setInterval(() => { + if (!this.ws || this.status !== "connected") return; + this.send({ type: "ping" }); + if (Date.now() - this.lastHeard > 45_000) this.ws.close(); + }, 15_000); + } ws.onopen = () => { this.watching = null; // the first state after (re)connecting may carry a gap this.status = "connected"; @@ -367,7 +380,10 @@ class Net { }; ws.onmessage = (raw) => { const msg = JSON.parse(raw.data as string); + this.lastHeard = Date.now(); switch (msg.type) { + case "pong": + break; case "seat": { this.token = msg.token; const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();