Forced drops bank honestly; half-dead sockets confess

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-27 23:45:26 -04:00
co-authored by Claude Fable 5
parent 5eb5312637
commit ca0a4ef2e3
4 changed files with 58 additions and 1 deletions
+5 -1
View File
@@ -1460,8 +1460,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
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);
+31
View File
@@ -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");
});
});
+6
View File
@@ -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" });
+16
View File
@@ -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<typeof setInterval> | null = null;
roomId = $state<string | null>(null);
players = $state<string[]>([]);
hostId = $state<string | null>(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();