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:
co-authored by
Claude Fable 5
parent
5eb5312637
commit
ca0a4ef2e3
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user