Wording throughout the app, the server's messages and the engine's refusals trades its stacked dashes for sentences, while the maze still trusts you to honor its curses, the gallery still listens, and the clockwork still learns its best tricks from the humans at the table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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: bits.length ? `${who} ${bits.join(" and ")}, but nothing touched you.` : `${who} passed the time without troubling 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;
|
|
}
|