Skip to content

Commit 9b0b489

Browse files
authored
feat(web): add replay timeline and branching (#102)
1 parent 4c86871 commit 9b0b489

13 files changed

Lines changed: 957 additions & 5 deletions

File tree

‎tests/browser/replay.spec.mjs‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { expect, test } from "@playwright/test";
2+
const site = "http://127.0.0.1:18081";
3+
const examples = [
4+
["tictactoe", {}, ["r1c1", "r2c1", "r1c2", "r2c2", "r1c3"]],
5+
["connect4", {}, ["1", "2", "1", "2", "1", "2", "1"]],
6+
["chess", {}, ["e2e4", "e7e5", "g1f3"]],
7+
["sudoku", { config: { size: 4 }, start: { position: "123434122143432." } }, ["r4c4+1", "r4c4=1"]],
8+
];
9+
async function fixture(request, game, extra, moves) {
10+
const response = await request.post("/v1/matches", { data: { game_id: game, include_info: false, ...extra } });
11+
expect(response.ok()).toBe(true);
12+
let state = await response.json();
13+
const id = state.match_id;
14+
for (const action of moves) {
15+
const result = await request.post(`/v1/matches/${id}/actions`, { data: { seat: state.to_act[0], turn: state.turn, action, reasoning: "Recorded browser strategy at turn " + state.turn } });
16+
expect(result.ok()).toBe(true);
17+
state = (await result.json()).state;
18+
}
19+
return id;
20+
}
21+
for (const [game, extra, moves] of examples) test(`${game} replays the selected turn and branches without changing its parent`, async ({ page, request }) => {
22+
if (game === "sudoku") await page.setViewportSize({ width: 360, height: 800 });
23+
const id = await fixture(request, game, extra, moves);
24+
const original = await (await request.get(`/v1/matches/${id}/replay`)).json();
25+
await page.goto(site + `/#/matches/${id}/replay?turn=1`);
26+
await expect(page.getByTestId("replay-turn")).toHaveText(`Turn 1 of ${moves.length}`);
27+
await page.getByText("Text board", { exact: true }).click();
28+
await expect(page.locator(".spectator-text pre")).toHaveText(original.states[1].observation.text);
29+
await page.getByRole("button", { name: "Branch from here", exact: true }).click();
30+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
31+
await page.getByRole("button", { name: "Branch and play", exact: true }).click();
32+
await expect(page.getByLabel("Legal move", { exact: true })).toBeEnabled();
33+
const child = /#\/matches\/([^/?]+)/.exec(page.url())[1];
34+
expect(child).not.toBe(id);
35+
await expect(page.locator(".turn-counter")).toHaveText("Turn 0");
36+
const metadata = await (await request.get(`/v1/matches/${child}`)).json();
37+
expect(metadata.forked_from).toEqual({ match_id: id, turn: 1 });
38+
await page.getByLabel("Legal move", { exact: true }).selectOption({ index: 1 });
39+
await expect(page.locator(".turn-counter")).toHaveText("Turn 1");
40+
expect(await (await request.get(`/v1/matches/${id}/replay`)).json()).toEqual(original);
41+
await page.getByRole("link", { name: "Replay and branch", exact: true }).click();
42+
await expect(page.getByRole("region", { name: "Variations", exact: true })).toContainText(id);
43+
await page.getByRole("link", { name: "Parent at turn 1", exact: true }).click();
44+
await expect(page.getByTestId("replay-turn")).toHaveText(`Turn 1 of ${moves.length}`);
45+
await expect(page.getByRole("link", { name: "Variation from turn 1", exact: true })).toBeVisible();
46+
});
47+
48+
test("replay timeline, perspective, autoplay, reasoning and turn links stay in sync", async ({ page, request, context }) => {
49+
const id = await fixture(request, ...examples[0]);
50+
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
51+
await page.goto(site + `/#/matches/${id}/replay?turn=2`);
52+
await expect(page.getByTestId("replay-turn")).toHaveText("Turn 2 of 5");
53+
await page.getByRole("button", { name: "Next", exact: true }).click();
54+
await expect(page.getByTestId("replay-turn")).toHaveText("Turn 3 of 5");
55+
await page.getByLabel("Replay turn").press("ArrowLeft");
56+
await expect(page.getByTestId("replay-turn")).toHaveText("Turn 2 of 5");
57+
await page.getByLabel("Replay perspective").selectOption("1");
58+
await expect(page.getByTestId("replay-turn")).toHaveText("Turn 2 of 5");
59+
await page.getByRole("button", { name: "Copy turn link", exact: true }).click();
60+
await expect(page.getByRole("status").filter({ hasText: "Turn link copied." })).toBeVisible();
61+
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(site + `/#/matches/${id}/replay?turn=2&seat=1`);
62+
await page.getByText("Reasoning", { exact: true }).first().click();
63+
await expect(page.getByText("Recorded browser strategy at turn 0", { exact: true })).toBeVisible();
64+
await page.getByRole("button", { name: "First", exact: true }).click();
65+
await page.getByLabel("Playback speed").selectOption("4");
66+
await page.getByRole("button", { name: "Play", exact: true }).click();
67+
await expect(page.getByTestId("replay-turn")).toHaveText("Turn 5 of 5");
68+
await expect(page.getByRole("button", { name: "Play", exact: true })).toBeVisible();
69+
await page.reload();
70+
await expect(page.getByTestId("replay-turn")).toHaveText("Turn 5 of 5");
71+
});
72+
73+
test("a replay can branch against an installed opponent and show recorded estimates", async ({ page, request }) => {
74+
const id = await fixture(request, ...examples[0]);
75+
await page.goto(site + `/#/matches/${id}/replay?turn=1`);
76+
await page.getByRole("button", { name: "Branch from here", exact: true }).click();
77+
await page.getByLabel("Variation players").selectOption("opponent");
78+
await page.getByLabel("Variation opponent").selectOption("minimax");
79+
await page.getByRole("button", { name: "Branch and play", exact: true }).click();
80+
await expect(page.locator(".turn-counter")).toHaveText("Turn 1");
81+
const child = /#\/matches\/([^/?]+)/.exec(page.url())[1];
82+
const state = await (await request.get(`/v1/matches/${child}/state?seat=0`)).json();
83+
await request.post(`/v1/matches/${child}/resign`, { data: { seat: 0, turn: state.turn } });
84+
await page.goto(site + `/#/matches/${child}/replay`);
85+
await expect(page.getByRole("img", { name: "Recorded engine estimates", exact: true })).toBeVisible();
86+
await page.getByText("Agent details", { exact: true }).click();
87+
await expect(page.locator(".agent-record pre")).toContainText("evaluation");
88+
});

‎web/README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,19 @@ WebSocket updates refresh the boards and move lists; polling continues when the
8989
stream is unavailable. Provider reasoning remains subject to server disclosure
9090
rules. This is the local workspace; hosted visibility/account controls are not
9191
implemented by these pages.
92+
93+
## Replays and variations
94+
95+
Open **Replay** from history or **Replay and branch** from a match. The replay
96+
uses recorded states, with timeline/step controls, autoplay speed, a public or
97+
player perspective, and links that retain the selected turn. The move list
98+
shows disclosed reasoning and recorded agent diagnostics. Engine estimates
99+
are plotted only where they were recorded, from the acting player's perspective;
100+
the viewer does not invent evaluations or blunder labels for missing analysis.
101+
102+
**Branch from here** creates an independent match from the selected turn, with
103+
hot-seat, an installed opponent, or an open agent seat. Game options and assists
104+
are inherited. Parent matches remain unchanged. The variation panel links the
105+
ancestor chain and discovered direct children; **Find more variations** continues
106+
the history scan when more records exist. Provider transcripts and omniscient
107+
hidden-information views require their corresponding backend capabilities.

‎web/apps/site/src/App.tsx‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { MatchSetup } from "./setup";
44
import { MatchPage } from "./match";
55
import { HistoryPage } from "./history";
66
import { Spectator } from "./spectate";
7+
import { ReplayPage } from "./replay";
78

89
export function App() {
910
const route = useRoute();
1011
const [path, search = ""] = route.split("?");
1112
const params = new URLSearchParams(search);
1213
let page = <Library />;
13-
const match = /^\/(games|matches)\/([^/]+)(\/(?:play|watch))?$/.exec(path);
14+
const match = /^\/(games|matches)\/([^/]+)(\/(?:play|watch|replay))?$/.exec(
15+
path,
16+
);
1417
if (match) {
1518
try {
1619
const id = decodeURIComponent(match[2]);
@@ -22,6 +25,23 @@ export function App() {
2225
) : (
2326
<GameDetails key={id} id={id} />
2427
);
28+
} else if (match[3] === "/replay") {
29+
const turn = Number(params.get("turn") ?? 0);
30+
const seat = params.get("seat") ?? "public";
31+
if (
32+
!Number.isSafeInteger(turn) ||
33+
turn < 0 ||
34+
(seat !== "public" && (!/^\d+$/.test(seat) || Number(seat) > 255))
35+
)
36+
throw new Error("Invalid replay link");
37+
page = (
38+
<ReplayPage
39+
key={id}
40+
id={id}
41+
initialTurn={turn}
42+
initialSeat={seat === "public" ? undefined : Number(seat)}
43+
/>
44+
);
2545
} else if (match[3] === "/watch") {
2646
page = <Spectator key={id} id={id} />;
2747
} else {

‎web/apps/site/src/feedback.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ export function Failure({
1616
: "The game server isn't reachable. Start gfa serve and try again."}
1717
</p>
1818
{error instanceof ApiError && <p>{error.hint}</p>}
19-
{retry && <button onClick={retry}>Try again</button>}
19+
{retry && (
20+
<button type="button" onClick={retry}>
21+
Try again
22+
</button>
23+
)}
2024
</section>
2125
);
2226
}

‎web/apps/site/src/history.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ export function HistoryPage({ live = false }: { live?: boolean }) {
203203
href={
204204
"#/matches/" +
205205
encodeURIComponent(match.forked_from.match_id) +
206-
"/watch"
206+
"/replay?turn=" +
207+
match.forked_from.turn
207208
}
208209
>
209210
{match.forked_from.match_id}
@@ -227,6 +228,14 @@ export function HistoryPage({ live = false }: { live?: boolean }) {
227228
>
228229
Open match
229230
</a>
231+
<a
232+
className="button secondary"
233+
href={
234+
"#/matches/" + encodeURIComponent(match.match_id) + "/replay"
235+
}
236+
>
237+
Replay
238+
</a>
230239
{live && (
231240
<label className="checkbox-label">
232241
<input

‎web/apps/site/src/match.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,11 @@ export function MatchPage({
327327
<a href={"#/matches/" + encodeURIComponent(id) + "/watch"}>
328328
Watch as a spectator
329329
</a>
330+
<p>
331+
<a href={"#/matches/" + encodeURIComponent(id) + "/replay"}>
332+
Replay and branch
333+
</a>
334+
</p>
330335
<label>
331336
Viewing seat
332337
<select
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { useId } from "react";
2+
import type { RecordedEvent } from "@gfa/api-client";
3+
4+
/** Display only recorded, disclosed estimates; never invent missing evaluations. */
5+
export function EvaluationGraph({
6+
events,
7+
names,
8+
range,
9+
last,
10+
}: {
11+
events: RecordedEvent[];
12+
names: string[];
13+
range: number[];
14+
last: number;
15+
}) {
16+
const label = useId();
17+
const points = events.flatMap((event) => {
18+
if (
19+
event.type !== "action" ||
20+
!event.opponent_info ||
21+
typeof event.opponent_info !== "object" ||
22+
Array.isArray(event.opponent_info)
23+
)
24+
return [];
25+
const value = event.opponent_info.evaluation;
26+
return typeof value === "number" && Number.isFinite(value)
27+
? [{ turn: event.turn, seat: event.seat, value }]
28+
: [];
29+
});
30+
if (!points.length)
31+
return (
32+
<p className="history-note">
33+
No engine evaluations were recorded for this view.
34+
</p>
35+
);
36+
const low = Math.min(range[0] ?? -1, ...points.map((point) => point.value));
37+
const high = Math.max(range[1] ?? 1, ...points.map((point) => point.value));
38+
const y = (value: number) =>
39+
140 - ((value - low) / Math.max(high - low, 1)) * 120;
40+
const x = (turn: number) => 45 + (turn / Math.max(last, 1)) * 510;
41+
return (
42+
<figure className="evaluation-graph">
43+
<figcaption id={label}>Recorded engine estimates</figcaption>
44+
<svg viewBox="0 0 600 170" role="img" aria-labelledby={label}>
45+
<line x1={45} y1={y(0)} x2={555} y2={y(0)} stroke="#526879" />
46+
<text x={5} y={25} fill="#adbaca" fontSize={13}>
47+
{high}
48+
</text>
49+
<text x={5} y={145} fill="#adbaca" fontSize={13}>
50+
{low}
51+
</text>
52+
<text x={45} y={165} fill="#adbaca" fontSize={13}>
53+
Turn 0
54+
</text>
55+
<text x={510} y={165} fill="#adbaca" fontSize={13}>
56+
Turn {last}
57+
</text>
58+
{names.map((name, seat) => (
59+
<polyline
60+
key={name}
61+
points={points
62+
.filter((point) => point.seat === seat)
63+
.map((point) => x(point.turn) + "," + y(point.value))
64+
.join(" ")}
65+
stroke={seat === 0 ? "#abefd0" : "#f4cd88"}
66+
strokeDasharray={seat === 0 ? undefined : "5 3"}
67+
strokeWidth={2}
68+
fill="none"
69+
/>
70+
))}
71+
{points.map((point) => (
72+
<circle
73+
key={point.turn}
74+
cx={x(point.turn)}
75+
cy={y(point.value)}
76+
r={4}
77+
fill={point.seat === 0 ? "#abefd0" : "#f4cd88"}
78+
>
79+
<title>
80+
{names[point.seat]} before turn {point.turn + 1}: {point.value}
81+
</title>
82+
</circle>
83+
))}
84+
</svg>
85+
<p className="history-note">
86+
Expected return from the acting player's perspective. Solid line:{" "}
87+
{names[0]}. {names[1] ? "Dashed line: " + names[1] + "." : ""} Missing
88+
turns have no recorded estimate.
89+
</p>
90+
<details>
91+
<summary>Evaluation values</summary>
92+
<table>
93+
<thead>
94+
<tr>
95+
<th>Before turn</th>
96+
<th>Player</th>
97+
<th>Estimate</th>
98+
</tr>
99+
</thead>
100+
<tbody>
101+
{points.map((point) => (
102+
<tr key={point.turn}>
103+
<td>{point.turn + 1}</td>
104+
<td>{names[point.seat]}</td>
105+
<td>{point.value.toFixed(3)}</td>
106+
</tr>
107+
))}
108+
</tbody>
109+
</table>
110+
</details>
111+
</figure>
112+
);
113+
}

0 commit comments

Comments
 (0)