Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions tests/browser/replay.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { expect, test } from "@playwright/test";
const site = "http://127.0.0.1:18081";
const examples = [
["tictactoe", {}, ["r1c1", "r2c1", "r1c2", "r2c2", "r1c3"]],
["connect4", {}, ["1", "2", "1", "2", "1", "2", "1"]],
["chess", {}, ["e2e4", "e7e5", "g1f3"]],
["sudoku", { config: { size: 4 }, start: { position: "123434122143432." } }, ["r4c4+1", "r4c4=1"]],
];
async function fixture(request, game, extra, moves) {
const response = await request.post("/v1/matches", { data: { game_id: game, include_info: false, ...extra } });
expect(response.ok()).toBe(true);
let state = await response.json();
const id = state.match_id;
for (const action of moves) {
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 } });
expect(result.ok()).toBe(true);
state = (await result.json()).state;
}
return id;
}
for (const [game, extra, moves] of examples) test(`${game} replays the selected turn and branches without changing its parent`, async ({ page, request }) => {
if (game === "sudoku") await page.setViewportSize({ width: 360, height: 800 });
const id = await fixture(request, game, extra, moves);
const original = await (await request.get(`/v1/matches/${id}/replay`)).json();
await page.goto(site + `/#/matches/${id}/replay?turn=1`);
await expect(page.getByTestId("replay-turn")).toHaveText(`Turn 1 of ${moves.length}`);
await page.getByText("Text board", { exact: true }).click();
await expect(page.locator(".spectator-text pre")).toHaveText(original.states[1].observation.text);
await page.getByRole("button", { name: "Branch from here", exact: true }).click();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.getByRole("button", { name: "Branch and play", exact: true }).click();
await expect(page.getByLabel("Legal move", { exact: true })).toBeEnabled();
const child = /#\/matches\/([^/?]+)/.exec(page.url())[1];
expect(child).not.toBe(id);
await expect(page.locator(".turn-counter")).toHaveText("Turn 0");
const metadata = await (await request.get(`/v1/matches/${child}`)).json();
expect(metadata.forked_from).toEqual({ match_id: id, turn: 1 });
await page.getByLabel("Legal move", { exact: true }).selectOption({ index: 1 });
await expect(page.locator(".turn-counter")).toHaveText("Turn 1");
expect(await (await request.get(`/v1/matches/${id}/replay`)).json()).toEqual(original);
await page.getByRole("link", { name: "Replay and branch", exact: true }).click();
await expect(page.getByRole("region", { name: "Variations", exact: true })).toContainText(id);
await page.getByRole("link", { name: "Parent at turn 1", exact: true }).click();
await expect(page.getByTestId("replay-turn")).toHaveText(`Turn 1 of ${moves.length}`);
await expect(page.getByRole("link", { name: "Variation from turn 1", exact: true })).toBeVisible();
});

test("replay timeline, perspective, autoplay, reasoning and turn links stay in sync", async ({ page, request, context }) => {
const id = await fixture(request, ...examples[0]);
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.goto(site + `/#/matches/${id}/replay?turn=2`);
await expect(page.getByTestId("replay-turn")).toHaveText("Turn 2 of 5");
await page.getByRole("button", { name: "Next", exact: true }).click();
await expect(page.getByTestId("replay-turn")).toHaveText("Turn 3 of 5");
await page.getByLabel("Replay turn").press("ArrowLeft");
await expect(page.getByTestId("replay-turn")).toHaveText("Turn 2 of 5");
await page.getByLabel("Replay perspective").selectOption("1");
await expect(page.getByTestId("replay-turn")).toHaveText("Turn 2 of 5");
await page.getByRole("button", { name: "Copy turn link", exact: true }).click();
await expect(page.getByRole("status").filter({ hasText: "Turn link copied." })).toBeVisible();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(site + `/#/matches/${id}/replay?turn=2&seat=1`);
await page.getByText("Reasoning", { exact: true }).first().click();
await expect(page.getByText("Recorded browser strategy at turn 0", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "First", exact: true }).click();
await page.getByLabel("Playback speed").selectOption("4");
await page.getByRole("button", { name: "Play", exact: true }).click();
await expect(page.getByTestId("replay-turn")).toHaveText("Turn 5 of 5");
await expect(page.getByRole("button", { name: "Play", exact: true })).toBeVisible();
await page.reload();
await expect(page.getByTestId("replay-turn")).toHaveText("Turn 5 of 5");
});

