diff --git a/AGENTS.md b/AGENTS.md index 906703d..20fe2e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,11 @@ gamesung/ - Reference the issue: "Closes #" - Keep PRs small and focused +### 7. Push + +- Push the working branch with `git push origin ` (NOT `git push dev-rkb`, which is the contributor's personal fork and requires separate credentials). +- Stop after pushing. Do not open a PR, do not merge, do not push to other remotes. + ## Forbidden Actions - Do not commit secrets, API keys, or credentials diff --git a/packages/server/bot/bot.go b/packages/server/bot/bot.go index 82b924c..c0c216d 100644 --- a/packages/server/bot/bot.go +++ b/packages/server/bot/bot.go @@ -15,15 +15,58 @@ var pieceValues = map[chess.PieceType]int{ chess.King: 20000, } +// Piece-square tables (white-perspective, rank 8 at index 0 .. rank 1 at index 7). +// Black mirrors the rank index. Values are added to the base material value. +// Source: simplified evaluation tables (Tomasz Michniewski / Chess Programming Wiki). +var pawnPST = [8][8]int{ + {0, 0, 0, 0, 0, 0, 0, 0}, + {50, 50, 50, 50, 50, 50, 50, 50}, + {10, 10, 20, 30, 30, 20, 10, 10}, + {5, 5, 10, 25, 25, 10, 5, 5}, + {0, 0, 0, 20, 20, 0, 0, 0}, + {5, -5, -10, 0, 0, -10, -5, 5}, + {5, 10, 10, -20, -20, 10, 10, 5}, + {0, 0, 0, 0, 0, 0, 0, 0}, +} + +var knightPST = [8][8]int{ + {-50, -40, -30, -30, -30, -30, -40, -50}, + {-40, -20, 0, 0, 0, 0, -20, -40}, + {-30, 0, 10, 15, 15, 10, 0, -30}, + {-30, 5, 15, 20, 20, 15, 5, -30}, + {-30, 0, 15, 20, 20, 15, 0, -30}, + {-30, 5, 10, 15, 15, 10, 5, -30}, + {-40, -20, 0, 5, 5, 0, -20, -40}, + {-50, -40, -30, -30, -30, -30, -40, -50}, +} + +// King PST for midgame: encourage castled position (corners), penalize center. +var kingPST = [8][8]int{ + {-30, -40, -40, -50, -50, -40, -40, -30}, + {-30, -40, -40, -50, -50, -40, -40, -30}, + {-30, -40, -40, -50, -50, -40, -40, -30}, + {-30, -40, -40, -50, -50, -40, -40, -30}, + {-20, -30, -30, -40, -40, -30, -30, -20}, + {-10, -20, -20, -20, -20, -20, -20, -10}, + {20, 20, 0, 0, 0, 0, 20, 20}, + {20, 30, 10, 0, 0, 10, 30, 20}, +} + +const ( + mateScore = 99999 + mobilityMul = 2 +) + type Bot struct { Depth int + rng *rand.Rand } func NewBot(depth int) *Bot { if depth < 1 { depth = 2 } - return &Bot{Depth: depth} + return &Bot{Depth: depth, rng: rand.New(rand.NewSource(rand.Int63()))} } func (b *Bot) GetMove(game *chess.Game) *chess.Move { @@ -36,27 +79,28 @@ func (b *Bot) GetMove(game *chess.Game) *chess.Move { return b.pickBestMove(game, validMoves) } - var bestMove *chess.Move - bestScore := -999999 + bestScore := -mateScore - 1 + candidates := []*chess.Move{} for _, move := range validMoves { - cloned := game.Clone() + cloned := game.Clone() if err := cloned.Move(move); err != nil { continue } - score := -b.minimax(cloned, b.Depth-1, -999999, 999999) + score := -b.minimax(cloned, b.Depth-1, -mateScore, mateScore) if score > bestScore { bestScore = score - bestMove = move + candidates = []*chess.Move{move} + } else if score == bestScore { + candidates = append(candidates, move) } } - if bestMove == nil { - return validMoves[rand.Intn(len(validMoves))] + if len(candidates) == 0 { + return validMoves[b.rng.Intn(len(validMoves))] } - - return bestMove + return candidates[b.rng.Intn(len(candidates))] } func (b *Bot) minimax(game *chess.Game, depth int, alpha, beta int) int { @@ -69,7 +113,7 @@ func (b *Bot) minimax(game *chess.Game, depth int, alpha, beta int) int { return b.evaluate(game) } - bestScore := -999999 + bestScore := -mateScore - 1 for _, move := range validMoves { cloned := game.Clone() if err := cloned.Move(move); err != nil { @@ -97,19 +141,20 @@ func (b *Bot) evaluate(game *chess.Game) int { var score int - for square := chess.A1; square <= chess.H8; square++ { - piece := board.Piece(square) + for sq := chess.A1; sq <= chess.H8; sq++ { + piece := board.Piece(sq) if piece == chess.NoPiece { continue } - value := pieceValues[piece.Type()] + score += pieceScore(piece, sq) + } - if piece.Color() == chess.White { - score += value - } else { - score -= value - } + moves := pos.ValidMoves() + if pos.Turn() == chess.White { + score += len(moves) * mobilityMul + } else { + score -= len(moves) * mobilityMul } if pos.Turn() == chess.Black { @@ -120,8 +165,8 @@ func (b *Bot) evaluate(game *chess.Game) int { } func (b *Bot) pickBestMove(game *chess.Game, moves []*chess.Move) *chess.Move { - var bestMove *chess.Move - bestScore := -999999 + bestScore := -mateScore - 1 + candidates := []*chess.Move{} for _, move := range moves { cloned := game.Clone() @@ -132,13 +177,41 @@ func (b *Bot) pickBestMove(game *chess.Game, moves []*chess.Move) *chess.Move { score := b.evaluate(cloned) if score > bestScore { bestScore = score - bestMove = move + candidates = []*chess.Move{move} + } else if score == bestScore { + candidates = append(candidates, move) } } - if bestMove == nil { - return moves[rand.Intn(len(moves))] + if len(candidates) == 0 { + return moves[b.rng.Intn(len(moves))] } + return candidates[b.rng.Intn(len(candidates))] +} - return bestMove +func pieceScore(piece chess.Piece, sq chess.Square) int { + base := pieceValues[piece.Type()] + + var pst int + rank := sq.Rank() + file := sq.File() + rankIdx := 7 - int(rank) + if piece.Color() == chess.Black { + rankIdx = int(rank) + } + + switch piece.Type() { + case chess.Pawn: + pst = pawnPST[rankIdx][int(file)] + case chess.Knight: + pst = knightPST[rankIdx][int(file)] + case chess.King: + pst = kingPST[rankIdx][int(file)] + } + + value := base + pst + if piece.Color() == chess.White { + return value + } + return -value } diff --git a/packages/server/bot/bot_test.go b/packages/server/bot/bot_test.go new file mode 100644 index 0000000..f1ca422 --- /dev/null +++ b/packages/server/bot/bot_test.go @@ -0,0 +1,38 @@ +package bot + +import ( + "testing" + + "github.com/notnil/chess" +) + +func TestGetMoveReturnsNonKnightFromStart(t *testing.T) { + notKnight := 0 + for i := 0; i < 50; i++ { + g := chess.NewGame() + b := NewBot(2) + m := b.GetMove(g) + if m == nil { + t.Fatalf("expected a move, got nil") + } + p := g.Position().Board().Piece(m.S1()) + if p.Type() != chess.Knight { + notKnight++ + } + } + if notKnight == 0 { + t.Fatalf("expected bot to play at least one non-knight move across 50 trials") + } +} + +func TestGetMoveReturnsValidMove(t *testing.T) { + g := chess.NewGame() + b := NewBot(2) + m := b.GetMove(g) + if m == nil { + t.Fatalf("expected a move, got nil") + } + if err := g.Move(m); err != nil { + t.Fatalf("bot returned an illegal move: %v", err) + } +} diff --git a/packages/server/game/game.go b/packages/server/game/game.go index 4fbcbde..bd737e9 100644 --- a/packages/server/game/game.go +++ b/packages/server/game/game.go @@ -88,7 +88,7 @@ func (g *Game) MakeMove(moveStr string) (string, error) { return "", ErrGameNotPlaying } - move, err := chess.AlgebraicNotation{}.Decode(g.Board.Position(), moveStr) + move, err := chess.UCINotation{}.Decode(g.Board.Position(), moveStr) if err != nil { return "", ErrInvalidMove } @@ -143,7 +143,7 @@ func (g *Game) GetBotMove() (string, error) { return "", ErrNoValidMoves } - moveStr := chess.AlgebraicNotation{}.Encode(g.Board.Position(), move) + moveStr := chess.UCINotation{}.Encode(g.Board.Position(), move) if err := g.Board.Move(move); err != nil { return "", ErrInvalidMove diff --git a/packages/web/package.json b/packages/web/package.json index ef53d5f..8ee3f40 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -6,8 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint .", - "typecheck": "tsc --noEmit" + "lint": "eslint src/", + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test" }, "dependencies": { "clsx": "^2.1.1", @@ -19,6 +20,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", + "@playwright/test": "^1.61.0", "@tailwindcss/postcss": "^4.3.1", "@types/node": "^22.15.3", "@types/react": "19.2.17", @@ -27,6 +29,7 @@ "@typescript-eslint/parser": "^8.32.0", "eslint": "^9.27.0", "eslint-config-next": "^15.3.3", + "playwright": "^1.61.0", "tailwindcss": "^4.3.1", "typescript": "^5.8.3" } diff --git a/packages/web/playwright.config.ts b/packages/web/playwright.config.ts new file mode 100644 index 0000000..427ca82 --- /dev/null +++ b/packages/web/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: false, + retries: 0, + workers: 1, + reporter: [["list"]], + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + headless: true, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/packages/web/tests/chess.spec.ts b/packages/web/tests/chess.spec.ts new file mode 100644 index 0000000..7a5064a --- /dev/null +++ b/packages/web/tests/chess.spec.ts @@ -0,0 +1,113 @@ +import { test, expect, Page } from "@playwright/test"; + +const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"]; +const RANKS = ["8", "7", "6", "5", "4", "3", "2", "1"]; + +async function clickSquare(page: Page, file: string, rank: string) { + const fi = FILES.indexOf(file); + const ri = RANKS.indexOf(rank); + const index = ri * 8 + fi; + await page.locator(".board > .rank > .square").nth(index).click(); +} + +async function getMoveTokens(page: Page): Promise { + const text = await page.locator(".move-list").textContent(); + return text!.replace(/^Moves:\s*/, "").split(/\s+/).filter(Boolean); +} + +test("bot responds to multiple player moves", async ({ page }) => { + const consoleLogs: string[] = []; + const consoleErrors: string[] = []; + page.on("console", (msg) => { + consoleLogs.push(`[${msg.type()}] ${msg.text()}`); + if (msg.type() === "error") consoleErrors.push(msg.text()); + }); + page.on("pageerror", (err) => { + consoleErrors.push(`PAGEERROR: ${err.message}\n${err.stack ?? ""}`); + }); + + await page.goto("http://localhost:3000/chess"); + await expect(page.getByRole("heading", { name: "Chess" })).toBeVisible(); + + await page.getByRole("button", { name: "Start Game" }).click(); + + await expect(page.locator(".chessboard")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("(Live)")).toBeVisible({ timeout: 10000 }); + await expect(page.locator(".bot-status")).toHaveText("Your turn", { timeout: 10000 }); + + console.log("--- Initial state ---"); + console.log("Status: Your turn"); + + // Helper: wait for bot to play (turn flips back to "Your turn"). + // We don't strictly require "Bot is thinking..." to appear — the bot can be fast enough + // that both updates arrive in one render cycle. We just wait for the move count to grow. + async function waitForBotMove(expectedCount: number) { + await expect.poll( + async () => { + const t = await getMoveTokens(page); + return t.length; + }, + { timeout: 15000, intervals: [100] } + ).toBe(expectedCount); + } + + // Move 1: d2-d4. + console.log("--- Move 1: d2-d4 ---"); + await clickSquare(page, "d", "2"); + await expect( + page.locator(".board > .rank > .square").nth(6 * 8 + 3) // d2 itself + ).toHaveClass(/selected/); + // d3 and d4 should be possible. + const d3 = page.locator(".board > .rank > .square").nth(5 * 8 + 3); // d3 + const d4 = page.locator(".board > .rank > .square").nth(4 * 8 + 3); // d4 + await expect(d3).toHaveClass(/possible/); + await expect(d4).toHaveClass(/possible/); + await clickSquare(page, "d", "4"); + await waitForBotMove(2); + let tokens = await getMoveTokens(page); + console.log("Moves after 1.d4 + bot:", tokens); + expect(tokens.length).toBe(2); + expect(tokens[0]).toBe("d2d4"); + + // Move 2: e2-e4. + console.log("--- Move 2: e2-e4 ---"); + await clickSquare(page, "e", "2"); + await expect( + page.locator(".board > .rank > .square").nth(6 * 8 + 4) + ).toHaveClass(/selected/); + await clickSquare(page, "e", "4"); + await waitForBotMove(4); + tokens = await getMoveTokens(page); + console.log("Moves after 2.e4 + bot:", tokens); + expect(tokens.length).toBe(4); + expect(tokens[0]).toBe("d2d4"); + expect(tokens[2]).toBe("e2e4"); + + // Move 3: g1-f3 (knight). + console.log("--- Move 3: Nf3 ---"); + await clickSquare(page, "g", "1"); + await expect( + page.locator(".board > .rank > .square").nth(7 * 8 + 6) // g1 + ).toHaveClass(/selected/); + // f3 should be highlighted. + await expect( + page.locator(".board > .rank > .square").nth(5 * 8 + 5) // f3 + ).toHaveClass(/possible/); + await clickSquare(page, "f", "3"); + await waitForBotMove(6); + tokens = await getMoveTokens(page); + console.log("Moves after 3.Nf3 + bot:", tokens); + expect(tokens.length).toBe(6); + expect(tokens[4]).toBe("g1f3"); + + // Take a screenshot of the final state. + await page.screenshot({ path: "test-results/chess-after-3-moves.png", fullPage: true }); + + // Print collected browser logs. + console.log("\n--- Browser console logs ---"); + for (const l of consoleLogs) console.log(l); + console.log("\n--- Browser errors ---"); + for (const e of consoleErrors) console.log(e); + + expect(consoleErrors).toEqual([]); +}); diff --git a/packages/web/tests/pieces.spec.ts b/packages/web/tests/pieces.spec.ts new file mode 100644 index 0000000..6415b1b --- /dev/null +++ b/packages/web/tests/pieces.spec.ts @@ -0,0 +1,194 @@ +import { test, expect, Page } from "@playwright/test"; + +const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"]; +const RANKS = ["8", "7", "6", "5", "4", "3", "2", "1"]; + +function squareIndex(file: string, rank: string): number { + return RANKS.indexOf(rank) * 8 + FILES.indexOf(file); +} + +async function clickSquare(page: Page, file: string, rank: string) { + await page + .locator(".board > .rank > .square") + .nth(squareIndex(file, rank)) + .click({ position: { x: 20, y: 20 } }); +} + +async function getMoveTokens(page: Page): Promise { + const text = (await page.locator(".move-list").textContent()) ?? ""; + return text.replace(/^Moves:\s*/, "").split(/\s+/).filter(Boolean); +} + +async function waitForPlayerTurn(page: Page, expectedCount: number) { + await expect + .poll(async () => (await getMoveTokens(page)).length, { + timeout: 15000, + intervals: [100], + }) + .toBe(expectedCount); + await page.waitForTimeout(200); +} + +async function getDestsFromBoard(page: Page): Promise { + const dests: string[] = []; + const allSquares = page.locator(".board > .rank > .square"); + const total = await allSquares.count(); + for (let j = 0; j < total; j++) { + const isPossible = await allSquares + .nth(j) + .evaluate((el) => el.classList.contains("possible")); + if (isPossible) { + const ri = Math.floor(j / 8); + const fi = j % 8; + dests.push(`${FILES[fi]}${RANKS[ri]}`); + } + } + return dests.sort(); +} + +async function startGame(page: Page) { + await page.goto("http://localhost:3000/chess"); + await page.getByRole("button", { name: "Start Game" }).click(); + await expect(page.locator(".chessboard")).toBeVisible({ timeout: 10000 }); + await expect(page.locator(".bot-status")).toHaveText("Your turn", { timeout: 10000 }); +} + +test.describe("Chess piece interaction", () => { + test("PAWN: double push shows two destinations", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.on("console", (m) => { + if (m.type() === "error") errors.push(m.text()); + }); + await startGame(page); + await clickSquare(page, "d", "2"); + const dests = await getDestsFromBoard(page); + expect(dests).toEqual(["d3", "d4"]); + expect(errors).toEqual([]); + }); + + test("KNIGHT: g1 has two L-move destinations", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.on("console", (m) => { + if (m.type() === "error") errors.push(m.text()); + }); + await startGame(page); + await clickSquare(page, "g", "1"); + const dests = await getDestsFromBoard(page); + expect(dests).toContain("f3"); + expect(dests).toContain("h3"); + expect(errors).toEqual([]); + }); + + test("BISHOP: c1 has long diagonal destinations after d2-d4", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.on("console", (m) => { + if (m.type() === "error") errors.push(m.text()); + }); + await startGame(page); + // Play 1. d4 to clear d2, opening the bishop's diagonal. + await clickSquare(page, "d", "2"); + await clickSquare(page, "d", "4"); + await waitForPlayerTurn(page, 2); + // Now bishop c1 should have: d2, e3, f4, g5, h6. + await clickSquare(page, "c", "1"); + const dests = await getDestsFromBoard(page); + console.log("Bishop c1 destinations (after d2-d4):", dests); + expect(dests).toContain("d2"); + expect(dests).toContain("e3"); + expect(dests).toContain("f4"); + expect(dests).toContain("g5"); + expect(dests).toContain("h6"); + expect(errors).toEqual([]); + }); + + test("ROOK: a1 has only rank-1 destinations (a-file blocked by pawn)", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.on("console", (m) => { + if (m.type() === "error") errors.push(m.text()); + }); + await startGame(page); + // Rook a1 is blocked along a-file (a2 has own pawn) and along rank 1 (b1 has own knight). + await clickSquare(page, "a", "1"); + const dests = await getDestsFromBoard(page); + expect(dests).toEqual([]); + expect(errors).toEqual([]); + }); + + test("QUEEN (user's bug): d1 destinations after e2 pawn moves", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.on("console", (m) => { + if (m.type() === "error") errors.push(m.text()); + }); + await startGame(page); + + // Play 1. d4 + wait for bot, 2. e4 + wait for bot. + await clickSquare(page, "d", "2"); + await clickSquare(page, "d", "4"); + await waitForPlayerTurn(page, 2); + await clickSquare(page, "e", "2"); + await clickSquare(page, "e", "4"); + await waitForPlayerTurn(page, 4); + + // Queen d1: d-file opens (d2, d3), e2 diagonal opens (e2). + await clickSquare(page, "d", "1"); + const dests = await getDestsFromBoard(page); + console.log("Queen d1 destinations:", dests); + // d2 and d3 should always be reachable (d2 empty, d3 empty, d4 has own pawn). + expect(dests).toContain("d2"); + expect(dests).toContain("d3"); + // e2 should be reachable (e2 pawn moved to e4). + expect(dests).toContain("e2"); + expect(errors).toEqual([]); + }); + + test("KING: e1 has at most 5 destinations (D, E, F squares)", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.on("console", (m) => { + if (m.type() === "error") errors.push(m.text()); + }); + await startGame(page); + await clickSquare(page, "e", "1"); + const dests = await getDestsFromBoard(page); + console.log("King e1 destinations:", dests); + // King is surrounded by own pieces except d1 (own queen) and ... actually all are own. + // e1 neighbors: d1 (queen), d2 (pawn), e2 (pawn), f1 (bishop), f2 (pawn). + // All are own pieces, so king has NO moves from initial position. + expect(dests).toEqual([]); + expect(errors).toEqual([]); + }); + + test("clicking a piece behind a pawn: doesn't select the pawn (e2)", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.on("console", (m) => { + if (m.type() === "error") errors.push(m.text()); + }); + await startGame(page); + // Play 1. e4 to clear e2. + await clickSquare(page, "e", "2"); + await clickSquare(page, "e", "4"); + await waitForPlayerTurn(page, 2); + + // Now click queen (d1) — should show queen destinations, not pawn destinations. + await clickSquare(page, "d", "1"); + const queenDests = await getDestsFromBoard(page); + console.log("Queen d1 destinations (after e4):", queenDests); + // Queen can reach e2 (diagonal opens after pawn moved). + expect(queenDests).toContain("e2"); + + // Now click e3. e3 is empty in this position. + // e3 is on the d1-h5 diagonal. Queen can reach e3? d1-e2-f3 (e2 empty, f3 empty) — so queen + // can reach e2 and f3, but e3 is not on the queen's path from d1. + // Actually d1 to e3: that's not a queen move (queen moves straight or diagonal). + // d1-e3 is not a diagonal. d1 is rank 1, e3 is rank 3, same file? No, d and e are different files. + // So e3 is not a queen destination. + expect(queenDests).not.toContain("e3"); + expect(errors).toEqual([]); + }); +}); diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index b8cdcc7..577db31 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -19,5 +19,5 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "tests", "playwright.config.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a62495..26f18f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,7 +17,7 @@ importers: version: 2.1.1 next: specifier: ^15.3.3 - version: 15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 15.5.19(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -34,6 +34,9 @@ importers: '@eslint/eslintrc': specifier: ^3.3.1 version: 3.3.5 + '@playwright/test': + specifier: ^1.61.0 + version: 1.61.0 '@tailwindcss/postcss': specifier: ^4.3.1 version: 4.3.1 @@ -58,6 +61,9 @@ importers: eslint-config-next: specifier: ^15.3.3 version: 15.5.19(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + playwright: + specifier: ^1.61.0 + version: 1.61.0 tailwindcss: specifier: ^4.3.1 version: 4.3.1 @@ -171,89 +177,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -323,24 +345,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.5.19': resolution: {integrity: sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.5.19': resolution: {integrity: sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.5.19': resolution: {integrity: sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.5.19': resolution: {integrity: sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==} @@ -370,6 +396,11 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@playwright/test@1.61.0': + resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} + engines: {node: '>=18'} + hasBin: true + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -417,24 +448,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.1': resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.1': resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.1': resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.1': resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} @@ -588,51 +623,61 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -1065,6 +1110,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1358,24 +1408,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1550,6 +1604,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + playwright-core@1.61.0: + resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.0: + resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2093,6 +2157,10 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@playwright/test@1.61.0': + dependencies: + playwright: 1.61.0 + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.16.1': {} @@ -2925,6 +2993,9 @@ snapshots: dependencies: is-callable: 1.2.7 + fsevents@2.3.2: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.2.0: @@ -3293,7 +3364,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - next@15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@15.5.19(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 15.5.19 '@swc/helpers': 0.5.15 @@ -3311,6 +3382,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.19 '@next/swc-win32-arm64-msvc': 15.5.19 '@next/swc-win32-x64-msvc': 15.5.19 + '@playwright/test': 1.61.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -3404,6 +3476,14 @@ snapshots: picomatch@4.0.4: {} + playwright-core@1.61.0: {} + + playwright@1.61.0: + dependencies: + playwright-core: 1.61.0 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss@8.4.31: