diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f864bfe..8fe5b9b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -117,20 +117,9 @@ function shareData(id: string): ShareData | null { const share = getShare(id); const room = share ? getRoom(share.roomId) : undefined; if (share && room?.state) { - const steps = momentSteps(room, SPECTATOR, share.turn); - if (!("error" in steps) && steps.length > 0) { - let actor: string = steps[steps.length - 1]!.actor; - let round = 0; - outer: for (const st of steps) { - for (const e of st.events) { - if (e.type === "turnStarted" || e.type === "extraTurnStarted") { - actor = e.player; - if (e.type === "turnStarted") round = e.round; - break outer; - } - } - } - data = { steps: steps as unknown as ShareData["steps"], actor, round }; + const reel = momentSteps(room, SPECTATOR, share.turn); + if (!("error" in reel) && reel.steps.length > 0) { + data = { steps: reel.steps as unknown as ShareData["steps"], actor: reel.owner, round: reel.round }; } } if (shareCache.size > 50) shareCache.clear(); @@ -624,8 +613,8 @@ wss.on("connection", (socket) => { return send(socket, { type: "error", message: "one moment" }); } session.lastCatchUpAt = now; - const steps = momentSteps(room, session.playerId, turn); - if ("error" in steps) return send(socket, { type: "error", message: steps.error }); + const reel = momentSteps(room, session.playerId, turn); + if ("error" in reel) return send(socket, { type: "error", message: reel.error }); const share = mintShare(room.id, turn); send(socket, { type: "share", id: share.id, turn }); break; @@ -639,9 +628,9 @@ wss.on("connection", (socket) => { return send(socket, { type: "error", message: "catching up already — one moment" }); } session.lastCatchUpAt = now; - const steps = momentSteps(room, session.playerId, Number(msg.turn ?? -1)); - if ("error" in steps) return send(socket, { type: "error", message: steps.error }); - send(socket, { type: "moment", steps }); + const reel = momentSteps(room, session.playerId, Number(msg.turn ?? -1)); + if ("error" in reel) return send(socket, { type: "error", message: reel.error }); + send(socket, { type: "moment", steps: reel.steps, owner: reel.owner }); break; } case "pickColor": { diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index fb6be45..e93a952 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -420,13 +420,22 @@ export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[] * identically, so a turn number names the same stretch on both ends. */ const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]); +export interface MomentReel { + steps: CatchUpStep[]; + /** The wizard whose turn this is — the reel's eyes. */ + owner: PlayerId; + round: number; +} + /** * One turn's reel: every command whose events touch turn `turnIndex` * (0-counted across boundary events). A command that ends one turn and * starts the next belongs to both, so a reel opens with the blow that - * began it and closes on the handover. + * began it and closes on the handover. The owner comes from the very + * boundary that opened the turn — which for turn 0 lives in the DEAL, + * before any command, so no scan of the steps could find it. */ -export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): CatchUpStep[] | { error: string } { +export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): MomentReel | { error: string } { if (!room.state) return { error: "game not started" }; // The SPECTATOR builds share pages: public knowledge only, no seat. if (playerId !== SPECTATOR && !room.players.includes(playerId)) { @@ -438,14 +447,24 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): // The deal's own events open the first turn — count them, or every // turn number would sit one behind the chronicle's. let counter = -1; - for (const e of dealt) if (TURN_BOUNDARY.has(e.type)) counter++; + let owner: PlayerId | null = null; + let round = 0; + const bump = (e: GameEvent) => { + if (!TURN_BOUNDARY.has(e.type)) return; + counter++; + if (counter === turnIndex && owner === null && "player" in e) { + owner = (e as { player: PlayerId }).player; + if ("round" in e) round = (e as { round: number }).round; + } + }; + for (const e of dealt) bump(e); const steps: CatchUpStep[] = []; for (const entry of room.log) { const result = applyCommand(current, entry.playerId, entry.command); if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` }; current = result.state; const before = counter; - for (const e of result.events) if (TURN_BOUNDARY.has(e.type)) counter++; + for (const e of result.events) bump(e); if (before > turnIndex) break; if (before <= turnIndex && turnIndex <= counter) { const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : ""; @@ -463,7 +482,7 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): } } if (steps.length === 0) return { error: "no such turn yet" }; - return steps; + return { steps, owner: owner ?? steps[steps.length - 1]!.actor, round }; } // --------------------------------------------------------------------------- diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 81b79a7..1930d96 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1619,8 +1619,9 @@ {/if} - {#if net.moment && net.moment.length > 0} - net.requestShare()} onclose={() => net.closeMoment()} /> + {#if net.moment && net.moment.steps.length > 0} + net.requestShare()} onclose={() => net.closeMoment()} /> {:else if net.catchUp && net.catchUp.length > 0} net.closeCatchUp()} /> {:else if local.replaySteps && local.replaySteps.length > 0} diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index df90e1a..10fcbce 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -14,6 +14,7 @@ steps, onclose, moment = false, + pov = null, onshare = null, endLabel = null, }: { @@ -22,6 +23,9 @@ /** An instant replay of one turn: open straight into first person, * through the eyes of the wizard whose turn it is. */ moment?: boolean; + /** The turn-owner, when the server already knows it (moment reels): + * the fallback when no boundary has scrolled past yet. */ + pov?: string | null; /** Mint a public link to this turn; resolves to the URL. */ onshare?: (() => Promise) | null; /** What the leave button says once the reel has run out. */ @@ -49,18 +53,22 @@ /** Watch the board from above, or relive it through your own eyes. */ let fp = $state(moment); - /** Whose eyes the first-person camera wears: normally your own; in a - * moment reel, the turn-owner's — their position seen with YOUR - * knowledge of the maze, so nothing private leaks. */ + /** Whose eyes the first-person camera wears: the wizard whose turn it + * is, ALWAYS — their position seen with the viewer's knowledge of the + * maze, so nothing private leaks. The owner is whoever the last turn + * boundary at or before the current step named; before any boundary + * scrolls past, the server-known owner (moment reels) or the viewer. */ const povId = $derived.by(() => { - if (!moment) return steps[0]!.view.you; - for (const s of steps) { - const started = s.events.find( - (e) => e.type === "turnStarted" || e.type === "extraTurnStarted", - ); - if (started && "player" in started) return started.player as string; + for (let i = Math.min(idx, steps.length - 1); i >= 0; i--) { + const evs = steps[i]!.events; + for (let j = evs.length - 1; j >= 0; j--) { + const e = evs[j]!; + if (e.type === "turnStarted" || e.type === "extraTurnStarted") { + return e.player as string; + } + } } - return steps[0]!.actor; + return pov ?? steps[0]!.view.you; }); const step = $derived(steps[Math.min(idx, steps.length - 1)]!); const lines = $derived( @@ -246,7 +254,27 @@ // that phantom distance must not pick the opening shot's direction. if (camReady && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; } else if (aim) { - targetFacing = Math.atan2(aim.y + 0.5 - ty, aim.x + 0.5 - tx); + const ax = aim.x + 0.5, ay = aim.y + 0.5; + const toAim = Math.hypot(ax - tx, ay - ty); + const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx)); + if (sight.warped || sight.dist < toAim - 0.4) { + // The deed lands beyond these eyes — a summon across the maze, a + // spell through walls. Cut away to the spot like a broadcast + // camera: stand back down its deepest corridor, facing it, for + // this one beat; the next step cuts home to the wizard. + let best = { d: -1, a: 0 }; + for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { + const h = castRay(v, ax, ay, a); + if (h.dist > best.d) best = { d: h.dist, a }; + } + const back = Math.min(1.6, Math.max(0.35, best.d - 0.4)); + cam.x = ax + Math.cos(best.a) * back; + cam.y = ay + Math.sin(best.a) * back; + cam.facing = best.a + Math.PI; + camReady = true; + return; + } + targetFacing = Math.atan2(ay - ty, ax - tx); aimed = true; } else if (actor && actor.id !== povId && (actor.position.x !== me.position.x || actor.position.y !== me.position.y)) { diff --git a/packages/web/src/SharePage.svelte b/packages/web/src/SharePage.svelte index b5308bd..f4c049b 100644 --- a/packages/web/src/SharePage.svelte +++ b/packages/web/src/SharePage.svelte @@ -44,7 +44,7 @@ {:else} diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 17256c5..f204ba7 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -314,8 +314,9 @@ class Net { onFx: ((events: GameEvent[]) => void) | null = null; /** A catch-up reel delivered by the server. */ catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null); - /** One turn's reel, summoned from a chronicle line's instant-replay eye. */ - moment = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null); + /** One turn's reel, summoned from a chronicle line's instant-replay + * eye — steps plus the wizard whose eyes the camera wears. */ + moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null); /** Turns witnessed so far: counts the same boundary events the server * counts, so a chronicle line can name the turn it belongs to. */ private turnCounter = -1; @@ -424,7 +425,7 @@ class Net { this.catchUp = msg.steps; break; case "moment": - this.moment = msg.steps; + this.moment = { steps: msg.steps, owner: msg.owner }; break; case "share": this.shareResolve?.(`${location.origin}/watch/${msg.id}`);