test("a replay can branch against an installed opponent and show recorded estimates", async ({ page, request }) => {
const id = await fixture(request, ...examples[0]);
await page.goto(site + `/#/matches/${id}/replay?turn=1`);
await page.getByRole("button", { name: "Branch from here", exact: true }).click();
await page.getByLabel("Variation players").selectOption("opponent");
await page.getByLabel("Variation opponent").selectOption("minimax");
await page.getByRole("button", { name: "Branch and play", exact: true }).click();
await expect(page.locator(".turn-counter")).toHaveText("Turn 1");
const child = /#\/matches\/([^/?]+)/.exec(page.url())[1];
const state = await (await request.get(`/v1/matches/${child}/state?seat=0`)).json();
await request.post(`/v1/matches/${child}/resign`, { data: { seat: 0, turn: state.turn } });
await page.goto(site + `/#/matches/${child}/replay`);
await expect(page.getByRole("img", { name: "Recorded engine estimates", exact: true })).toBeVisible();
await page.getByText("Agent details", { exact: true }).click();
await expect(page.locator(".agent-record pre")).toContainText("evaluation");
});
16 changes: 16 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,19 @@ WebSocket updates refresh the boards and move lists; polling continues when the
stream is unavailable. Provider reasoning remains subject to server disclosure
rules. This is the local workspace; hosted visibility/account controls are not
implemented by these pages.

## Replays and variations

Open **Replay** from history or **Replay and branch** from a match. The replay
uses recorded states, with timeline/step controls, autoplay speed, a public or
player perspective, and links that retain the selected turn. The move list
shows disclosed reasoning and recorded agent diagnostics. Engine estimates
are plotted only where they were recorded, from the acting player's perspective;
the viewer does not invent evaluations or blunder labels for missing analysis.

**Branch from here** creates an independent match from the selected turn, with
hot-seat, an installed opponent, or an open agent seat. Game options and assists
are inherited. Parent matches remain unchanged. The variation panel links the
ancestor chain and discovered direct children; **Find more variations** continues
the history scan when more records exist. Provider transcripts and omniscient
hidden-information views require their corresponding backend capabilities.
22 changes: 21 additions & 1 deletion web/apps/site/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import { MatchSetup } from "./setup";
import { MatchPage } from "./match";
import { HistoryPage } from "./history";
import { Spectator } from "./spectate";
import { ReplayPage } from "./replay";

