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.defender.carriedTreasureId = null;
|
||||||
ctx.events.push({
|
ctx.events.push({
|
||||||
type: "treasureDropped", player: ctx.defender.id, treasureId: t.id,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted);
|
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);
|
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 {
|
try {
|
||||||
switch (msg.type) {
|
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": {
|
case "create": {
|
||||||
const name = cleanName(msg.name);
|
const name = cleanName(msg.name);
|
||||||
if (!name) return send(socket, { type: "error", message: "name required" });
|
if (!name) return send(socket, { type: "error", message: "name required" });
|
||||||
|
|||||||
@@ -277,6 +277,11 @@ function saveSeats(seats: Seat[]): void {
|
|||||||
|
|
||||||
class Net {
|
class Net {
|
||||||
status = $state<"disconnected" | "connected">("disconnected");
|
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);
|
roomId = $state<string | null>(null);
|
||||||
players = $state<string[]>([]);
|
players = $state<string[]>([]);
|
||||||
hostId = $state<string | null>(null);
|
hostId = $state<string | null>(null);
|
||||||
@@ -339,6 +344,14 @@ class Net {
|
|||||||
if (this.ws) return;
|
if (this.ws) return;
|
||||||
const ws = new WebSocket(SERVER_URL);
|
const ws = new WebSocket(SERVER_URL);
|
||||||
this.ws = ws;
|
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 = () => {
|
ws.onopen = () => {
|
||||||
this.watching = null; // the first state after (re)connecting may carry a gap
|
this.watching = null; // the first state after (re)connecting may carry a gap
|
||||||
this.status = "connected";
|
this.status = "connected";
|
||||||
@@ -367,7 +380,10 @@ class Net {
|
|||||||
};
|
};
|
||||||
ws.onmessage = (raw) => {
|
ws.onmessage = (raw) => {
|
||||||
const msg = JSON.parse(raw.data as string);
|
const msg = JSON.parse(raw.data as string);
|
||||||
|
this.lastHeard = Date.now();
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
|
case "pong":
|
||||||
|
break;
|
||||||
case "seat": {
|
case "seat": {
|
||||||
this.token = msg.token;
|
this.token = msg.token;
|
||||||
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
|
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user