Since 6e49fc9. Engine: the teleport BFS in the view and the engine's
wallIgnoringDistance share teleportStep; the counter trail is weighed
between steps instead of on six arms; the bush attack takes its
adjacency from SIDES like the wall does; a dead redirected clause, a
one-member set, an identity ternary, and a stray try/catch are gone;
two tests now assert what their names promise. Server: KEEPER and
MAX_SEATS live in rooms.ts, the three Sentry bells ring through one
helper, the unread reply field is dropped, actingSeat comes from the
engine, and the protocol header names every message. Web: ReplayStep
is one type, cardName one helper, the say box and the reference section
one snippet each, the keeper's name arrives from the server rather than
the source, and the lifesaver ruling that ruled nothing is out.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
113 lines
4.8 KiB
TypeScript
113 lines
4.8 KiB
TypeScript
// What happened while you were away, as a few facts before the reel:
|
|
// read from the missed steps as the server redacted them for you, so it
|
|
// tells you only what you would have seen at the table.
|
|
|
|
import type { GameEvent, GameView } from "@wizwar/engine";
|
|
import { cardName } from "./reference";
|
|
|
|
/** One step of a replayed stretch: who acted, what happened, the table
|
|
* after, and any talk that landed alongside. */
|
|
export interface ReplayStep {
|
|
seq: number;
|
|
actor: string;
|
|
events: GameEvent[];
|
|
view: GameView;
|
|
chat?: { player: string; text: string }[];
|
|
}
|
|
|
|
export interface MissedFact {
|
|
text: string;
|
|
/** The step of the reel this fact belongs to. */
|
|
step: number;
|
|
}
|
|
|
|
function spell(id: string | null): string {
|
|
return id ? cardName(id) : "a punch";
|
|
}
|
|
|
|
function nearHome(view: GameView, you: string, cell: { x: number; y: number }): boolean {
|
|
const me = view.players.find((p) => p.id === you);
|
|
if (!me) return false;
|
|
return Math.abs(me.home.x - cell.x) + Math.abs(me.home.y - cell.y) <= 2;
|
|
}
|
|
|
|
/** The facts of the missed steps that touch you, in order, capped so the
|
|
* slip stays a slip. Each fact points at its step for the reel. */
|
|
export function summarizeMissed(steps: readonly ReplayStep[], you: string, now: GameView | null): MissedFact[] {
|
|
const facts: MissedFact[] = [];
|
|
let lifeLost = 0;
|
|
let lifeLostStep = -1;
|
|
let talk = 0;
|
|
steps.forEach((s, i) => {
|
|
talk += s.chat?.length ?? 0;
|
|
for (const e of s.events) {
|
|
switch (e.type) {
|
|
case "treasurePickedUp":
|
|
if (e.owner === you && e.player !== you) facts.push({ text: `${e.player} took your treasure.`, step: i });
|
|
else if (e.player !== you && e.owner !== e.player) facts.push({ text: `${e.player} picked up ${e.owner}'s treasure.`, step: i });
|
|
break;
|
|
case "treasureDropped": {
|
|
const owner = s.view.treasures.find((t) => t.id === e.treasureId)?.owner;
|
|
if (e.onHomeOf != null && owner && e.onHomeOf !== owner) {
|
|
facts.push({ text: owner === you ? `Your treasure was carried home by ${e.player}.` : `${e.player} carried ${owner}'s treasure home.`, step: i });
|
|
}
|
|
break;
|
|
}
|
|
case "damaged":
|
|
if (e.player === you && e.amount > 0) { lifeLost += e.amount; if (lifeLostStep < 0) lifeLostStep = i; }
|
|
break;
|
|
case "spellCast":
|
|
if (e.target === you) facts.push({ text: `${e.caster} cast ${spell(e.cardId)} at you.`, step: i });
|
|
break;
|
|
case "punched":
|
|
if (e.target === you) facts.push({ text: `${e.attacker} punched you.`, step: i });
|
|
break;
|
|
case "wallDestroyed":
|
|
if (nearHome(s.view, you, e.edge.cell)) facts.push({ text: "A wall near your home was destroyed.", step: i });
|
|
break;
|
|
case "playerEliminated":
|
|
facts.push({ text: e.player === you ? "You were eliminated." : `${e.player} is out of the game.`, step: i });
|
|
break;
|
|
case "gameWon":
|
|
facts.push({ text: e.player === you ? "You won." : `${e.player} won the game.`, step: i });
|
|
break;
|
|
case "tableTalk":
|
|
talk++;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
// Life lost reads second: the first fact names who came for you.
|
|
if (lifeLost > 0) facts.splice(Math.min(facts.length, 1), 0, { text: `You lost ${lifeLost} life.`, step: lifeLostStep });
|
|
if (facts.length === 0) {
|
|
// Nothing touched you: say what each wizard was up to, all the same.
|
|
const doings = new Map<string, { moves: number; casts: number; first: number }>();
|
|
steps.forEach((s, i) => {
|
|
if (s.actor === you) return;
|
|
const d = doings.get(s.actor) ?? { moves: 0, casts: 0, first: i };
|
|
for (const e of s.events) {
|
|
if (e.type === "moved" && e.player === s.actor) d.moves++;
|
|
if (e.type === "spellCast" && e.caster === s.actor) d.casts++;
|
|
}
|
|
doings.set(s.actor, d);
|
|
});
|
|
for (const [who, d] of doings) {
|
|
const bits = [d.moves > 0 ? `walked ${d.moves} square${d.moves === 1 ? "" : "s"}` : "", d.casts > 0 ? `cast ${d.casts} spell${d.casts === 1 ? "" : "s"}` : ""].filter(Boolean);
|
|
facts.push({ text: `${who} ${bits.length ? bits.join(" and ") : "passed the time"}; nothing touched you.`, step: d.first });
|
|
}
|
|
}
|
|
if (talk > 0) facts.push({ text: `${talk} line${talk === 1 ? "" : "s"} of table talk.`, step: -1 });
|
|
const out = facts.slice(0, 6);
|
|
if (facts.length > 6) out.push({ text: `…and ${facts.length - 6} more.`, step: -1 });
|
|
if (now) {
|
|
const me = now.players.find((p) => p.id === you);
|
|
if (now.phase === "playing" && me?.alive) {
|
|
const mine = now.activePlayerId === you || now.stack?.waitingOn === you || now.pendingDiscard === you;
|
|
out.push({ text: mine ? "It's your turn." : `It's ${now.activePlayerId}'s turn.`, step: -1 });
|
|
}
|
|
}
|
|
return out;
|
|
}
|