export function App() {
const route = useRoute();
const [path, search = ""] = route.split("?");
const params = new URLSearchParams(search);
let page = <Library />;
const match = /^\/(games|matches)\/([^/]+)(\/(?:play|watch))?$/.exec(path);
const match = /^\/(games|matches)\/([^/]+)(\/(?:play|watch|replay))?$/.exec(
path,
);
if (match) {
try {
const id = decodeURIComponent(match[2]);
Expand All @@ -22,6 +25,23 @@ export function App() {
) : (
<GameDetails key={id} id={id} />
);
} else if (match[3] === "/replay") {
const turn = Number(params.get("turn") ?? 0);
const seat = params.get("seat") ?? "public";
if (
!Number.isSafeInteger(turn) ||
turn < 0 ||
(seat !== "public" && (!/^\d+$/.test(seat) || Number(seat) > 255))
)
throw new Error("Invalid replay link");
page = (
<ReplayPage
key={id}
id={id}
initialTurn={turn}
initialSeat={seat === "public" ? undefined : Number(seat)}
/>
);
} else if (match[3] === "/watch") {
page = <Spectator key={id} id={id} />;
} else {
Expand Down
6 changes: 5 additions & 1 deletion web/apps/site/src/feedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ export function Failure({
: "The game server isn't reachable. Start gfa serve and try again."}
</p>
{error instanceof ApiError && <p>{error.hint}</p>}
{retry && <button onClick={retry}>Try again</button>}
{retry && (
<button type="button" onClick={retry}>
Try again
</button>
)}
</section>
);
}
Expand Down
11 changes: 10 additions & 1 deletion web/apps/site/src/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ export function HistoryPage({ live = false }: { live?: boolean }) {
href={
"#/matches/" +
encodeURIComponent(match.forked_from.match_id) +
"/watch"
"/replay?turn=" +
match.forked_from.turn
}
>
{match.forked_from.match_id}
Expand All @@ -227,6 +228,14 @@ export function HistoryPage({ live = false }: { live?: boolean }) {
>
Open match
</a>
<a
className="button secondary"
href={
"#/matches/" + encodeURIComponent(match.match_id) + "/replay"
}
>
Replay
</a>
{live && (
<label className="checkbox-label">
<input
Expand Down
5 changes: 5 additions & 0 deletions web/apps/site/src/match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,11 @@ export function MatchPage({
<a href={"#/matches/" + encodeURIComponent(id) + "/watch"}>
Watch as a spectator
</a>
<p>
<a href={"#/matches/" + encodeURIComponent(id) + "/replay"}>
Replay and branch
</a>
</p>
<label>
Viewing seat
<select
Expand Down
113 changes: 113 additions & 0 deletions web/apps/site/src/replay-evaluation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useId } from "react";
import type { RecordedEvent } from "@gfa/api-client";

/** Display only recorded, disclosed estimates; never invent missing evaluations. */
export function EvaluationGraph({
events,
names,
range,
last,
}: {
events: RecordedEvent[];
names: string[];
range: number[];
last: number;
}) {
const label = useId();
const points = events.flatMap((event) => {
if (
event.type !== "action" ||
!event.opponent_info ||
typeof event.opponent_info !== "object" ||
Array.isArray(event.opponent_info)
)
return [];
const value = event.opponent_info.evaluation;
return typeof value === "number" && Number.isFinite(value)
? [{ turn: event.turn, seat: event.seat, value }]
: [];
});
if (!points.length)
return (
<p className="history-note">
No engine evaluations were recorded for this view.
</p>
);
const low = Math.min(range[0] ?? -1, ...points.map((point) => point.value));
const high = Math.max(range[1] ?? 1, ...points.map((point) => point.value));
const y = (value: number) =>
140 - ((value - low) / Math.max(high - low, 1)) * 120;
const x = (turn: number) => 45 + (turn / Math.max(last, 1)) * 510;
return (
<figure className="evaluation-graph">
<figcaption id={label}>Recorded engine estimates</figcaption>
<svg viewBox="0 0 600 170" role="img" aria-labelledby={label}>
<line x1={45} y1={y(0)} x2={555} y2={y(0)} stroke="#526879" />
<text x={5} y={25} fill="#adbaca" fontSize={13}>
{high}
</text>
<text x={5} y={145} fill="#adbaca" fontSize={13}>
{low}
</text>
<text x={45} y={165} fill="#adbaca" fontSize={13}>
Turn 0
</text>
<text x={510} y={165} fill="#adbaca" fontSize={13}>
Turn {last}
</text>
{names.map((name, seat) => (
<polyline
key={name}
points={points
.filter((point) => point.seat === seat)
.map((point) => x(point.turn) + "," + y(point.value))
.join(" ")}
stroke={seat === 0 ? "#abefd0" : "#f4cd88"}
strokeDasharray={seat === 0 ? undefined : "5 3"}
strokeWidth={2}
fill="none"
/>
))}
{points.map((point) => (
<circle
key={point.turn}
cx={x(point.turn)}
cy={y(point.value)}
r={4}
fill={point.seat === 0 ? "#abefd0" : "#f4cd88"}
>
<title>
{names[point.seat]} before turn {point.turn + 1}: {point.value}
</title>
</circle>
))}
</svg>
<p className="history-note">
Expected return from the acting player's perspective. Solid line:{" "}
{names[0]}. {names[1] ? "Dashed line: " + names[1] + "." : ""} Missing
turns have no recorded estimate.
</p>
<details>
<summary>Evaluation values</summary>
<table>
<thead>
<tr>
<th>Before turn</th>
<th>Player</th>
<th>Estimate</th>
</tr>
</thead>
<tbody>
{points.map((point) => (
<tr key={point.turn}>
<td>{point.turn + 1}</td>
<td>{names[point.seat]}</td>
<td>{point.value.toFixed(3)}</td>
</tr>
))}
</tbody>
</table>
</details>
</figure>
);
}
Loading
Loading