mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:07:04 +01:00
Archive bucket_catch and poker-stakes to testsAndMisc-archive
Two self-contained apps, neither referenced by any other code, systemd unit or symlink -- which is why they go first in the restructure. Full history preserved via git filter-repo and merged into github.com/kuhyx/testsAndMisc-archive at their original repo-relative paths, matching how C/, CPP/, horatio/ and the rest already sit there. Archived commit counts match the monorepo exactly: bucket_catch 10, poker-stakes 2. poker-stakes-ci.yml goes with the app it builds, so no workflow is left pointing at a missing path. bucket_catch had no workflow. Verified before deleting: file counts match (64 and 65), diff -r against the working tree is clean, and the GitHub API lists both units in the archive. Depth violations drop 556 -> 451. billsplit is deliberately NOT part of this commit: it is coupled to python_pkg/billsplit_coverage and an app_icons registry entry that the 100% coverage gate requires be deleted in the same commit as the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
This commit is contained in:
parent
3cd42a4451
commit
ea281134b6
31
.github/workflows/poker-stakes-ci.yml
vendored
31
.github/workflows/poker-stakes-ci.yml
vendored
@ -1,31 +0,0 @@
|
||||
name: poker-stakes CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "poker-stakes/**"
|
||||
- ".github/workflows/poker-stakes-ci.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "poker-stakes/**"
|
||||
- ".github/workflows/poker-stakes-ci.yml"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: poker-stakes
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: poker-stakes/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run check
|
||||
- run: npm run build
|
||||
27
AGENTS.md
27
AGENTS.md
@ -4,20 +4,19 @@ Mixed-language monorepo: Python packages, Bash automation, and several apps.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
| Path | Description |
|
||||
| --------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `python_pkg/` | Python packages — every `.py` in the repo belongs here or in an allowlisted dir |
|
||||
| `meta/` | Repo-wide tooling: `pyproject.toml`, `requirements.txt`, `run.sh`, `lint_python.sh`, `.fvmrc`, `scripts/` |
|
||||
| `linux_configuration/` | Arch setup, i3 config, system maintenance, and the tracked git hooks |
|
||||
| `phone_focus_mode/` | GPS-based Android focus enforcer (Bash, ADB, Magisk) |
|
||||
| `billsplit/` | Receipt bill-splitting Flutter app (Dart) |
|
||||
| `reverse_survivors/` | React/Vite game — you play the horde (TypeScript) |
|
||||
| `poker-stakes/` | Poker stakes trainer (TypeScript, Vite) |
|
||||
| `kcd2_dice_solver/` | KCD2 dice solver |
|
||||
| `focus_owner/` | Android Device-Owner focus enforcer (Kotlin) |
|
||||
| `bucket_catch/`, `bottles/` | Small standalone projects |
|
||||
| `docs/` | Reference docs; `docs/superpowers/` holds AI workflow artifacts |
|
||||
| `third_party/` | Vendored upstream skills/agents |
|
||||
| Path | Description |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `python_pkg/` | Python packages — every `.py` in the repo belongs here or in an allowlisted dir |
|
||||
| `meta/` | Repo-wide tooling: `pyproject.toml`, `requirements.txt`, `run.sh`, `lint_python.sh`, `.fvmrc`, `scripts/` |
|
||||
| `linux_configuration/` | Arch setup, i3 config, system maintenance, and the tracked git hooks |
|
||||
| `phone_focus_mode/` | GPS-based Android focus enforcer (Bash, ADB, Magisk) |
|
||||
| `billsplit/` | Receipt bill-splitting Flutter app (Dart) |
|
||||
| `reverse_survivors/` | React/Vite game — you play the horde (TypeScript) |
|
||||
| `kcd2_dice_solver/` | KCD2 dice solver |
|
||||
| `focus_owner/` | Android Device-Owner focus enforcer (Kotlin) |
|
||||
| `bottles/` | Small standalone project |
|
||||
| `docs/` | Reference docs; `docs/superpowers/` holds AI workflow artifacts |
|
||||
| `third_party/` | Vendored upstream skills/agents |
|
||||
|
||||
Root `pyproject.toml`, `requirements.txt`, `run.sh`, `lint_python.sh` and
|
||||
`.fvmrc` are **symlinks into `meta/`** — edit the files in `meta/`, not the
|
||||
|
||||
6
bucket_catch/.gitignore
vendored
6
bucket_catch/.gitignore
vendored
@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
packages/*/node_modules/
|
||||
packages/frontend/dist/
|
||||
packages/backend/dist/
|
||||
packages/backend/uploads/*
|
||||
!packages/backend/uploads/.gitkeep
|
||||
@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "bucket-catch",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --parallel -r dev",
|
||||
"build": "pnpm -r build",
|
||||
"lint": "pnpm -r lint"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22",
|
||||
"pnpm": ">=10"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'coverage'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }],
|
||||
// NestJS modules/controllers are intentionally empty classes decorated with @Module/@Controller
|
||||
'@typescript-eslint/no-extraneous-class': ['error', { allowWithDecorator: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.js', '*.mjs', '*.cjs'],
|
||||
...tseslint.configs.disableTypeChecked,
|
||||
},
|
||||
);
|
||||
@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "bucket-catch-backend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/main.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.0.0",
|
||||
"eslint": "^10.6.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"typescript-eslint": "^8.62.0"
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { FilesModule } from "./files/files.module";
|
||||
import { HealthController } from "./health.controller";
|
||||
|
||||
@Module({
|
||||
imports: [FilesModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
@ -1,38 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { extname } from "path";
|
||||
import { FilesService, UploadResult } from "./files.service";
|
||||
|
||||
@Controller("files")
|
||||
export class FilesController {
|
||||
constructor(private readonly filesService: FilesService) {}
|
||||
|
||||
@Post("upload")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
storage: diskStorage({
|
||||
destination: "./uploads",
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now().toString()}-${Math.round(Math.random() * 1e9).toString()}`;
|
||||
cb(null, uniqueSuffix + extname(file.originalname));
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB
|
||||
}),
|
||||
)
|
||||
uploadFile(
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
): UploadResult {
|
||||
if (!file) {
|
||||
throw new BadRequestException("No file provided");
|
||||
}
|
||||
return this.filesService.processUpload(file);
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { FilesController } from "./files.controller";
|
||||
import { FilesService } from "./files.service";
|
||||
|
||||
@Module({
|
||||
controllers: [FilesController],
|
||||
providers: [FilesService],
|
||||
})
|
||||
export class FilesModule {}
|
||||
@ -1,20 +0,0 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
export interface UploadResult {
|
||||
filename: string;
|
||||
originalname: string;
|
||||
size: number;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
processUpload(file: Express.Multer.File): UploadResult {
|
||||
return {
|
||||
filename: file.filename,
|
||||
originalname: file.originalname,
|
||||
size: file.size,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check(): { status: string } {
|
||||
return { status: "ok" };
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors({
|
||||
origin: ["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
methods: ["GET", "POST", "OPTIONS"],
|
||||
});
|
||||
await app.listen(3000);
|
||||
console.log("Backend running on http://localhost:3000");
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@ -1,23 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
24
bucket_catch/packages/frontend/.gitignore
vendored
24
bucket_catch/packages/frontend/.gitignore
vendored
@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@ -1,29 +0,0 @@
|
||||
import js from '@eslint/js';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'coverage'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': reactHooksPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...reactHooksPlugin.configs.recommended.rules,
|
||||
'@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.js', '*.mjs', '*.cjs'],
|
||||
...tseslint.configs.disableTypeChecked,
|
||||
},
|
||||
);
|
||||
@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bucket Catch</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "bucket-catch-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "tsc --noEmit && eslint src",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
@ -1,157 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type { FileGameResult, PuzzleGameResult, TransferMode } from "./types";
|
||||
import App from "./App";
|
||||
|
||||
// ---- Minimal component mocks ------------------------------------------------
|
||||
|
||||
vi.mock("./components/DropZone", () => ({
|
||||
DropZone: ({
|
||||
onFiles,
|
||||
onPuzzle,
|
||||
}: {
|
||||
onFiles: (f: File[]) => void;
|
||||
onPuzzle: (f: File, g: number) => void;
|
||||
}) => (
|
||||
<div data-testid="dropzone">
|
||||
<button
|
||||
onClick={() => { onFiles([new File(["x"], "a.txt")]); }}
|
||||
>
|
||||
drop-files
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onPuzzle(new File(["x"], "img.png", { type: "image/png" }), 3);
|
||||
}}
|
||||
>
|
||||
drop-puzzle
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./components/ModeSelect", () => ({
|
||||
ModeSelect: ({
|
||||
onStart,
|
||||
}: {
|
||||
onStart: (m: TransferMode, g?: number) => void;
|
||||
}) => (
|
||||
<div data-testid="modeselect">
|
||||
<button onClick={() => { onStart("download"); }}>mode-download</button>
|
||||
<button onClick={() => { onStart("upload"); }}>mode-upload</button>
|
||||
<button onClick={() => { onStart("puzzle", 3); }}>mode-puzzle</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./components/GameCanvas", () => ({
|
||||
GameCanvas: ({ onDone }: { onDone: (r: FileGameResult) => void }) => (
|
||||
<button
|
||||
onClick={() => { onDone({ caught: [], missed: [] }); }}
|
||||
>
|
||||
game-done
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./components/PuzzleCanvas", () => ({
|
||||
PuzzleCanvas: ({
|
||||
onDone,
|
||||
}: {
|
||||
onDone: (r: PuzzleGameResult) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
onDone({ caughtPieces: [], missedPieces: [], gridSize: 3 });
|
||||
}}
|
||||
>
|
||||
puzzle-done
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./components/ScoreScreen", () => ({
|
||||
ScoreScreen: ({ onRestart }: { onRestart: () => void }) => (
|
||||
<button onClick={onRestart}>score-restart</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./components/PuzzleResult", () => ({
|
||||
PuzzleResult: ({ onRestart }: { onRestart: () => void }) => (
|
||||
<button onClick={onRestart}>puzzle-restart</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// ---- Tests ------------------------------------------------------------------
|
||||
|
||||
describe("App", () => {
|
||||
it("initially renders DropZone (drop phase)", () => {
|
||||
render(<App />);
|
||||
expect(screen.getByTestId("dropzone")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handleFiles transitions from drop to mode phase", () => {
|
||||
render(<App />);
|
||||
fireEvent.click(screen.getByText("drop-files"));
|
||||
expect(screen.getByTestId("modeselect")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handlePuzzleDirect skips mode phase and goes straight to PuzzleCanvas", () => {
|
||||
render(<App />);
|
||||
fireEvent.click(screen.getByText("drop-puzzle"));
|
||||
expect(screen.getByText("puzzle-done")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handleStart('download') transitions from mode to GameCanvas", () => {
|
||||
render(<App />);
|
||||
fireEvent.click(screen.getByText("drop-files"));
|
||||
fireEvent.click(screen.getByText("mode-download"));
|
||||
expect(screen.getByText("game-done")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handleStart('upload') transitions from mode to GameCanvas", () => {
|
||||
render(<App />);
|
||||
fireEvent.click(screen.getByText("drop-files"));
|
||||
fireEvent.click(screen.getByText("mode-upload"));
|
||||
expect(screen.getByText("game-done")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handleStart('puzzle', gridSize) sets puzzleGridSize and shows PuzzleCanvas", () => {
|
||||
render(<App />);
|
||||
fireEvent.click(screen.getByText("drop-files"));
|
||||
fireEvent.click(screen.getByText("mode-puzzle"));
|
||||
expect(screen.getByText("puzzle-done")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handleFileDone transitions to done phase showing ScoreScreen", () => {
|
||||
render(<App />);
|
||||
fireEvent.click(screen.getByText("drop-files"));
|
||||
fireEvent.click(screen.getByText("mode-download"));
|
||||
fireEvent.click(screen.getByText("game-done"));
|
||||
expect(screen.getByText("score-restart")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handlePuzzleDone transitions to done phase showing PuzzleResult", () => {
|
||||
render(<App />);
|
||||
fireEvent.click(screen.getByText("drop-puzzle"));
|
||||
fireEvent.click(screen.getByText("puzzle-done"));
|
||||
expect(screen.getByText("puzzle-restart")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handleRestart from ScoreScreen resets to drop phase", () => {
|
||||
render(<App />);
|
||||
fireEvent.click(screen.getByText("drop-files"));
|
||||
fireEvent.click(screen.getByText("mode-download"));
|
||||
fireEvent.click(screen.getByText("game-done"));
|
||||
fireEvent.click(screen.getByText("score-restart"));
|
||||
expect(screen.getByTestId("dropzone")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handleRestart from PuzzleResult resets to drop phase", () => {
|
||||
render(<App />);
|
||||
fireEvent.click(screen.getByText("drop-puzzle"));
|
||||
fireEvent.click(screen.getByText("puzzle-done"));
|
||||
fireEvent.click(screen.getByText("puzzle-restart"));
|
||||
expect(screen.getByTestId("dropzone")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -1,86 +0,0 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import type {
|
||||
GamePhase,
|
||||
FileGameResult,
|
||||
PuzzleGameResult,
|
||||
TransferMode,
|
||||
} from "./types";
|
||||
import { DropZone } from "./components/DropZone";
|
||||
import { ModeSelect } from "./components/ModeSelect";
|
||||
import { GameCanvas } from "./components/GameCanvas";
|
||||
import { PuzzleCanvas } from "./components/PuzzleCanvas";
|
||||
import { ScoreScreen } from "./components/ScoreScreen";
|
||||
import { PuzzleResult } from "./components/PuzzleResult";
|
||||
|
||||
export default function App(): React.ReactElement {
|
||||
const [phase, setPhase] = useState<GamePhase>({ kind: "drop" });
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [mode, setMode] = useState<TransferMode>("download");
|
||||
const [puzzleGridSize, setPuzzleGridSize] = useState(4);
|
||||
|
||||
const handleFiles = useCallback((incoming: File[]) => {
|
||||
setFiles(incoming);
|
||||
setPhase({ kind: "mode" });
|
||||
}, []);
|
||||
|
||||
const handlePuzzleDirect = useCallback(
|
||||
(imageFile: File, gridSize: number) => {
|
||||
setFiles([imageFile]);
|
||||
setMode("puzzle");
|
||||
setPuzzleGridSize(gridSize);
|
||||
setPhase({ kind: "playing" });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleStart = useCallback((selected: TransferMode, gridSize = 4) => {
|
||||
setMode(selected);
|
||||
if (selected === "puzzle") setPuzzleGridSize(gridSize);
|
||||
setPhase({ kind: "playing" });
|
||||
}, []);
|
||||
|
||||
const handleFileDone = useCallback((value: FileGameResult) => {
|
||||
setPhase({ kind: "done", result: { kind: "file", value } });
|
||||
}, []);
|
||||
|
||||
const handlePuzzleDone = useCallback((value: PuzzleGameResult) => {
|
||||
setPhase({ kind: "done", result: { kind: "puzzle", value } });
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
setFiles([]);
|
||||
setPhase({ kind: "drop" });
|
||||
}, []);
|
||||
|
||||
if (phase.kind === "drop") {
|
||||
return <DropZone onFiles={handleFiles} onPuzzle={handlePuzzleDirect} />;
|
||||
}
|
||||
if (phase.kind === "mode") {
|
||||
return <ModeSelect files={files} onStart={handleStart} />;
|
||||
}
|
||||
if (phase.kind === "playing") {
|
||||
if (mode === "puzzle") {
|
||||
return (
|
||||
<PuzzleCanvas
|
||||
imageFile={files[0]}
|
||||
gridSize={puzzleGridSize}
|
||||
onDone={handlePuzzleDone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <GameCanvas files={files} onDone={handleFileDone} />;
|
||||
}
|
||||
// done phase: the phase carries its result, so both arms are non-null.
|
||||
if (phase.result.kind === "puzzle") {
|
||||
return (
|
||||
<PuzzleResult result={phase.result.value} onRestart={handleRestart} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ScoreScreen
|
||||
result={phase.result.value}
|
||||
mode={mode}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,169 +0,0 @@
|
||||
.zone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100dvh;
|
||||
background: var(--bg-gradient);
|
||||
transition: background 0.2s;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4) var(--space-3);
|
||||
}
|
||||
|
||||
.zone.dragging {
|
||||
background: var(--bg-drag-gradient);
|
||||
outline: 4px dashed var(--color-indigo);
|
||||
outline-offset: -12px;
|
||||
}
|
||||
|
||||
.inner {
|
||||
text-align: center;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 52px;
|
||||
line-height: 1;
|
||||
/* Dimmed relative to the .subtitle text below it (rule 28). */
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--muted-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 1rem;
|
||||
color: var(--color-indigo-light);
|
||||
font-weight: 600;
|
||||
background: rgba(var(--color-indigo-rgb), 0.15);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.browseBtn {
|
||||
padding: 12px var(--space-4);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--gradient-accent);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.browseBtn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: var(--muted-border);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.puzzleSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gridRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gridLabel {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.preset {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
/* muted-text, not muted-border — the border-only value measured under
|
||||
3:1 against the page background (rule 7). */
|
||||
border: 1.5px solid var(--muted-text);
|
||||
color: var(--muted-text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.preset:hover {
|
||||
border-color: var(--color-indigo);
|
||||
color: var(--color-indigo-lighter);
|
||||
}
|
||||
|
||||
.presetActive {
|
||||
border-color: var(--color-pink);
|
||||
color: var(--color-pink-light);
|
||||
background: rgba(var(--color-pink-rgb), 0.1);
|
||||
}
|
||||
|
||||
.gridInput {
|
||||
width: 56px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
border: 1.5px solid var(--muted-border);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.gridInput:focus {
|
||||
border-color: var(--color-indigo);
|
||||
}
|
||||
|
||||
.gridEq {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.puzzleBtn {
|
||||
padding: 12px var(--space-4);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
border: 2px solid var(--color-pink);
|
||||
color: var(--color-pink-light);
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.puzzleBtn:hover {
|
||||
background: rgba(var(--color-pink-rgb), 0.12);
|
||||
border-color: var(--color-pink-lighter);
|
||||
color: var(--color-pink-lighter);
|
||||
}
|
||||
@ -1,229 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { DropZone } from "./DropZone";
|
||||
import { must } from "../test/domQueries";
|
||||
|
||||
const makeFile = (name: string, type = "text/plain"): File =>
|
||||
new File(["x"], name, { type });
|
||||
|
||||
describe("DropZone", () => {
|
||||
it("renders title and subtitle", () => {
|
||||
render(<DropZone onFiles={() => undefined} onPuzzle={() => undefined} />);
|
||||
expect(screen.getByText("Bucket Catch")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Drop your files here/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows initial grid equation", () => {
|
||||
render(<DropZone onFiles={() => undefined} onPuzzle={() => undefined} />);
|
||||
expect(screen.getByText("4×4 = 16 pieces")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dragOver adds dragging class; dragLeave removes it", () => {
|
||||
const { container } = render(
|
||||
<DropZone onFiles={() => undefined} onPuzzle={() => undefined} />,
|
||||
);
|
||||
const zone = must(container.firstChild as Element | null, "the drop zone");
|
||||
expect(zone.className).not.toContain("dragging");
|
||||
fireEvent.dragOver(zone);
|
||||
expect(zone.className).toContain("dragging");
|
||||
fireEvent.dragLeave(zone);
|
||||
expect(zone.className).not.toContain("dragging");
|
||||
});
|
||||
|
||||
it("drop with files calls onFiles and shows count (plural)", () => {
|
||||
const onFiles = vi.fn();
|
||||
const { container } = render(
|
||||
<DropZone onFiles={onFiles} onPuzzle={() => undefined} />,
|
||||
);
|
||||
const zone = must(container.firstChild as Element | null, "the drop zone");
|
||||
const f1 = makeFile("a.txt");
|
||||
const f2 = makeFile("b.txt");
|
||||
fireEvent.drop(zone, { dataTransfer: { files: [f1, f2] } });
|
||||
expect(onFiles).toHaveBeenCalledWith([f1, f2]);
|
||||
expect(screen.getByText("2 files ready")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drop with files shows singular count for 1 file", () => {
|
||||
const { container } = render(
|
||||
<DropZone onFiles={() => undefined} onPuzzle={() => undefined} />,
|
||||
);
|
||||
const zone = must(container.firstChild as Element | null, "the drop zone");
|
||||
fireEvent.drop(zone, { dataTransfer: { files: [makeFile("a.txt")] } });
|
||||
expect(screen.getByText("1 file ready")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drop with empty files does not call onFiles", () => {
|
||||
const onFiles = vi.fn();
|
||||
const { container } = render(
|
||||
<DropZone onFiles={onFiles} onPuzzle={() => undefined} />,
|
||||
);
|
||||
const zone = must(container.firstChild as Element | null, "the drop zone");
|
||||
fireEvent.drop(zone, { dataTransfer: { files: [] } });
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("file input change with files calls onFiles", () => {
|
||||
const onFiles = vi.fn();
|
||||
const { container } = render(
|
||||
<DropZone onFiles={onFiles} onPuzzle={() => undefined} />,
|
||||
);
|
||||
const fileInput = must(
|
||||
container.querySelector("input[type='file'][multiple]"),
|
||||
"input[type='file'][multiple]",
|
||||
);
|
||||
const file = makeFile("c.txt");
|
||||
Object.defineProperty(fileInput, "files", {
|
||||
value: [file],
|
||||
configurable: true,
|
||||
});
|
||||
fireEvent.change(fileInput);
|
||||
expect(onFiles).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
|
||||
it("file input change with empty files does not call onFiles", () => {
|
||||
const onFiles = vi.fn();
|
||||
const { container } = render(
|
||||
<DropZone onFiles={onFiles} onPuzzle={() => undefined} />,
|
||||
);
|
||||
const fileInput = must(
|
||||
container.querySelector("input[type='file'][multiple]"),
|
||||
"input[type='file'][multiple]",
|
||||
);
|
||||
Object.defineProperty(fileInput, "files", {
|
||||
value: [],
|
||||
configurable: true,
|
||||
});
|
||||
fireEvent.change(fileInput);
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("file input change with null files (nullish-coalesce branch) does not call onFiles", () => {
|
||||
const onFiles = vi.fn();
|
||||
const { container } = render(
|
||||
<DropZone onFiles={onFiles} onPuzzle={() => undefined} />,
|
||||
);
|
||||
const fileInput = must(
|
||||
container.querySelector("input[type='file'][multiple]"),
|
||||
"input[type='file'][multiple]",
|
||||
);
|
||||
Object.defineProperty(fileInput, "files", {
|
||||
value: null,
|
||||
configurable: true,
|
||||
});
|
||||
fireEvent.change(fileInput);
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("puzzle input change with file calls onPuzzle with current gridSize", () => {
|
||||
const onPuzzle = vi.fn();
|
||||
const { container } = render(
|
||||
<DropZone onFiles={() => undefined} onPuzzle={onPuzzle} />,
|
||||
);
|
||||
const puzzleInput = must(
|
||||
container.querySelector("input[accept='image/*']"),
|
||||
"input[accept='image/*']",
|
||||
);
|
||||
const imgFile = makeFile("photo.png", "image/png");
|
||||
Object.defineProperty(puzzleInput, "files", {
|
||||
value: [imgFile],
|
||||
configurable: true,
|
||||
});
|
||||
fireEvent.change(puzzleInput);
|
||||
expect(onPuzzle).toHaveBeenCalledWith(imgFile, 4); // default gridSize = 4
|
||||
});
|
||||
|
||||
it("puzzle input change with null files does not call onPuzzle (optional-chain null branch)", () => {
|
||||
const onPuzzle = vi.fn();
|
||||
const { container } = render(
|
||||
<DropZone onFiles={() => undefined} onPuzzle={onPuzzle} />,
|
||||
);
|
||||
const puzzleInput = must(
|
||||
container.querySelector("input[accept='image/*']"),
|
||||
"input[accept='image/*']",
|
||||
);
|
||||
Object.defineProperty(puzzleInput, "files", {
|
||||
value: null,
|
||||
configurable: true,
|
||||
});
|
||||
fireEvent.change(puzzleInput);
|
||||
expect(onPuzzle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("puzzle input change with empty files does not call onPuzzle (no first element)", () => {
|
||||
const onPuzzle = vi.fn();
|
||||
const { container } = render(
|
||||
<DropZone onFiles={() => undefined} onPuzzle={onPuzzle} />,
|
||||
);
|
||||
const puzzleInput = must(
|
||||
container.querySelector("input[accept='image/*']"),
|
||||
"input[accept='image/*']",
|
||||
);
|
||||
Object.defineProperty(puzzleInput, "files", {
|
||||
value: [],
|
||||
configurable: true,
|
||||
});
|
||||
fireEvent.change(puzzleInput);
|
||||
expect(onPuzzle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Play Puzzle Mode button triggers click on hidden puzzle input", () => {
|
||||
const { container } = render(
|
||||
<DropZone onFiles={() => undefined} onPuzzle={() => undefined} />,
|
||||
);
|
||||
const puzzleInput = must(
|
||||
container.querySelector("input[accept='image/*']"),
|
||||
"input[accept='image/*']",
|
||||
);
|
||||
const clickSpy = vi
|
||||
.spyOn(puzzleInput, "click")
|
||||
.mockImplementation(() => undefined);
|
||||
fireEvent.click(screen.getByText(/Play Puzzle Mode/));
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clicking preset updates gridSize display and equation", () => {
|
||||
render(<DropZone onFiles={() => undefined} onPuzzle={() => undefined} />);
|
||||
fireEvent.click(screen.getByText("2×2"));
|
||||
expect(screen.getByText("2×2 = 4 pieces")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("custom grid input with valid value updates gridSize", () => {
|
||||
render(<DropZone onFiles={() => undefined} onPuzzle={() => undefined} />);
|
||||
const input = screen.getByLabelText("Custom grid size");
|
||||
fireEvent.change(input, { target: { value: "7" } });
|
||||
expect(screen.getByText("7×7 = 49 pieces")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("custom grid input with NaN value does not update gridSize", () => {
|
||||
render(<DropZone onFiles={() => undefined} onPuzzle={() => undefined} />);
|
||||
const input = screen.getByLabelText("Custom grid size");
|
||||
fireEvent.change(input, { target: { value: "xyz" } });
|
||||
expect(screen.getByText("4×4 = 16 pieces")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("custom grid input with value below 2 does not update gridSize", () => {
|
||||
render(<DropZone onFiles={() => undefined} onPuzzle={() => undefined} />);
|
||||
const input = screen.getByLabelText("Custom grid size");
|
||||
fireEvent.change(input, { target: { value: "1" } });
|
||||
expect(screen.getByText("4×4 = 16 pieces")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("onPuzzle uses updated gridSize after preset click", () => {
|
||||
const onPuzzle = vi.fn();
|
||||
const { container } = render(
|
||||
<DropZone onFiles={() => undefined} onPuzzle={onPuzzle} />,
|
||||
);
|
||||
fireEvent.click(screen.getByText("3×3")); // change gridSize to 3
|
||||
const puzzleInput = must(
|
||||
container.querySelector("input[accept='image/*']"),
|
||||
"input[accept='image/*']",
|
||||
);
|
||||
const imgFile = makeFile("img.png", "image/png");
|
||||
Object.defineProperty(puzzleInput, "files", {
|
||||
value: [imgFile],
|
||||
configurable: true,
|
||||
});
|
||||
fireEvent.change(puzzleInput);
|
||||
expect(onPuzzle).toHaveBeenCalledWith(imgFile, 3);
|
||||
});
|
||||
});
|
||||
@ -1,130 +0,0 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import styles from "./DropZone.module.css";
|
||||
|
||||
const PRESETS = [2, 3, 4, 5, 6] as const;
|
||||
|
||||
interface Props {
|
||||
onFiles: (files: File[]) => void;
|
||||
onPuzzle: (imageFile: File, gridSize: number) => void;
|
||||
}
|
||||
|
||||
export function DropZone({ onFiles, onPuzzle }: Props): React.ReactElement {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [count, setCount] = useState(0);
|
||||
const [gridSize, setGridSize] = useState(4);
|
||||
const puzzleInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
setCount(files.length);
|
||||
onFiles(files);
|
||||
},
|
||||
[onFiles],
|
||||
);
|
||||
|
||||
const handleFileInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
setCount(files.length);
|
||||
onFiles(files);
|
||||
},
|
||||
[onFiles],
|
||||
);
|
||||
|
||||
const handlePuzzleInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
onPuzzle(file, gridSize);
|
||||
},
|
||||
[onPuzzle, gridSize],
|
||||
);
|
||||
|
||||
const handleGridChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = parseInt(e.target.value, 10);
|
||||
if (!isNaN(raw) && raw >= 2) setGridSize(raw);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.zone} ${dragging ? styles.dragging : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => { setDragging(false); }}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className={styles.inner}>
|
||||
<span className={styles.icon}>🪣</span>
|
||||
<h1 className={styles.title}>Bucket Catch</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Drop your files here — then catch them before they fall!
|
||||
</p>
|
||||
{count > 0 && (
|
||||
<p className={styles.count}>
|
||||
{count} file{count !== 1 ? "s" : ""} ready
|
||||
</p>
|
||||
)}
|
||||
<label className={styles.browseBtn}>
|
||||
Or browse folder
|
||||
<input
|
||||
type="file"
|
||||
ref={(el) => el?.setAttribute("webkitdirectory", "")}
|
||||
multiple
|
||||
hidden
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={styles.divider}>or</div>
|
||||
|
||||
<div className={styles.puzzleSection}>
|
||||
<div className={styles.gridRow}>
|
||||
<span className={styles.gridLabel}>Grid:</span>
|
||||
{PRESETS.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
className={`${styles.preset} ${gridSize === n ? styles.presetActive : ""}`}
|
||||
onClick={() => { setGridSize(n); }}
|
||||
>
|
||||
{n}×{n}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={99}
|
||||
value={gridSize}
|
||||
onChange={handleGridChange}
|
||||
className={styles.gridInput}
|
||||
aria-label="Custom grid size"
|
||||
/>
|
||||
</div>
|
||||
<p className={styles.gridEq}>{gridSize}×{gridSize} = {gridSize * gridSize} pieces</p>
|
||||
<button
|
||||
className={styles.puzzleBtn}
|
||||
onClick={() => puzzleInputRef.current?.click()}
|
||||
>
|
||||
🧩 Play Puzzle Mode
|
||||
</button>
|
||||
<input
|
||||
ref={puzzleInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={handlePuzzleInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: rgba(var(--color-text-rgb), 0.5);
|
||||
font-size: 0.85rem;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
@ -1,76 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { GameCanvas } from "./GameCanvas";
|
||||
|
||||
vi.mock("../hooks/useBasketControl", () => ({
|
||||
useBasketControl: vi.fn(() => ({ current: 400 })),
|
||||
}));
|
||||
vi.mock("../hooks/useGameLoop", () => ({
|
||||
useGameLoop: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { useGameLoop } from "../hooks/useGameLoop";
|
||||
|
||||
describe("GameCanvas", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useGameLoop).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("renders a canvas element and hint text", () => {
|
||||
const { container } = render(
|
||||
<GameCanvas files={[]} onDone={() => undefined} />,
|
||||
);
|
||||
expect(container.querySelector("canvas")).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".hint") ??
|
||||
container.querySelector('[class*="hint"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds resize event listener on mount and removes it on unmount", () => {
|
||||
const addSpy = vi.spyOn(window, "addEventListener");
|
||||
const removeSpy = vi.spyOn(window, "removeEventListener");
|
||||
|
||||
const { unmount } = render(
|
||||
<GameCanvas files={[]} onDone={() => undefined} />,
|
||||
);
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith("resize", expect.any(Function));
|
||||
unmount();
|
||||
expect(removeSpy).toHaveBeenCalledWith("resize", expect.any(Function));
|
||||
});
|
||||
|
||||
it("calls onDone with the result when useGameLoop returns non-null", async () => {
|
||||
const result = { caught: [], missed: [] };
|
||||
vi.mocked(useGameLoop).mockReturnValue(result);
|
||||
|
||||
const onDone = vi.fn();
|
||||
render(<GameCanvas files={[]} onDone={onDone} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDone).toHaveBeenCalledWith(result);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call onDone when result is null", () => {
|
||||
vi.mocked(useGameLoop).mockReturnValue(null);
|
||||
const onDone = vi.fn();
|
||||
render(<GameCanvas files={[]} onDone={onDone} />);
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resize handler returns safely when canvasRef is null after unmount", () => {
|
||||
const addSpy = vi.spyOn(window, "addEventListener");
|
||||
const { unmount } = render(<GameCanvas files={[]} onDone={() => undefined} />);
|
||||
|
||||
const resizeArgs = addSpy.mock.calls.find((c) => c[0] === "resize");
|
||||
const resize = resizeArgs?.[1];
|
||||
|
||||
unmount(); // React nulls canvasRef.current
|
||||
|
||||
// Call the captured handler — canvas is now null → covers the null-guard branch
|
||||
if (typeof resize === "function") resize(new Event("resize"));
|
||||
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@ -1,41 +0,0 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import type { FileGameResult } from "../types";
|
||||
import { useBasketControl } from "../hooks/useBasketControl";
|
||||
import { useGameLoop } from "../hooks/useGameLoop";
|
||||
import styles from "./GameCanvas.module.css";
|
||||
|
||||
interface Props {
|
||||
files: File[];
|
||||
onDone: (result: FileGameResult) => void;
|
||||
}
|
||||
|
||||
export function GameCanvas({ files, onDone }: Props): React.ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const basketXRef = useBasketControl(canvasRef);
|
||||
|
||||
useEffect(() => {
|
||||
const resize = (): void => {
|
||||
const canvas = canvasRef.current;
|
||||
/* istanbul ignore next */
|
||||
if (!canvas) return;
|
||||
canvas.width = canvas.clientWidth;
|
||||
canvas.height = canvas.clientHeight;
|
||||
};
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
return () => { window.removeEventListener("resize", resize); };
|
||||
}, []);
|
||||
|
||||
const result = useGameLoop(canvasRef, basketXRef, files, true);
|
||||
|
||||
useEffect(() => {
|
||||
if (result) onDone(result);
|
||||
}, [result, onDone]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<canvas ref={canvasRef} className={styles.canvas} />
|
||||
<div className={styles.hint}>Move mouse to control the basket</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,170 +0,0 @@
|
||||
.container {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
background: var(--bg-gradient);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 1.1rem;
|
||||
color: var(--muted-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 2px solid var(--color-indigo);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5) var(--space-6);
|
||||
width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--color-pink);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
font-size: 48px;
|
||||
/* Dimmed relative to .cardDesc below it (rule 28). */
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
/* Was 0.85rem (13.6px), under the 16px reading floor (rule 20). */
|
||||
font-size: 1rem;
|
||||
color: var(--muted-text);
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cardDisabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
border-color: var(--muted-border);
|
||||
}
|
||||
|
||||
.cardDisabled:hover {
|
||||
border-color: var(--muted-border);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.puzzleCard {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gridRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.preset {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
/* muted-text, not muted-border — same rule 7 contrast fix as DropZone. */
|
||||
border: 1.5px solid var(--muted-text);
|
||||
color: var(--muted-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.preset:hover {
|
||||
border-color: var(--color-indigo);
|
||||
color: var(--color-indigo-lighter);
|
||||
}
|
||||
|
||||
.presetActive {
|
||||
border-color: var(--color-pink);
|
||||
color: var(--color-pink-light);
|
||||
/* 0.1, not 0.12 — matches DropZone's identical "active preset chip"
|
||||
role; the two files had drifted to different alpha values. */
|
||||
background: rgba(var(--color-pink-rgb), 0.1);
|
||||
}
|
||||
|
||||
.gridInput {
|
||||
width: 52px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
/* --surface, not a raw rgba(15,12,41,...) literal — DropZone's sibling
|
||||
gridInput already used the surface color; this one had drifted to a
|
||||
different base (bg-1) for the identical component. */
|
||||
background: var(--surface);
|
||||
border: 1.5px solid var(--muted-border);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.gridInput:focus {
|
||||
border-color: var(--color-indigo);
|
||||
}
|
||||
|
||||
.gridEq {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted-faint);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
.puzzleStart {
|
||||
margin-top: 6px;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-pill);
|
||||
/* --gradient-accent, not the stops written out reversed — this button
|
||||
was pink->indigo while every other primary gradient in the app is
|
||||
indigo->pink (same underlying bug the audit flagged in
|
||||
PuzzleResult.module.css, just not cited by this exact line). */
|
||||
background: var(--gradient-accent);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.puzzleStart:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
@ -1,134 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ModeSelect } from "./ModeSelect";
|
||||
|
||||
const makeFile = (name: string, type = "text/plain"): File =>
|
||||
new File(["x"], name, { type });
|
||||
|
||||
const makeImageFile = (): File =>
|
||||
new File(["x"], "photo.png", { type: "image/png" });
|
||||
|
||||
describe("ModeSelect", () => {
|
||||
it("renders singular file count for 1 file", () => {
|
||||
render(
|
||||
<ModeSelect files={[makeFile("a.txt")]} onStart={() => undefined} />,
|
||||
);
|
||||
expect(screen.getByText("1 file ready to drop")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plural file count for multiple files", () => {
|
||||
render(
|
||||
<ModeSelect
|
||||
files={[makeFile("a.txt"), makeFile("b.txt")]}
|
||||
onStart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("2 files ready to drop")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onStart('download') when Download is clicked", () => {
|
||||
const onStart = vi.fn();
|
||||
render(<ModeSelect files={[makeFile("a.txt")]} onStart={onStart} />);
|
||||
fireEvent.click(screen.getByText("Download"));
|
||||
expect(onStart).toHaveBeenCalledWith("download");
|
||||
});
|
||||
|
||||
it("calls onStart('upload') when Upload is clicked", () => {
|
||||
const onStart = vi.fn();
|
||||
render(<ModeSelect files={[makeFile("a.txt")]} onStart={onStart} />);
|
||||
fireEvent.click(screen.getByText("Upload"));
|
||||
expect(onStart).toHaveBeenCalledWith("upload");
|
||||
});
|
||||
|
||||
it("shows disabled puzzle message when files is not a single image", () => {
|
||||
render(
|
||||
<ModeSelect
|
||||
files={[makeFile("a.txt"), makeFile("b.txt")]}
|
||||
onStart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Drop exactly one image file to play puzzle mode."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Start")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows disabled puzzle message for single non-image file", () => {
|
||||
render(
|
||||
<ModeSelect files={[makeFile("a.txt")]} onStart={() => undefined} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Drop exactly one image file to play puzzle mode."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows puzzle controls when a single image file is loaded", () => {
|
||||
render(
|
||||
<ModeSelect files={[makeImageFile()]} onStart={() => undefined} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Catch puzzle pieces to assemble your image!"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Start")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking a preset button updates grid size display", () => {
|
||||
render(
|
||||
<ModeSelect files={[makeImageFile()]} onStart={() => undefined} />,
|
||||
);
|
||||
expect(screen.getByText("4×4 = 16 pieces")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("3×3"));
|
||||
expect(screen.getByText("3×3 = 9 pieces")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("custom grid input with valid value updates grid size", () => {
|
||||
render(
|
||||
<ModeSelect files={[makeImageFile()]} onStart={() => undefined} />,
|
||||
);
|
||||
const input = screen.getByLabelText("Custom grid size");
|
||||
fireEvent.change(input, { target: { value: "6" } });
|
||||
expect(screen.getByText("6×6 = 36 pieces")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("custom grid input with NaN value does not update grid size", () => {
|
||||
render(
|
||||
<ModeSelect files={[makeImageFile()]} onStart={() => undefined} />,
|
||||
);
|
||||
const input = screen.getByLabelText("Custom grid size");
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
expect(screen.getByText("4×4 = 16 pieces")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("custom grid input with value < 2 does not update grid size", () => {
|
||||
render(
|
||||
<ModeSelect files={[makeImageFile()]} onStart={() => undefined} />,
|
||||
);
|
||||
const input = screen.getByLabelText("Custom grid size");
|
||||
fireEvent.change(input, { target: { value: "1" } });
|
||||
expect(screen.getByText("4×4 = 16 pieces")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Start button calls onStart('puzzle', gridSize)", () => {
|
||||
const onStart = vi.fn();
|
||||
render(<ModeSelect files={[makeImageFile()]} onStart={onStart} />);
|
||||
// Default gridSize is 4
|
||||
fireEvent.click(screen.getByText("Start"));
|
||||
expect(onStart).toHaveBeenCalledWith("puzzle", 4);
|
||||
});
|
||||
|
||||
it("clicking number input stops propagation (onClick handler covered)", () => {
|
||||
render(
|
||||
<ModeSelect files={[makeImageFile()]} onStart={() => undefined} />,
|
||||
);
|
||||
const input = screen.getByLabelText("Custom grid size");
|
||||
fireEvent.click(input); // covers onClick={(e) => { e.stopPropagation(); }}
|
||||
});
|
||||
|
||||
it("Start button uses updated grid size after preset click", () => {
|
||||
const onStart = vi.fn();
|
||||
render(<ModeSelect files={[makeImageFile()]} onStart={onStart} />);
|
||||
fireEvent.click(screen.getByText("5×5"));
|
||||
fireEvent.click(screen.getByText("Start"));
|
||||
expect(onStart).toHaveBeenCalledWith("puzzle", 5);
|
||||
});
|
||||
});
|
||||
@ -1,100 +0,0 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import type { TransferMode } from "../types";
|
||||
import styles from "./ModeSelect.module.css";
|
||||
|
||||
const PRESETS = [2, 3, 4, 5, 6] as const;
|
||||
|
||||
interface Props {
|
||||
files: File[];
|
||||
onStart: (mode: TransferMode, gridSize?: number) => void;
|
||||
}
|
||||
|
||||
export function ModeSelect({ files, onStart }: Props): React.ReactElement {
|
||||
const [gridSize, setGridSize] = useState(4);
|
||||
const fileCount = files.length;
|
||||
const isSingleImage =
|
||||
files.length === 1 && files[0].type.startsWith("image/");
|
||||
|
||||
const handleGridChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = parseInt(e.target.value, 10);
|
||||
if (!isNaN(raw) && raw >= 2) setGridSize(raw);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h2 className={styles.heading}>
|
||||
{fileCount} file{fileCount !== 1 ? "s" : ""} ready to drop
|
||||
</h2>
|
||||
<p className={styles.sub}>What happens to the files you catch?</p>
|
||||
<div className={styles.cards}>
|
||||
<button
|
||||
className={styles.card}
|
||||
onClick={() => { onStart("download"); }}
|
||||
>
|
||||
<span className={styles.cardIcon}>⬇️</span>
|
||||
<span className={styles.cardTitle}>Download</span>
|
||||
<span className={styles.cardDesc}>
|
||||
Caught files are zipped and saved to your device. No server needed.
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={styles.card}
|
||||
onClick={() => { onStart("upload"); }}
|
||||
>
|
||||
<span className={styles.cardIcon}>☁️</span>
|
||||
<span className={styles.cardTitle}>Upload</span>
|
||||
<span className={styles.cardDesc}>
|
||||
Caught files are sent to the backend server (localhost:3000).
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={`${styles.card} ${isSingleImage ? styles.puzzleCard : styles.cardDisabled}`}
|
||||
title={isSingleImage ? "" : "Drop exactly one image file to unlock puzzle mode"}
|
||||
>
|
||||
<span className={styles.cardIcon}>🧩</span>
|
||||
<span className={styles.cardTitle}>Puzzle</span>
|
||||
<span className={styles.cardDesc}>
|
||||
{isSingleImage
|
||||
? "Catch puzzle pieces to assemble your image!"
|
||||
: "Drop exactly one image file to play puzzle mode."}
|
||||
</span>
|
||||
{isSingleImage && (
|
||||
<>
|
||||
<div className={styles.gridRow}>
|
||||
{PRESETS.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
className={`${styles.preset} ${gridSize === n ? styles.presetActive : ""}`}
|
||||
onClick={(e) => { e.stopPropagation(); setGridSize(n); }}
|
||||
>
|
||||
{n}×{n}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={99}
|
||||
value={gridSize}
|
||||
onChange={handleGridChange}
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
className={styles.gridInput}
|
||||
aria-label="Custom grid size"
|
||||
/>
|
||||
</div>
|
||||
<p className={styles.gridEq}>{gridSize}×{gridSize} = {gridSize * gridSize} pieces</p>
|
||||
<button
|
||||
className={styles.puzzleStart}
|
||||
onClick={() => { onStart("puzzle", gridSize); }}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: rgba(var(--color-pink-rgb), 0.7);
|
||||
font-size: 0.9rem;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { PuzzleCanvas } from "./PuzzleCanvas";
|
||||
|
||||
vi.mock("../hooks/useBasketControl", () => ({
|
||||
useBasketControl: vi.fn(() => ({ current: 400 })),
|
||||
}));
|
||||
vi.mock("../hooks/usePuzzleGameLoop", () => ({
|
||||
usePuzzleGameLoop: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { usePuzzleGameLoop } from "../hooks/usePuzzleGameLoop";
|
||||
|
||||
const imageFile = new File(["img"], "test.png", { type: "image/png" });
|
||||
|
||||
describe("PuzzleCanvas", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(usePuzzleGameLoop).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("renders a canvas element and puzzle hint text", () => {
|
||||
const { container } = render(
|
||||
<PuzzleCanvas imageFile={imageFile} gridSize={2} onDone={() => undefined} />,
|
||||
);
|
||||
expect(container.querySelector("canvas")).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".hint") ??
|
||||
container.querySelector('[class*="hint"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds resize event listener on mount and removes it on unmount", () => {
|
||||
const addSpy = vi.spyOn(window, "addEventListener");
|
||||
const removeSpy = vi.spyOn(window, "removeEventListener");
|
||||
|
||||
const { unmount } = render(
|
||||
<PuzzleCanvas imageFile={imageFile} gridSize={2} onDone={() => undefined} />,
|
||||
);
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith("resize", expect.any(Function));
|
||||
unmount();
|
||||
expect(removeSpy).toHaveBeenCalledWith("resize", expect.any(Function));
|
||||
});
|
||||
|
||||
it("calls onDone with the result when usePuzzleGameLoop returns non-null", async () => {
|
||||
const result = { caughtPieces: [], missedPieces: [], gridSize: 2 };
|
||||
vi.mocked(usePuzzleGameLoop).mockReturnValue(result);
|
||||
|
||||
const onDone = vi.fn();
|
||||
render(<PuzzleCanvas imageFile={imageFile} gridSize={2} onDone={onDone} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDone).toHaveBeenCalledWith(result);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call onDone when result is null", () => {
|
||||
vi.mocked(usePuzzleGameLoop).mockReturnValue(null);
|
||||
const onDone = vi.fn();
|
||||
render(<PuzzleCanvas imageFile={imageFile} gridSize={2} onDone={onDone} />);
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resize handler returns safely when canvasRef is null after unmount", () => {
|
||||
const addSpy = vi.spyOn(window, "addEventListener");
|
||||
const { unmount } = render(
|
||||
<PuzzleCanvas imageFile={imageFile} gridSize={2} onDone={() => undefined} />,
|
||||
);
|
||||
|
||||
const resizeArgs = addSpy.mock.calls.find((c) => c[0] === "resize");
|
||||
const resize = resizeArgs?.[1];
|
||||
|
||||
unmount(); // React nulls canvasRef.current
|
||||
|
||||
if (typeof resize === "function") resize(new Event("resize"));
|
||||
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@ -1,46 +0,0 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import type { PuzzleGameResult } from "../types";
|
||||
import { useBasketControl } from "../hooks/useBasketControl";
|
||||
import { usePuzzleGameLoop } from "../hooks/usePuzzleGameLoop";
|
||||
import styles from "./PuzzleCanvas.module.css";
|
||||
|
||||
interface Props {
|
||||
imageFile: File;
|
||||
gridSize: number;
|
||||
onDone: (result: PuzzleGameResult) => void;
|
||||
}
|
||||
|
||||
export function PuzzleCanvas({
|
||||
imageFile,
|
||||
gridSize,
|
||||
onDone,
|
||||
}: Props): React.ReactElement {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const basketXRef = useBasketControl(canvasRef);
|
||||
|
||||
useEffect(() => {
|
||||
const resize = (): void => {
|
||||
const canvas = canvasRef.current;
|
||||
/* istanbul ignore next */
|
||||
if (!canvas) return;
|
||||
canvas.width = canvas.clientWidth;
|
||||
canvas.height = canvas.clientHeight;
|
||||
};
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
return () => { window.removeEventListener("resize", resize); };
|
||||
}, []);
|
||||
|
||||
const result = usePuzzleGameLoop(canvasRef, basketXRef, imageFile, gridSize, true);
|
||||
|
||||
useEffect(() => {
|
||||
if (result) onDone(result);
|
||||
}, [result, onDone]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<canvas ref={canvasRef} className={styles.canvas} />
|
||||
<div className={styles.hint}>Move mouse to catch the puzzle pieces!</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
.container {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
padding: var(--space-5) var(--space-3);
|
||||
background: var(--bg-gradient);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.scoreBox {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.grade {
|
||||
font-size: 6rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
/* Was reversed (pink->indigo) vs every other heading gradient in the
|
||||
app (indigo->pink) — the bug DESIGN_AUDIT_TODO.md flagged. */
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.pct {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-indigo-light);
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.caught {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-width: min(480px, 90vw);
|
||||
width: 100%;
|
||||
border: 3px solid rgba(var(--color-indigo-rgb), 0.4);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--surface-solid);
|
||||
}
|
||||
|
||||
.piece {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.hole {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: rgba(var(--surface-rgb), 0.9);
|
||||
border: 1px dashed rgba(var(--color-indigo-rgb), 0.2);
|
||||
}
|
||||
|
||||
.restartBtn {
|
||||
padding: 14px 28px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
border: 2px solid var(--color-pink);
|
||||
color: var(--color-pink-light);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.restartBtn:hover {
|
||||
border-color: var(--color-indigo);
|
||||
color: var(--color-indigo-light);
|
||||
}
|
||||
@ -1,180 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { PuzzleResult } from "./PuzzleResult";
|
||||
import { must } from "../test/domQueries";
|
||||
import type { FallingPuzzleItem, PuzzlePiece } from "../types";
|
||||
|
||||
const makePiece = (row: number, col: number): PuzzlePiece => ({
|
||||
row,
|
||||
col,
|
||||
gridSize: 2,
|
||||
imageUrl: `data:image/png;base64,${row}-${col}`,
|
||||
pieceWidth: 50,
|
||||
pieceHeight: 50,
|
||||
});
|
||||
|
||||
const makeFalling = (row: number, col: number): FallingPuzzleItem => ({
|
||||
kind: "puzzle",
|
||||
id: `${row}-${col}`,
|
||||
piece: makePiece(row, col),
|
||||
x: 0,
|
||||
y: 0,
|
||||
speed: 3,
|
||||
startFrame: 0,
|
||||
status: "caught",
|
||||
});
|
||||
|
||||
describe("PuzzleResult", () => {
|
||||
it("renders grade A and percentage for 75% completion", () => {
|
||||
// gridSize=2 → total=4; 3 caught → 75% → A
|
||||
render(
|
||||
<PuzzleResult
|
||||
result={{
|
||||
gridSize: 2,
|
||||
caughtPieces: [
|
||||
makeFalling(0, 0),
|
||||
makeFalling(0, 1),
|
||||
makeFalling(1, 0),
|
||||
],
|
||||
missedPieces: [makeFalling(1, 1)],
|
||||
}}
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("A")).toBeInTheDocument();
|
||||
expect(screen.getByText("75%")).toBeInTheDocument();
|
||||
expect(screen.getByText(/3 \/ 4 pieces caught/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders grade S at 100%", () => {
|
||||
render(
|
||||
<PuzzleResult
|
||||
result={{
|
||||
gridSize: 1,
|
||||
caughtPieces: [makeFalling(0, 0)],
|
||||
missedPieces: [],
|
||||
}}
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
expect(screen.getByText("100%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders grade B at exactly 50%", () => {
|
||||
render(
|
||||
<PuzzleResult
|
||||
result={{
|
||||
gridSize: 2,
|
||||
caughtPieces: [makeFalling(0, 0), makeFalling(0, 1)],
|
||||
missedPieces: [makeFalling(1, 0), makeFalling(1, 1)],
|
||||
}}
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("B")).toBeInTheDocument();
|
||||
expect(screen.getByText("50%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders grade C at 25%", () => {
|
||||
render(
|
||||
<PuzzleResult
|
||||
result={{
|
||||
gridSize: 2,
|
||||
caughtPieces: [makeFalling(0, 0)],
|
||||
missedPieces: [
|
||||
makeFalling(0, 1),
|
||||
makeFalling(1, 0),
|
||||
makeFalling(1, 1),
|
||||
],
|
||||
}}
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("C")).toBeInTheDocument();
|
||||
expect(screen.getByText("25%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders grade D at 0%", () => {
|
||||
render(
|
||||
<PuzzleResult
|
||||
result={{
|
||||
gridSize: 2,
|
||||
caughtPieces: [],
|
||||
missedPieces: [
|
||||
makeFalling(0, 0),
|
||||
makeFalling(0, 1),
|
||||
makeFalling(1, 0),
|
||||
makeFalling(1, 1),
|
||||
],
|
||||
}}
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("D")).toBeInTheDocument();
|
||||
expect(screen.getByText("0%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders img elements for caught pieces and div holes for missed", () => {
|
||||
const { container } = render(
|
||||
<PuzzleResult
|
||||
result={{
|
||||
gridSize: 2,
|
||||
caughtPieces: [makeFalling(0, 0), makeFalling(1, 1)],
|
||||
missedPieces: [makeFalling(0, 1), makeFalling(1, 0)],
|
||||
}}
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
// 2 img elements for caught pieces
|
||||
const imgs = container.querySelectorAll("img");
|
||||
expect(imgs).toHaveLength(2);
|
||||
// The grid div has inline style with gridTemplateColumns; its non-img children are holes
|
||||
const gridEl = must(
|
||||
container.querySelector('[style*="repeat"]'),
|
||||
"the puzzle grid element",
|
||||
);
|
||||
const holeDivs = Array.from(gridEl.children).filter(
|
||||
(c) => c.tagName === "DIV",
|
||||
);
|
||||
expect(holeDivs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("img src matches imageUrl of caught piece", () => {
|
||||
render(
|
||||
<PuzzleResult
|
||||
result={{
|
||||
gridSize: 1,
|
||||
caughtPieces: [makeFalling(0, 0)],
|
||||
missedPieces: [],
|
||||
}}
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
const img = screen.getByRole("img", { name: "Piece 0-0" });
|
||||
expect(img).toHaveAttribute("src", "data:image/png;base64,0-0");
|
||||
});
|
||||
|
||||
it("calls onRestart when Play again is clicked", () => {
|
||||
const onRestart = vi.fn();
|
||||
render(
|
||||
<PuzzleResult
|
||||
result={{ gridSize: 1, caughtPieces: [], missedPieces: [] }}
|
||||
onRestart={onRestart}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("Play again"));
|
||||
expect(onRestart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("handles gridSize=0 without errors (0% fallback)", () => {
|
||||
render(
|
||||
<PuzzleResult
|
||||
result={{ gridSize: 0, caughtPieces: [], missedPieces: [] }}
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("D")).toBeInTheDocument();
|
||||
expect(screen.getByText("0%")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -1,68 +0,0 @@
|
||||
import React from "react";
|
||||
import type { PuzzleGameResult } from "../types";
|
||||
import styles from "./PuzzleResult.module.css";
|
||||
|
||||
interface Props {
|
||||
result: PuzzleGameResult;
|
||||
onRestart: () => void;
|
||||
}
|
||||
|
||||
function calcGrade(pct: number): string {
|
||||
if (pct >= 90) return "S";
|
||||
if (pct >= 75) return "A";
|
||||
if (pct >= 50) return "B";
|
||||
if (pct >= 25) return "C";
|
||||
return "D";
|
||||
}
|
||||
|
||||
export function PuzzleResult({ result, onRestart }: Props): React.ReactElement {
|
||||
const total = result.gridSize * result.gridSize;
|
||||
const caught = result.caughtPieces.length;
|
||||
const pct = total === 0 ? 0 : Math.round((caught / total) * 100);
|
||||
const grade = calcGrade(pct);
|
||||
|
||||
const cells = Array.from({ length: total }, (_, index) => {
|
||||
const row = Math.floor(index / result.gridSize);
|
||||
const col = index % result.gridSize;
|
||||
const piece = result.caughtPieces.find(
|
||||
(p) => p.piece.row === row && p.piece.col === col,
|
||||
);
|
||||
return { row, col, piece };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.scoreBox}>
|
||||
<div className={styles.grade}>{grade}</div>
|
||||
<div className={styles.pct}>{pct}%</div>
|
||||
<div className={styles.stats}>
|
||||
<span className={styles.caught}>
|
||||
✅ {caught} / {total} pieces caught
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.grid}
|
||||
style={{ gridTemplateColumns: `repeat(${result.gridSize}, 1fr)` }}
|
||||
>
|
||||
{cells.map(({ row, col, piece }) =>
|
||||
piece ? (
|
||||
<img
|
||||
key={`${row}-${col}`}
|
||||
src={piece.piece.imageUrl}
|
||||
className={styles.piece}
|
||||
alt={`Piece ${row}-${col}`}
|
||||
/>
|
||||
) : (
|
||||
<div key={`${row}-${col}`} className={styles.hole} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className={styles.restartBtn} onClick={onRestart}>
|
||||
Play again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,157 +0,0 @@
|
||||
.container {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
padding: var(--space-5) var(--space-3);
|
||||
background: var(--bg-gradient);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.scoreBox {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.grade {
|
||||
font-size: 6rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.pct {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-indigo-light);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.caught {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.missed {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.lists {
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.listTitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted-text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.fileList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fileItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.missedItem {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.fname {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.fsize {
|
||||
color: var(--muted-faint);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
padding: 16px var(--space-5);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--gradient-accent);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.actionBtn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.restartBtn {
|
||||
padding: 14px 28px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
border: 2px solid var(--color-indigo);
|
||||
color: var(--color-indigo-light);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.restartBtn:hover {
|
||||
border-color: var(--color-pink);
|
||||
color: var(--color-pink-light);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--color-success);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-error);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
@ -1,208 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ScoreScreen } from "./ScoreScreen";
|
||||
|
||||
vi.mock("../lib/zipDownload", () => ({
|
||||
zipDownload: vi.fn(),
|
||||
}));
|
||||
vi.mock("../lib/uploadFiles", () => ({
|
||||
uploadFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
import { zipDownload } from "../lib/zipDownload";
|
||||
import { uploadFiles } from "../lib/uploadFiles";
|
||||
|
||||
const makeFile = (name: string, size = 10): File => {
|
||||
const f = new File(["x".repeat(size)], name, { type: "text/plain" });
|
||||
return f;
|
||||
};
|
||||
|
||||
const result1 = {
|
||||
caught: [makeFile("a.txt"), makeFile("b.txt")],
|
||||
missed: [makeFile("c.txt")],
|
||||
};
|
||||
|
||||
describe("ScoreScreen", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(zipDownload).mockResolvedValue(undefined);
|
||||
vi.mocked(uploadFiles).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("renders grade, percentage, and file lists", () => {
|
||||
render(
|
||||
<ScoreScreen result={result1} mode="download" onRestart={() => undefined} />,
|
||||
);
|
||||
// 2 caught / 3 total = 67% → grade B
|
||||
expect(screen.getByText("B")).toBeInTheDocument();
|
||||
expect(screen.getByText("67%")).toBeInTheDocument();
|
||||
expect(screen.getByText("Caught")).toBeInTheDocument();
|
||||
expect(screen.getByText("Missed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows download button for download mode with multiple files", () => {
|
||||
render(
|
||||
<ScoreScreen result={result1} mode="download" onRestart={() => undefined} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Download 2 caught files/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows singular 'file' for exactly 1 caught file", () => {
|
||||
render(
|
||||
<ScoreScreen
|
||||
result={{ caught: [makeFile("x.txt")], missed: [] }}
|
||||
mode="download"
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Download 1 caught file$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("triggers zip download and shows done state", async () => {
|
||||
render(
|
||||
<ScoreScreen result={result1} mode="download" onRestart={() => undefined} />,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Download/));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Downloaded!")).toBeInTheDocument();
|
||||
});
|
||||
expect(zipDownload).toHaveBeenCalledWith(result1.caught);
|
||||
});
|
||||
|
||||
it("shows upload button for upload mode", () => {
|
||||
render(
|
||||
<ScoreScreen result={result1} mode="upload" onRestart={() => undefined} />,
|
||||
);
|
||||
expect(screen.getByText(/Upload 2 caught files to server/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows singular upload text for exactly 1 caught file", () => {
|
||||
render(
|
||||
<ScoreScreen
|
||||
result={{ caught: [makeFile("x.txt")], missed: [] }}
|
||||
mode="upload"
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Upload 1 caught file to server$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("triggers upload and shows done state", async () => {
|
||||
render(
|
||||
<ScoreScreen result={result1} mode="upload" onRestart={() => undefined} />,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Upload/));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Uploaded to server!")).toBeInTheDocument();
|
||||
});
|
||||
expect(uploadFiles).toHaveBeenCalledWith(result1.caught);
|
||||
});
|
||||
|
||||
it("shows error state when action throws an Error", async () => {
|
||||
vi.mocked(zipDownload).mockRejectedValue(new Error("Network error"));
|
||||
render(
|
||||
<ScoreScreen result={result1} mode="download" onRestart={() => undefined} />,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Download/));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Error: Network error/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state when action throws a non-Error value", async () => {
|
||||
vi.mocked(uploadFiles).mockRejectedValue("server unavailable");
|
||||
render(
|
||||
<ScoreScreen result={result1} mode="upload" onRestart={() => undefined} />,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Upload/));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Error: server unavailable/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onRestart when play again is clicked", () => {
|
||||
const onRestart = vi.fn();
|
||||
render(
|
||||
<ScoreScreen
|
||||
result={{ caught: [], missed: [] }}
|
||||
mode="download"
|
||||
onRestart={onRestart}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("Play again"));
|
||||
expect(onRestart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("hides action button when no files were caught", () => {
|
||||
render(
|
||||
<ScoreScreen
|
||||
result={{ caught: [], missed: [makeFile("x.txt")] }}
|
||||
mode="download"
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/Download/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("grades S at >= 90%", () => {
|
||||
const files = Array.from({ length: 10 }, (_, i) => makeFile(`f${i}.txt`));
|
||||
render(
|
||||
<ScoreScreen
|
||||
result={{ caught: files.slice(0, 9), missed: files.slice(9) }}
|
||||
mode="download"
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("grades A at >= 75%", () => {
|
||||
const files = Array.from({ length: 4 }, (_, i) => makeFile(`f${i}.txt`));
|
||||
render(
|
||||
<ScoreScreen
|
||||
result={{ caught: files.slice(0, 3), missed: files.slice(3) }}
|
||||
mode="download"
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("grades C at >= 25%", () => {
|
||||
const files = Array.from({ length: 4 }, (_, i) => makeFile(`f${i}.txt`));
|
||||
render(
|
||||
<ScoreScreen
|
||||
result={{ caught: files.slice(0, 1), missed: files.slice(1) }}
|
||||
mode="download"
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("C")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("grades D below 25%", () => {
|
||||
const files = Array.from({ length: 5 }, (_, i) => makeFile(`f${i}.txt`));
|
||||
render(
|
||||
<ScoreScreen
|
||||
result={{ caught: [], missed: files }}
|
||||
mode="download"
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("D")).toBeInTheDocument();
|
||||
expect(screen.getByText("0%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles zero total files (0%)", () => {
|
||||
render(
|
||||
<ScoreScreen
|
||||
result={{ caught: [], missed: [] }}
|
||||
mode="download"
|
||||
onRestart={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("D")).toBeInTheDocument();
|
||||
expect(screen.getByText("0%")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -1,118 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import type { FileGameResult, TransferMode } from "../types";
|
||||
import { fileIcon, formatSize } from "../lib/fileIcon";
|
||||
import { zipDownload } from "../lib/zipDownload";
|
||||
import { uploadFiles } from "../lib/uploadFiles";
|
||||
import styles from "./ScoreScreen.module.css";
|
||||
|
||||
interface Props {
|
||||
result: FileGameResult;
|
||||
mode: TransferMode;
|
||||
onRestart: () => void;
|
||||
}
|
||||
|
||||
type ActionState = "idle" | "working" | "done" | "error";
|
||||
|
||||
function calcGrade(pct: number): string {
|
||||
if (pct >= 90) return "S";
|
||||
if (pct >= 75) return "A";
|
||||
if (pct >= 50) return "B";
|
||||
if (pct >= 25) return "C";
|
||||
return "D";
|
||||
}
|
||||
|
||||
export function ScoreScreen({
|
||||
result,
|
||||
mode,
|
||||
onRestart,
|
||||
}: Props): React.ReactElement {
|
||||
const [state, setState] = useState<ActionState>("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const total = result.caught.length + result.missed.length;
|
||||
const pct =
|
||||
total === 0 ? 0 : Math.round((result.caught.length / total) * 100);
|
||||
const grade = calcGrade(pct);
|
||||
|
||||
const handleAction = async (): Promise<void> => {
|
||||
setState("working");
|
||||
try {
|
||||
if (mode === "download") {
|
||||
await zipDownload(result.caught);
|
||||
} else {
|
||||
await uploadFiles(result.caught);
|
||||
}
|
||||
setState("done");
|
||||
} catch (e) {
|
||||
setErrorMsg(e instanceof Error ? e.message : String(e));
|
||||
setState("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.scoreBox}>
|
||||
<div className={styles.grade}>{grade}</div>
|
||||
<div className={styles.pct}>{pct}%</div>
|
||||
<div className={styles.stats}>
|
||||
<span className={styles.caught}>✅ {result.caught.length} caught</span>
|
||||
<span className={styles.missed}>❌ {result.missed.length} missed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.lists}>
|
||||
{result.caught.length > 0 && (
|
||||
<div>
|
||||
<h3 className={styles.listTitle}>Caught</h3>
|
||||
<ul className={styles.fileList}>
|
||||
{result.caught.map((f, i) => (
|
||||
<li key={i} className={styles.fileItem}>
|
||||
<span>{fileIcon(f)}</span>
|
||||
<span className={styles.fname}>{f.name}</span>
|
||||
<span className={styles.fsize}>{formatSize(f.size)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{result.missed.length > 0 && (
|
||||
<div>
|
||||
<h3 className={styles.listTitle}>Missed</h3>
|
||||
<ul className={styles.fileList}>
|
||||
{result.missed.map((f, i) => (
|
||||
<li key={i} className={`${styles.fileItem} ${styles.missedItem}`}>
|
||||
<span>{fileIcon(f)}</span>
|
||||
<span className={styles.fname}>{f.name}</span>
|
||||
<span className={styles.fsize}>{formatSize(f.size)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{result.caught.length > 0 && state === "idle" && (
|
||||
<button className={styles.actionBtn} onClick={() => void handleAction()}>
|
||||
{mode === "download"
|
||||
? `⬇️ Download ${result.caught.length} caught file${result.caught.length !== 1 ? "s" : ""}`
|
||||
: `☁️ Upload ${result.caught.length} caught file${result.caught.length !== 1 ? "s" : ""} to server`}
|
||||
</button>
|
||||
)}
|
||||
{state === "working" && (
|
||||
<p className={styles.status}>Working…</p>
|
||||
)}
|
||||
{state === "done" && (
|
||||
<p className={styles.status}>
|
||||
{mode === "download" ? "Downloaded!" : "Uploaded to server!"}
|
||||
</p>
|
||||
)}
|
||||
{state === "error" && (
|
||||
<p className={styles.error}>Error: {errorMsg}</p>
|
||||
)}
|
||||
<button className={styles.restartBtn} onClick={onRestart}>
|
||||
Play again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
import type { PuzzlePiece } from "../types";
|
||||
|
||||
/**
|
||||
* Board geometry and piece scheduling for the puzzle game.
|
||||
*
|
||||
* The interesting part is `assignXPositions`, which is what makes a 100% score
|
||||
* always achievable: pieces that arrive at the basket at overlapping times are
|
||||
* clustered so one basket position covers all of them.
|
||||
*/
|
||||
|
||||
export const BASKET_HALF_WIDTH = 60;
|
||||
export const BASKET_HEIGHT = 40;
|
||||
export const PIECE_HALF_W = 48;
|
||||
export const PIECE_HALF_H = 48;
|
||||
export const BASKET_Y_OFFSET = 80;
|
||||
export const MIN_SPEED = 3;
|
||||
export const SPEED_RANGE = 3;
|
||||
/** y-half-extent of the catch zone: BASKET_HEIGHT/2 + PIECE_HALF_H */
|
||||
export const CATCH_HALF = BASKET_HEIGHT / 2 + PIECE_HALF_H; // 68 px
|
||||
/** x-half-extent: basket catches a piece within this horizontal distance */
|
||||
export const CATCH_RANGE = BASKET_HALF_WIDTH + PIECE_HALF_W; // 108 px
|
||||
/** Frames between consecutive piece spawn times — controls pacing. */
|
||||
export const SPAWN_GAP = 20;
|
||||
|
||||
export interface ScheduledPiece {
|
||||
piece: PuzzlePiece;
|
||||
spawnFrame: number;
|
||||
speed: number;
|
||||
x: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes when each piece enters/exits the catch zone, groups pieces
|
||||
* whose windows overlap (Union-Find), and assigns x positions so that
|
||||
* every group fits within one basket-width — guaranteeing 100% is achievable.
|
||||
*/
|
||||
export function assignXPositions(
|
||||
scheduled: ScheduledPiece[],
|
||||
basketY: number,
|
||||
canvasW: number,
|
||||
): void {
|
||||
const n = scheduled.length;
|
||||
const parent = Array.from({ length: n }, (_, i) => i);
|
||||
|
||||
const find = (start: number): number => {
|
||||
let root = start;
|
||||
while (parent[root] !== root) root = parent[root];
|
||||
let i = start;
|
||||
while (parent[i] !== root) {
|
||||
const next = parent[i];
|
||||
parent[i] = root;
|
||||
i = next;
|
||||
}
|
||||
return root;
|
||||
};
|
||||
|
||||
const union = (a: number, b: number): void => {
|
||||
parent[find(a)] = find(b);
|
||||
};
|
||||
|
||||
// Catch window for each piece: the frame range when it's at basket height.
|
||||
const enters = scheduled.map(
|
||||
(s) => s.spawnFrame + (basketY - CATCH_HALF + PIECE_HALF_H) / s.speed,
|
||||
);
|
||||
const exits = scheduled.map(
|
||||
(s) => s.spawnFrame + (basketY + CATCH_HALF + PIECE_HALF_H) / s.speed,
|
||||
);
|
||||
|
||||
// Union all pairs whose windows overlap — they arrive simultaneously.
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
if (enters[i] <= exits[j] && enters[j] <= exits[i]) {
|
||||
union(i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect groups.
|
||||
const groups = new Map<number, number[]>();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const root = find(i);
|
||||
const g = groups.get(root);
|
||||
if (g !== undefined) {
|
||||
g.push(i);
|
||||
} else {
|
||||
groups.set(root, [i]);
|
||||
}
|
||||
}
|
||||
|
||||
// maxOffset: pieces within ±maxOffset of cluster center are all reachable
|
||||
// from a single basket position (|piece.x - basketX| < CATCH_RANGE iff
|
||||
// basketX = centerX and |piece.x - centerX| ≤ maxOffset < CATCH_RANGE).
|
||||
const maxOffset = BASKET_HALF_WIDTH; // 60 px
|
||||
const minX = PIECE_HALF_W;
|
||||
const maxX = canvasW - PIECE_HALF_W;
|
||||
|
||||
for (const indices of groups.values()) {
|
||||
if (indices.length === 1) {
|
||||
// Solo piece: any position across the full canvas width.
|
||||
scheduled[indices[0]].x = minX + Math.random() * (maxX - minX);
|
||||
} else {
|
||||
// Multi-piece group: cluster within ±maxOffset of a shared center.
|
||||
const centerMin = PIECE_HALF_W + maxOffset;
|
||||
const centerMax = canvasW - PIECE_HALF_W - maxOffset;
|
||||
const centerX =
|
||||
centerMin < centerMax
|
||||
? centerMin + Math.random() * (centerMax - centerMin)
|
||||
: canvasW / 2;
|
||||
for (const idx of indices) {
|
||||
scheduled[idx].x =
|
||||
centerX + (Math.random() * 2 - 1) * maxOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function aabbCollision(
|
||||
fx: number,
|
||||
fy: number,
|
||||
bx: number,
|
||||
by: number,
|
||||
): boolean {
|
||||
return (
|
||||
Math.abs(fx - bx) < CATCH_RANGE &&
|
||||
Math.abs(fy - by) < BASKET_HEIGHT / 2 + PIECE_HALF_H
|
||||
);
|
||||
}
|
||||
@ -1,110 +0,0 @@
|
||||
import type { FallingPuzzleItem } from "../types";
|
||||
import {
|
||||
BASKET_HALF_WIDTH,
|
||||
BASKET_HEIGHT,
|
||||
PIECE_HALF_H,
|
||||
PIECE_HALF_W,
|
||||
} from "./puzzleGeometry";
|
||||
|
||||
/**
|
||||
* Canvas drawing for the puzzle game.
|
||||
*
|
||||
* Nothing here reads or writes game state — each function takes what it needs
|
||||
* and paints. Keeping the rendering separate from the tick means the loop in
|
||||
* `usePuzzleGameLoop` reads as scheduling and collision, not as drawing calls.
|
||||
*/
|
||||
|
||||
export function drawBasket(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
const h = BASKET_HEIGHT;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "#f472b6";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - BASKET_HALF_WIDTH, y - h / 2);
|
||||
ctx.lineTo(x - BASKET_HALF_WIDTH, y + h / 2);
|
||||
ctx.lineTo(x + BASKET_HALF_WIDTH, y + h / 2);
|
||||
ctx.lineTo(x + BASKET_HALF_WIDTH, y - h / 2);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = "#e879f9";
|
||||
ctx.lineWidth = 6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - BASKET_HALF_WIDTH - 6, y - h / 2);
|
||||
ctx.lineTo(x + BASKET_HALF_WIDTH + 6, y - h / 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function drawPiece(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
item: FallingPuzzleItem,
|
||||
img: HTMLImageElement | undefined,
|
||||
caught: boolean,
|
||||
): void {
|
||||
const w = PIECE_HALF_W * 2;
|
||||
const h = PIECE_HALF_H * 2;
|
||||
const x = item.x - PIECE_HALF_W;
|
||||
const y = item.y - PIECE_HALF_H;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = caught ? 0.35 : 1;
|
||||
|
||||
if (img?.complete && img.naturalWidth > 0) {
|
||||
ctx.drawImage(img, x, y, w, h);
|
||||
} else {
|
||||
ctx.fillStyle = "rgba(129, 140, 248, 0.5)";
|
||||
ctx.fillRect(x, y, w, h);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = caught ? "#34d399" : "#f472b6";
|
||||
ctx.lineWidth = caught ? 3 : 2;
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function drawHUD(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
caught: number,
|
||||
total: number,
|
||||
piecesDone: number,
|
||||
): void {
|
||||
ctx.save();
|
||||
ctx.fillStyle = "rgba(0,0,0,0.45)";
|
||||
ctx.roundRect(canvas.width - 160, 12, 148, 36, 8);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#a5b4fc";
|
||||
ctx.font = "bold 14px monospace";
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(`✅ ${caught} / ${total}`, canvas.width - 20, 30);
|
||||
ctx.restore();
|
||||
|
||||
const barW = 200;
|
||||
const barX = (canvas.width - barW) / 2;
|
||||
ctx.save();
|
||||
ctx.fillStyle = "rgba(0,0,0,0.4)";
|
||||
ctx.roundRect(barX, 12, barW, 8, 4);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#818cf8";
|
||||
ctx.roundRect(barX, 12, barW * (piecesDone / total), 8, 4);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Paints the backdrop gradient the pieces fall against. */
|
||||
export function drawBackdrop(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
): void {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
||||
grad.addColorStop(0, "#0f0c29");
|
||||
grad.addColorStop(1, "#302b63");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { useBasketControl } from "./useBasketControl";
|
||||
|
||||
const makeRef = <T>(val: T): React.RefObject<T> => ({
|
||||
current: val,
|
||||
});
|
||||
|
||||
describe("useBasketControl", () => {
|
||||
it("initialises basket x to half of window.innerWidth", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBasketControl(makeRef<HTMLCanvasElement | null>(null)),
|
||||
);
|
||||
expect(result.current.current).toBe(window.innerWidth / 2);
|
||||
});
|
||||
|
||||
it("does not attach listener when canvas is null", () => {
|
||||
const canvasRef = makeRef<HTMLCanvasElement | null>(null);
|
||||
// If the effect returned early (canvas null), no error and value unchanged.
|
||||
const { result } = renderHook(() => useBasketControl(canvasRef));
|
||||
expect(result.current.current).toBe(window.innerWidth / 2);
|
||||
});
|
||||
|
||||
it("updates basket x on mouse move within canvas", () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 800;
|
||||
canvas.getBoundingClientRect = () =>
|
||||
({ left: 0, top: 0 }) as DOMRect;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBasketControl(makeRef<HTMLCanvasElement | null>(canvas)),
|
||||
);
|
||||
|
||||
fireEvent.mouseMove(canvas, { clientX: 400 });
|
||||
expect(result.current.current).toBe(400);
|
||||
});
|
||||
|
||||
it("clamps basket x to minimum (BASKET_HALF_WIDTH = 60)", () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 800;
|
||||
canvas.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBasketControl(makeRef<HTMLCanvasElement | null>(canvas)),
|
||||
);
|
||||
|
||||
fireEvent.mouseMove(canvas, { clientX: 5 }); // x = 5 < 60
|
||||
expect(result.current.current).toBe(60);
|
||||
});
|
||||
|
||||
it("clamps basket x to maximum (canvas.width - 60)", () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 800;
|
||||
canvas.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBasketControl(makeRef<HTMLCanvasElement | null>(canvas)),
|
||||
);
|
||||
|
||||
fireEvent.mouseMove(canvas, { clientX: 790 }); // x = 790 > 800 - 60 = 740
|
||||
expect(result.current.current).toBe(740);
|
||||
});
|
||||
|
||||
it("removes event listener on unmount", () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 200;
|
||||
canvas.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
|
||||
const removeSpy = vi.spyOn(canvas, "removeEventListener");
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useBasketControl(makeRef<HTMLCanvasElement | null>(canvas)),
|
||||
);
|
||||
|
||||
unmount();
|
||||
expect(removeSpy).toHaveBeenCalledWith("mousemove", expect.any(Function));
|
||||
});
|
||||
});
|
||||
@ -1,32 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const BASKET_HALF_WIDTH = 60;
|
||||
|
||||
/**
|
||||
* Tracks mouse X on the canvas and keeps a ref updated with the clamped basket
|
||||
* centre X. Uses a ref (not state) to avoid re-renders inside the game loop.
|
||||
*/
|
||||
export function useBasketControl(
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>,
|
||||
): React.RefObject<number> {
|
||||
const basketXRef = useRef<number>(window.innerWidth / 2);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const onMouseMove = (e: MouseEvent): void => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
basketXRef.current = Math.max(
|
||||
BASKET_HALF_WIDTH,
|
||||
Math.min(canvas.width - BASKET_HALF_WIDTH, x),
|
||||
);
|
||||
};
|
||||
|
||||
canvas.addEventListener("mousemove", onMouseMove);
|
||||
return () => { canvas.removeEventListener("mousemove", onMouseMove); };
|
||||
}, [canvasRef]);
|
||||
|
||||
return basketXRef;
|
||||
}
|
||||
@ -1,208 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { useGameLoop } from "./useGameLoop";
|
||||
|
||||
const makeRef = <T>(val: T): React.RefObject<T> => ({ current: val });
|
||||
|
||||
describe("useGameLoop", () => {
|
||||
let rafCallbacks: FrameRequestCallback[];
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallbacks = [];
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((cb: FrameRequestCallback) => {
|
||||
rafCallbacks.push(cb);
|
||||
return rafCallbacks.length;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const flushTick = (): void => {
|
||||
const cb = rafCallbacks.pop();
|
||||
if (cb) act(() => { cb(0); });
|
||||
};
|
||||
|
||||
it("returns null when active=false and does not start RAF", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(null),
|
||||
makeRef(400),
|
||||
[],
|
||||
false,
|
||||
),
|
||||
);
|
||||
expect(result.current).toBeNull();
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when canvasRef.current is null (active=true)", () => {
|
||||
renderHook(() =>
|
||||
useGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(null),
|
||||
makeRef(400),
|
||||
[new File(["x"], "a.txt")],
|
||||
true,
|
||||
),
|
||||
);
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels RAF on unmount", () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 800;
|
||||
canvas.height = 600;
|
||||
const { unmount } = renderHook(() =>
|
||||
useGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(canvas),
|
||||
makeRef(400),
|
||||
[],
|
||||
true,
|
||||
),
|
||||
);
|
||||
unmount();
|
||||
expect(cancelAnimationFrame).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves immediately with empty result when files is empty", async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 800;
|
||||
canvas.height = 600;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(canvas),
|
||||
makeRef(400),
|
||||
[],
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
flushTick(); // allDone=true on first frame → setResult
|
||||
await waitFor(() => { expect(result.current).not.toBeNull(); });
|
||||
const settled = result.current;
|
||||
if (settled === null) {
|
||||
throw new Error('result never settled');
|
||||
}
|
||||
expect(settled.caught).toHaveLength(0);
|
||||
expect(settled.missed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("catches file and resolves result (caught branch + caught-draw branch)", async () => {
|
||||
// Math.random()=0 → file x=48, speed=2.5
|
||||
// basketXRef=48 → |fx-bx|=0<108, |fy-by|=35.5<48 → caught on tick 0
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 800;
|
||||
canvas.height = 90; // basketY = 90-80 = 10
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(canvas),
|
||||
makeRef(48),
|
||||
[new File(["x"], "a.txt")],
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
flushTick(); // tick 0: caught
|
||||
flushTick(); // tick 1: draw caught + allDone=true → setResult
|
||||
|
||||
await waitFor(() => { expect(result.current).not.toBeNull(); });
|
||||
const settled = result.current;
|
||||
if (settled === null) {
|
||||
throw new Error('result never settled');
|
||||
}
|
||||
expect(settled.caught).toHaveLength(1);
|
||||
expect(settled.missed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("misses file (still-falling + missed branches)", async () => {
|
||||
// Math.random()=0 → file x=48, speed=2.5; basketXRef=400
|
||||
// |48-400|=352 > 108 → no collision; off-bottom at y>118, ~60 ticks
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 800;
|
||||
canvas.height = 90;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(canvas),
|
||||
makeRef(400),
|
||||
[new File(["x"], "a.txt")],
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
for (let i = 0; i < 65; i++) {
|
||||
if (result.current !== null) break;
|
||||
flushTick();
|
||||
}
|
||||
|
||||
await waitFor(() => { expect(result.current).not.toBeNull(); });
|
||||
const settled = result.current;
|
||||
if (settled === null) {
|
||||
throw new Error('result never settled');
|
||||
}
|
||||
expect(settled.caught).toHaveLength(0);
|
||||
expect(settled.missed).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not start RAF when canvas.getContext returns null", () => {
|
||||
// A plain fake canvas whose getContext returns null avoids prototype contamination
|
||||
const fakeCanvas = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
getContext: () => null,
|
||||
} as unknown as HTMLCanvasElement;
|
||||
|
||||
renderHook(() =>
|
||||
useGameLoop(makeRef<HTMLCanvasElement | null>(fakeCanvas), makeRef(400), [], true),
|
||||
);
|
||||
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles not-yet-spawned branch with two files (startFrames 0 and 50)", async () => {
|
||||
// Both files: x=48, speed=2.5; basket at 48 → both caught
|
||||
// File 1 has startFrame=50 → "frame < ff.startFrame" branch hit for ticks 1-49
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 800;
|
||||
canvas.height = 90;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(canvas),
|
||||
makeRef(48),
|
||||
[new File(["a"], "a.txt"), new File(["b"], "b.txt")],
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
if (result.current !== null) break;
|
||||
flushTick();
|
||||
}
|
||||
|
||||
await waitFor(() => { expect(result.current).not.toBeNull(); });
|
||||
const settled = result.current;
|
||||
if (settled === null) {
|
||||
throw new Error('result never settled');
|
||||
}
|
||||
expect(settled.caught).toHaveLength(2);
|
||||
expect(settled.missed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@ -1,194 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { FallingFileItem, FileGameResult } from "../types";
|
||||
import { fileIcon, truncateFilename, formatSize } from "../lib/fileIcon";
|
||||
|
||||
const BASKET_HALF_WIDTH = 60;
|
||||
const BASKET_HEIGHT = 40;
|
||||
const FILE_HALF_W = 48;
|
||||
const FILE_HALF_H = 28;
|
||||
const SPAWN_INTERVAL = 50;
|
||||
const BASKET_Y_OFFSET = 80;
|
||||
|
||||
function buildFallingFiles(files: File[], canvasWidth: number): FallingFileItem[] {
|
||||
return files.map((file, i) => ({
|
||||
kind: "file" as const,
|
||||
id: `${file.name}-${i}`,
|
||||
file,
|
||||
x: FILE_HALF_W + Math.random() * (canvasWidth - FILE_HALF_W * 2),
|
||||
y: -FILE_HALF_H,
|
||||
speed: 2.5 + Math.random() * 3,
|
||||
startFrame: i * SPAWN_INTERVAL,
|
||||
status: "falling" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
function aabbCollision(
|
||||
fx: number,
|
||||
fy: number,
|
||||
bx: number,
|
||||
by: number,
|
||||
): boolean {
|
||||
return (
|
||||
Math.abs(fx - bx) < BASKET_HALF_WIDTH + FILE_HALF_W &&
|
||||
Math.abs(fy - by) < BASKET_HEIGHT / 2 + FILE_HALF_H
|
||||
);
|
||||
}
|
||||
|
||||
function drawBasket(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
): void {
|
||||
const h = BASKET_HEIGHT;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "#f472b6";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - BASKET_HALF_WIDTH, y - h / 2);
|
||||
ctx.lineTo(x - BASKET_HALF_WIDTH, y + h / 2);
|
||||
ctx.lineTo(x + BASKET_HALF_WIDTH, y + h / 2);
|
||||
ctx.lineTo(x + BASKET_HALF_WIDTH, y - h / 2);
|
||||
ctx.stroke();
|
||||
// Rim line above basket opening
|
||||
ctx.strokeStyle = "#e879f9";
|
||||
ctx.lineWidth = 6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - BASKET_HALF_WIDTH - 6, y - h / 2);
|
||||
ctx.lineTo(x + BASKET_HALF_WIDTH + 6, y - h / 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawFile(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
ff: FallingFileItem,
|
||||
caught: boolean,
|
||||
): void {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = caught ? 0.4 : 1;
|
||||
const w = FILE_HALF_W * 2;
|
||||
const h = FILE_HALF_H * 2;
|
||||
const x = ff.x - FILE_HALF_W;
|
||||
const y = ff.y - FILE_HALF_H;
|
||||
const r = 8;
|
||||
|
||||
ctx.fillStyle = "rgba(30, 27, 75, 0.85)";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, w, h, r);
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = caught ? "#6b7280" : "#818cf8";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, w, h, r);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.font = "18px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(fileIcon(ff.file), x + 4, ff.y);
|
||||
|
||||
ctx.fillStyle = caught ? "#6b7280" : "#e0e7ff";
|
||||
ctx.font = "11px monospace";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText(truncateFilename(ff.file.name, 10), x + 26, y + 4);
|
||||
|
||||
ctx.fillStyle = "#94a3b8";
|
||||
ctx.font = "10px monospace";
|
||||
ctx.textBaseline = "bottom";
|
||||
ctx.fillText(formatSize(ff.file.size), x + 26, y + h - 4);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the osu!catch game loop on the provided canvas.
|
||||
* Returns the game result when all files are resolved, or null while playing.
|
||||
*/
|
||||
export function useGameLoop(
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>,
|
||||
basketXRef: React.RefObject<number>,
|
||||
files: File[],
|
||||
active: boolean,
|
||||
): FileGameResult | null {
|
||||
const [result, setResult] = useState<FileGameResult | null>(null);
|
||||
const stateRef = useRef<FallingFileItem[]>([]);
|
||||
const frameRef = useRef<number>(0);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
const startLoop = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
/* istanbul ignore next */
|
||||
if (!ctx) return;
|
||||
|
||||
stateRef.current = buildFallingFiles(files, canvas.width);
|
||||
frameRef.current = 0;
|
||||
|
||||
const tick = (): void => {
|
||||
const frame = frameRef.current;
|
||||
frameRef.current = frame + 1;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
||||
grad.addColorStop(0, "#0f0c29");
|
||||
grad.addColorStop(1, "#302b63");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const basketX = basketXRef.current;
|
||||
const basketY = canvas.height - BASKET_Y_OFFSET;
|
||||
|
||||
drawBasket(ctx, basketX, basketY);
|
||||
|
||||
let allDone = true;
|
||||
for (const ff of stateRef.current) {
|
||||
if (ff.status !== "falling") {
|
||||
if (ff.status === "caught") drawFile(ctx, ff, true);
|
||||
continue;
|
||||
}
|
||||
if (frame < ff.startFrame) {
|
||||
allDone = false;
|
||||
continue;
|
||||
}
|
||||
allDone = false;
|
||||
ff.y += ff.speed;
|
||||
|
||||
if (aabbCollision(ff.x, ff.y, basketX, basketY)) {
|
||||
ff.status = "caught";
|
||||
} else if (ff.y > canvas.height + FILE_HALF_H) {
|
||||
ff.status = "missed";
|
||||
} else {
|
||||
drawFile(ctx, ff, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (allDone) {
|
||||
const caught = stateRef.current
|
||||
.filter((f) => f.status === "caught")
|
||||
.map((f) => f.file);
|
||||
const missed = stateRef.current
|
||||
.filter((f) => f.status === "missed")
|
||||
.map((f) => f.file);
|
||||
setResult({ caught, missed });
|
||||
return;
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}, [canvasRef, basketXRef, files]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
startLoop();
|
||||
return () => { cancelAnimationFrame(rafRef.current); };
|
||||
}, [active, startLoop]);
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,125 +0,0 @@
|
||||
/** Lifecycle and guard paths: when the loop declines to start, and teardown. */
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import type { PuzzlePiece } from "../types";
|
||||
import { usePuzzleGameLoop } from "./usePuzzleGameLoop";
|
||||
|
||||
vi.mock("../lib/sliceImage");
|
||||
import { sliceImage } from "../lib/sliceImage";
|
||||
import {
|
||||
flushPromises,
|
||||
installRafHarness,
|
||||
makeCanvas,
|
||||
makePiece,
|
||||
makeRef,
|
||||
} from "../test/puzzleLoopFixtures";
|
||||
|
||||
describe("usePuzzleGameLoop lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
installRafHarness();
|
||||
vi.mocked(sliceImage).mockResolvedValue([makePiece(0, 0)]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when active=false", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePuzzleGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(makeCanvas()),
|
||||
makeRef(400),
|
||||
new File([""], "img.png"),
|
||||
1,
|
||||
false,
|
||||
),
|
||||
);
|
||||
expect(result.current).toBeNull();
|
||||
expect(sliceImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when canvas is null", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePuzzleGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(null),
|
||||
makeRef(400),
|
||||
new File([""], "img.png"),
|
||||
1,
|
||||
true,
|
||||
),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(result.current).toBeNull();
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when unmounted before sliceImage resolves (cancelled branch)", async () => {
|
||||
let resolveSlice!: (pieces: PuzzlePiece[]) => void;
|
||||
vi.mocked(sliceImage).mockReturnValue(
|
||||
new Promise((res) => { resolveSlice = res; }),
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
usePuzzleGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(makeCanvas()),
|
||||
makeRef(400),
|
||||
new File([""], "img.png"),
|
||||
1,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
unmount(); // sets cancelled=true before sliceImage resolves
|
||||
await act(async () => {
|
||||
resolveSlice([makePiece(0, 0)]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels RAF on unmount after game started", async () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
usePuzzleGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(makeCanvas()),
|
||||
makeRef(400),
|
||||
new File([""], "img.png"),
|
||||
1,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
await flushPromises();
|
||||
unmount();
|
||||
expect(cancelAnimationFrame).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not start RAF when startLoop canvas.getContext returns null", async () => {
|
||||
// A plain fake canvas whose getContext returns null avoids prototype contamination
|
||||
const fakeCanvas = {
|
||||
width: 800,
|
||||
height: 300,
|
||||
clientWidth: 800,
|
||||
clientHeight: 300,
|
||||
getContext: () => null,
|
||||
} as unknown as HTMLCanvasElement;
|
||||
|
||||
renderHook(() =>
|
||||
usePuzzleGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(fakeCanvas),
|
||||
makeRef(400),
|
||||
new File([""], "img.png"),
|
||||
1,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
await flushPromises(); // sliceImage resolves → startLoop called → null ctx → early return
|
||||
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,196 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { FallingPuzzleItem, PuzzlePiece, PuzzleGameResult } from "../types";
|
||||
import { sliceImage } from "../lib/sliceImage";
|
||||
import {
|
||||
BASKET_Y_OFFSET,
|
||||
MIN_SPEED,
|
||||
PIECE_HALF_H,
|
||||
SPAWN_GAP,
|
||||
SPEED_RANGE,
|
||||
aabbCollision,
|
||||
assignXPositions,
|
||||
type ScheduledPiece,
|
||||
} from "./puzzleGeometry";
|
||||
import { drawBackdrop, drawBasket, drawHUD, drawPiece } from "./puzzleRender";
|
||||
|
||||
/**
|
||||
* Puzzle game loop.
|
||||
*
|
||||
* All pieces are scheduled up-front. Their x positions are assigned via
|
||||
* interval-graph clustering so that pieces arriving simultaneously share a
|
||||
* spatial cluster the basket can cover in one position — guaranteeing 100%
|
||||
* is always achievable. Multiple pieces fall simultaneously, creating an
|
||||
* exciting hectic visual while remaining fair.
|
||||
*
|
||||
* The clustering itself lives in `puzzleGeometry.ts` and the canvas drawing in
|
||||
* `puzzleRender.ts`; what remains here is the per-frame tick.
|
||||
*/
|
||||
export function usePuzzleGameLoop(
|
||||
canvasRef: RefObject<HTMLCanvasElement | null>,
|
||||
basketXRef: RefObject<number>,
|
||||
imageFile: File,
|
||||
gridSize: number,
|
||||
active: boolean,
|
||||
): PuzzleGameResult | null {
|
||||
const [result, setResult] = useState<PuzzleGameResult | null>(null);
|
||||
const scheduleRef = useRef<ScheduledPiece[]>([]);
|
||||
const activeItemsRef = useRef<FallingPuzzleItem[]>([]);
|
||||
const resolvedRef = useRef<FallingPuzzleItem[]>([]);
|
||||
const resolvedFrameMapRef = useRef<Map<string, number>>(new Map());
|
||||
const frameRef = useRef<number>(0);
|
||||
const rafRef = useRef<number>(0);
|
||||
const imgsRef = useRef<Map<string, HTMLImageElement>>(new Map());
|
||||
const totalRef = useRef<number>(0);
|
||||
|
||||
const startLoop = useCallback(
|
||||
(pieces: PuzzlePiece[], canvas: HTMLCanvasElement) => {
|
||||
const ctx = canvas.getContext("2d");
|
||||
/* istanbul ignore next */
|
||||
if (!ctx) return;
|
||||
|
||||
const basketY = canvas.height - BASKET_Y_OFFSET;
|
||||
|
||||
// Build schedule: staggered spawn times, random speeds.
|
||||
const scheduled: ScheduledPiece[] = pieces.map((piece, i) => ({
|
||||
piece,
|
||||
spawnFrame: i * SPAWN_GAP,
|
||||
speed: MIN_SPEED + Math.random() * SPEED_RANGE,
|
||||
x: 0, // will be filled by assignXPositions
|
||||
}));
|
||||
|
||||
// Assign x positions with the spatial clustering guarantee.
|
||||
assignXPositions(scheduled, basketY, canvas.width);
|
||||
|
||||
// Sort by spawn time (speeds vary, so original order isn't strictly sorted).
|
||||
scheduled.sort((a, b) => a.spawnFrame - b.spawnFrame);
|
||||
|
||||
scheduleRef.current = scheduled;
|
||||
activeItemsRef.current = [];
|
||||
resolvedRef.current = [];
|
||||
resolvedFrameMapRef.current = new Map();
|
||||
frameRef.current = 0;
|
||||
totalRef.current = pieces.length;
|
||||
|
||||
const tick = (): void => {
|
||||
const frame = frameRef.current;
|
||||
frameRef.current = frame + 1;
|
||||
|
||||
drawBackdrop(ctx, canvas);
|
||||
|
||||
const basketX = basketXRef.current;
|
||||
drawBasket(ctx, basketX, basketY);
|
||||
|
||||
// Spawn pieces whose time has come.
|
||||
while (
|
||||
scheduleRef.current.length > 0 &&
|
||||
frame >= scheduleRef.current[0].spawnFrame
|
||||
) {
|
||||
// The while condition already indexes [0] unasserted, so this is
|
||||
// the same non-null value; shift() only advances the queue.
|
||||
const s = scheduleRef.current[0];
|
||||
scheduleRef.current.shift();
|
||||
activeItemsRef.current.push({
|
||||
kind: "puzzle",
|
||||
id: `puzzle-${s.piece.row}-${s.piece.col}`,
|
||||
piece: s.piece,
|
||||
x: s.x,
|
||||
y: -PIECE_HALF_H,
|
||||
speed: s.speed,
|
||||
startFrame: frame,
|
||||
status: "falling",
|
||||
});
|
||||
}
|
||||
|
||||
// Update all falling pieces.
|
||||
const stillActive: FallingPuzzleItem[] = [];
|
||||
for (const item of activeItemsRef.current) {
|
||||
const img = imgsRef.current.get(`${item.piece.row}-${item.piece.col}`);
|
||||
item.y += item.speed;
|
||||
|
||||
if (aabbCollision(item.x, item.y, basketX, basketY)) {
|
||||
item.status = "caught";
|
||||
resolvedRef.current.push(item);
|
||||
resolvedFrameMapRef.current.set(item.id, frame);
|
||||
} else if (item.y > canvas.height + PIECE_HALF_H) {
|
||||
item.status = "missed";
|
||||
resolvedRef.current.push(item);
|
||||
resolvedFrameMapRef.current.set(item.id, frame);
|
||||
} else {
|
||||
drawPiece(ctx, item, img, false);
|
||||
stillActive.push(item);
|
||||
}
|
||||
}
|
||||
activeItemsRef.current = stillActive;
|
||||
|
||||
// Flash caught pieces briefly at their catch position.
|
||||
for (const item of resolvedRef.current) {
|
||||
if (item.status === "caught") {
|
||||
// id is always set in the map when piece is resolved (lines above)
|
||||
const rf = resolvedFrameMapRef.current.get(item.id)!;
|
||||
if (frame - rf < 30) {
|
||||
const img = imgsRef.current.get(
|
||||
`${item.piece.row}-${item.piece.col}`,
|
||||
);
|
||||
drawPiece(ctx, item, img, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const caughtCount = resolvedRef.current.filter(
|
||||
(p) => p.status === "caught",
|
||||
).length;
|
||||
drawHUD(
|
||||
ctx,
|
||||
canvas,
|
||||
caughtCount,
|
||||
totalRef.current,
|
||||
resolvedRef.current.length,
|
||||
);
|
||||
|
||||
if (
|
||||
activeItemsRef.current.length === 0 &&
|
||||
scheduleRef.current.length === 0
|
||||
) {
|
||||
const caught = resolvedRef.current.filter(
|
||||
(p) => p.status === "caught",
|
||||
);
|
||||
const missed = resolvedRef.current.filter(
|
||||
(p) => p.status === "missed",
|
||||
);
|
||||
setResult({ caughtPieces: caught, missedPieces: missed, gridSize });
|
||||
return;
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
},
|
||||
[basketXRef, gridSize],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void sliceImage(imageFile, gridSize).then((pieces) => {
|
||||
if (cancelled) return;
|
||||
pieces.forEach((piece) => {
|
||||
const img = new Image();
|
||||
img.src = piece.imageUrl;
|
||||
imgsRef.current.set(`${piece.row}-${piece.col}`, img);
|
||||
});
|
||||
startLoop(pieces, canvas);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [active, imageFile, gridSize, canvasRef, startLoop]);
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,222 +0,0 @@
|
||||
/**
|
||||
* Gameplay paths: catching, missing, the catch flash, piece rendering, and the
|
||||
* two x-position clustering branches in assignXPositions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { usePuzzleGameLoop } from "./usePuzzleGameLoop";
|
||||
|
||||
vi.mock("../lib/sliceImage");
|
||||
import { sliceImage } from "../lib/sliceImage";
|
||||
import {
|
||||
flushPromises,
|
||||
installRafHarness,
|
||||
makeCanvas,
|
||||
makePiece,
|
||||
makeRef,
|
||||
type FlushTick,
|
||||
} from "../test/puzzleLoopFixtures";
|
||||
|
||||
describe("usePuzzleGameLoop play", () => {
|
||||
let flushTick: FlushTick;
|
||||
|
||||
beforeEach(() => {
|
||||
flushTick = installRafHarness();
|
||||
vi.mocked(sliceImage).mockResolvedValue([makePiece(0, 0)]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("catches single piece and resolves result (solo x branch, drawPiece no-img branch)", async () => {
|
||||
// Math.random()=0 → speed=3, x=48 (solo: minX + 0*(maxX-minX) = 48)
|
||||
// basketXRef=48 → caught on tick 0; game ends on same tick
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
vi.mocked(sliceImage).mockResolvedValue([makePiece(0, 0)]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePuzzleGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(makeCanvas(800, 90)),
|
||||
makeRef(48),
|
||||
new File([""], "img.png"),
|
||||
1,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
await flushPromises(); // let sliceImage resolve and startLoop run
|
||||
flushTick(); // tick 0: spawn, move, caught, flash, allDone → setResult
|
||||
|
||||
await waitFor(() => { expect(result.current).not.toBeNull(); });
|
||||
const settled = result.current;
|
||||
if (settled === null) {
|
||||
throw new Error('result never settled');
|
||||
}
|
||||
expect(settled.caughtPieces).toHaveLength(1);
|
||||
expect(settled.missedPieces).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("misses piece (still-falling + missed branches)", async () => {
|
||||
// Math.random()=0 → speed=3, x=48; basket at 500 → no collision
|
||||
// piece off bottom at y>90+48=138 → ~62 ticks
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePuzzleGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(makeCanvas(800, 90)),
|
||||
makeRef(500),
|
||||
new File([""], "img.png"),
|
||||
1,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
await flushPromises();
|
||||
for (let i = 0; i < 70; i++) {
|
||||
if (result.current !== null) break;
|
||||
flushTick();
|
||||
}
|
||||
|
||||
await waitFor(() => { expect(result.current).not.toBeNull(); });
|
||||
const settled = result.current;
|
||||
if (settled === null) {
|
||||
throw new Error('result never settled');
|
||||
}
|
||||
expect(settled.caughtPieces).toHaveLength(0);
|
||||
expect(settled.missedPieces).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("tests flash-during and flash-expired branches with 2 pieces", async () => {
|
||||
// Piece 0: x=48, speed=3, spawnFrame=0 → caught by basket@48 on tick 0
|
||||
// Piece 1: x=168, speed=3, spawnFrame=20 → |168-48|=120>108 → missed at tick~82
|
||||
// Flash: piece 0 caught at frame 0; at frame 30+ flash expires
|
||||
vi.spyOn(Math, "random")
|
||||
.mockReturnValueOnce(0) // speed[0] = 3
|
||||
.mockReturnValueOnce(0) // speed[1] = 3
|
||||
.mockReturnValueOnce(0) // centerX = 108 (108 + 0*(692-108))
|
||||
.mockReturnValueOnce(0) // x[0] = 108 + (0*2-1)*60 = 48
|
||||
.mockReturnValueOnce(1); // x[1] = 108 + (1*2-1)*60 = 168
|
||||
|
||||
vi.mocked(sliceImage).mockResolvedValue([
|
||||
makePiece(0, 0),
|
||||
{ ...makePiece(0, 1), gridSize: 2 },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePuzzleGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(makeCanvas(800, 90)),
|
||||
makeRef(48),
|
||||
new File([""], "img.png"),
|
||||
2,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
await flushPromises();
|
||||
for (let i = 0; i < 90; i++) {
|
||||
if (result.current !== null) break;
|
||||
flushTick();
|
||||
}
|
||||
|
||||
await waitFor(() => { expect(result.current).not.toBeNull(); });
|
||||
const settled = result.current;
|
||||
if (settled === null) {
|
||||
throw new Error('result never settled');
|
||||
}
|
||||
expect(settled.caughtPieces).toHaveLength(1);
|
||||
expect(settled.missedPieces).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses drawImage when piece image is loaded (img.complete=true branch)", async () => {
|
||||
// Class constructor — arrow functions can't be used with `new`
|
||||
class FakeImage {
|
||||
complete = true;
|
||||
naturalWidth = 100;
|
||||
src = "";
|
||||
}
|
||||
vi.stubGlobal("Image", FakeImage);
|
||||
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
|
||||
// Use large canvas so piece doesn't collide on first tick
|
||||
const canvas = makeCanvas(800, 600); // basketY=520; piece reaches it at tick~190
|
||||
renderHook(() =>
|
||||
usePuzzleGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(canvas),
|
||||
makeRef(500), // far from x=48 → piece will miss
|
||||
new File([""], "img.png"),
|
||||
1,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// Run one tick — piece is still falling, drawPiece called with loaded img
|
||||
flushTick();
|
||||
|
||||
// Verify drawImage was called (piece has complete+naturalWidth>0)
|
||||
const { mockCtx } = await import("../test/canvasMock");
|
||||
expect(mockCtx.drawImage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers Union-Find path compression with 4 overlapping pieces", async () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
vi.mocked(sliceImage).mockResolvedValue([
|
||||
makePiece(0, 0),
|
||||
makePiece(0, 1),
|
||||
makePiece(1, 0),
|
||||
makePiece(1, 1),
|
||||
]);
|
||||
|
||||
// basketY = 300-80 = 220; all 4 pieces overlap (SPAWN_GAP=20 and speed=3)
|
||||
const canvas = makeCanvas(800, 300);
|
||||
const { unmount } = renderHook(() =>
|
||||
usePuzzleGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(canvas),
|
||||
makeRef(48),
|
||||
new File([""], "img.png"),
|
||||
2,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
await flushPromises(); // sliceImage resolves → assignXPositions (path compression) runs
|
||||
flushTick(); // one tick to confirm loop is running
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("uses canvasW/2 for centerX when canvas is too narrow", async () => {
|
||||
// canvas.width=200: centerMin=108 >= centerMax=92 → uses 200/2=100
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.5);
|
||||
|
||||
vi.mocked(sliceImage).mockResolvedValue([
|
||||
makePiece(0, 0),
|
||||
makePiece(0, 1),
|
||||
]);
|
||||
|
||||
const canvas = makeCanvas(200, 90); // basketY=10
|
||||
// With centerX=100, both pieces at x=100 (offset = 0); basket@100 → caught
|
||||
const { result } = renderHook(() =>
|
||||
usePuzzleGameLoop(
|
||||
makeRef<HTMLCanvasElement | null>(canvas),
|
||||
makeRef(100),
|
||||
new File([""], "img.png"),
|
||||
2,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
await flushPromises();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (result.current !== null) break;
|
||||
flushTick();
|
||||
}
|
||||
|
||||
await waitFor(() => { expect(result.current).not.toBeNull(); });
|
||||
});
|
||||
});
|
||||
@ -1,85 +0,0 @@
|
||||
/* Design tokens — the shared source of truth every *.module.css file
|
||||
should reference instead of its own hardcoded hex literals. Previously
|
||||
absent entirely (rule 4), which is how the same page-background/heading
|
||||
gradients ended up copy-pasted verbatim across 4 files, one of them with
|
||||
the gradient stops reversed (PuzzleResult.module.css, and ModeSelect's
|
||||
puzzleStart button — same bug, two spots). */
|
||||
:root {
|
||||
/* Page background (3-stop gradient) */
|
||||
--bg-1: #0f0c29;
|
||||
--bg-2: #302b63;
|
||||
--bg-3: #24243e;
|
||||
--bg-gradient: linear-gradient(135deg, var(--bg-1), var(--bg-2), var(--bg-3));
|
||||
|
||||
/* Drag-over state (DropZone only) */
|
||||
--bg-drag-1: #1a1560;
|
||||
--bg-drag-2: #4a3f9e;
|
||||
--bg-drag-3: #3a3868;
|
||||
--bg-drag-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--bg-drag-1),
|
||||
var(--bg-drag-2),
|
||||
var(--bg-drag-3)
|
||||
);
|
||||
|
||||
/* Accent pair. --gradient-accent is the ONE canonical direction
|
||||
(indigo -> pink) for every heading/primary-button gradient in the
|
||||
app — always use this token, never write the stops out by hand. */
|
||||
--color-indigo: #818cf8;
|
||||
--color-indigo-rgb: 129, 140, 248;
|
||||
--color-pink: #f472b6;
|
||||
--color-pink-rgb: 244, 114, 182;
|
||||
--gradient-accent: linear-gradient(90deg, var(--color-indigo), var(--color-pink));
|
||||
|
||||
--color-indigo-light: #a5b4fc;
|
||||
--color-indigo-lighter: #c7d2fe;
|
||||
--color-pink-light: #f9a8d4;
|
||||
--color-pink-lighter: #e879f9;
|
||||
|
||||
--color-text: #e0e7ff;
|
||||
--color-text-rgb: 224, 231, 255;
|
||||
|
||||
/* Raised-surface fill (cards, file rows, the puzzle grid) — one base
|
||||
color at different opacities/solidity, not independent literals. */
|
||||
--surface-rgb: 30, 27, 75;
|
||||
--surface: rgba(var(--surface-rgb), 0.7);
|
||||
--surface-solid: rgb(var(--surface-rgb));
|
||||
|
||||
--color-success: #34d399;
|
||||
--color-error: #f87171;
|
||||
|
||||
/* Muted grays — was 4 unrelated shades (rule 2); collapsed to 2 named,
|
||||
semantically distinct roles: body-adjacent secondary text, and a
|
||||
slightly darker tertiary/caption tone. (Borders get their own token
|
||||
below, since one of them needed a contrast fix rule 7 flagged.) */
|
||||
--muted-text: #94a3b8;
|
||||
--muted-faint: #6b7280;
|
||||
--muted-border: #4b5563;
|
||||
|
||||
/* Spacing scale (4px base) */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 16px;
|
||||
--space-4: 24px;
|
||||
--space-5: 32px;
|
||||
--space-6: 40px;
|
||||
|
||||
/* Corner radius scale */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 16px;
|
||||
--radius-pill: 999px;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg-1);
|
||||
color: var(--color-text);
|
||||
}
|
||||
@ -1,115 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fileIcon, truncateFilename, formatSize } from "./fileIcon";
|
||||
|
||||
const makeFile = (name: string, type = "") =>
|
||||
new File([""], name, { type });
|
||||
|
||||
describe("fileIcon", () => {
|
||||
it("returns image emoji for image/* MIME", () => {
|
||||
expect(fileIcon(makeFile("a.png", "image/png"))).toBe("🖼️");
|
||||
});
|
||||
it("returns video emoji for video/* MIME", () => {
|
||||
expect(fileIcon(makeFile("a.mp4", "video/mp4"))).toBe("🎬");
|
||||
});
|
||||
it("returns audio emoji for audio/* MIME", () => {
|
||||
expect(fileIcon(makeFile("a.mp3", "audio/mpeg"))).toBe("🎵");
|
||||
});
|
||||
it("returns pdf emoji for application/pdf", () => {
|
||||
expect(fileIcon(makeFile("a.pdf", "application/pdf"))).toBe("📄");
|
||||
});
|
||||
it("returns archive emoji for application/zip", () => {
|
||||
expect(fileIcon(makeFile("a.zip", "application/zip"))).toBe("📦");
|
||||
});
|
||||
it("returns archive emoji for application/x-zip-compressed", () => {
|
||||
expect(fileIcon(makeFile("a.zip", "application/x-zip-compressed"))).toBe("📦");
|
||||
});
|
||||
it("returns archive emoji for .zip extension", () => {
|
||||
expect(fileIcon(makeFile("a.zip"))).toBe("📦");
|
||||
});
|
||||
it("returns archive emoji for .tar.gz extension", () => {
|
||||
expect(fileIcon(makeFile("a.tar.gz"))).toBe("📦");
|
||||
});
|
||||
it("returns archive emoji for .rar extension", () => {
|
||||
expect(fileIcon(makeFile("a.rar"))).toBe("📦");
|
||||
});
|
||||
it("returns code emoji for javascript MIME", () => {
|
||||
expect(fileIcon(makeFile("a.js", "application/javascript"))).toBe("💻");
|
||||
});
|
||||
it("returns code emoji for typescript MIME", () => {
|
||||
expect(fileIcon(makeFile("a.ts", "application/typescript"))).toBe("💻");
|
||||
});
|
||||
it("returns code emoji for .ts extension", () => {
|
||||
expect(fileIcon(makeFile("a.ts"))).toBe("💻");
|
||||
});
|
||||
it("returns code emoji for .tsx extension", () => {
|
||||
expect(fileIcon(makeFile("a.tsx"))).toBe("💻");
|
||||
});
|
||||
it("returns code emoji for .py extension", () => {
|
||||
expect(fileIcon(makeFile("a.py"))).toBe("💻");
|
||||
});
|
||||
it("returns code emoji for .go extension", () => {
|
||||
expect(fileIcon(makeFile("a.go"))).toBe("💻");
|
||||
});
|
||||
it("returns code emoji for .rs extension", () => {
|
||||
expect(fileIcon(makeFile("a.rs"))).toBe("💻");
|
||||
});
|
||||
it("returns code emoji for .c extension", () => {
|
||||
expect(fileIcon(makeFile("a.c"))).toBe("💻");
|
||||
});
|
||||
it("returns code emoji for .cpp extension", () => {
|
||||
expect(fileIcon(makeFile("a.cpp"))).toBe("💻");
|
||||
});
|
||||
it("returns code emoji for .java extension", () => {
|
||||
expect(fileIcon(makeFile("a.java"))).toBe("💻");
|
||||
});
|
||||
it("returns code emoji for .sh extension", () => {
|
||||
expect(fileIcon(makeFile("a.sh"))).toBe("💻");
|
||||
});
|
||||
it("returns text emoji for text/* MIME", () => {
|
||||
expect(fileIcon(makeFile("a.txt", "text/plain"))).toBe("📝");
|
||||
});
|
||||
it("returns text emoji for .md extension", () => {
|
||||
expect(fileIcon(makeFile("a.md"))).toBe("📝");
|
||||
});
|
||||
it("returns text emoji for .csv extension", () => {
|
||||
expect(fileIcon(makeFile("a.csv"))).toBe("📝");
|
||||
});
|
||||
it("returns text emoji for .log extension", () => {
|
||||
expect(fileIcon(makeFile("a.log"))).toBe("📝");
|
||||
});
|
||||
it("returns generic emoji for unknown type", () => {
|
||||
expect(fileIcon(makeFile("a.xyz"))).toBe("📎");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateFilename", () => {
|
||||
it("returns name unchanged when short enough", () => {
|
||||
expect(truncateFilename("hello.txt", 18)).toBe("hello.txt");
|
||||
});
|
||||
it("uses default max of 18", () => {
|
||||
expect(truncateFilename("short")).toBe("short");
|
||||
});
|
||||
it("truncates long name with extension", () => {
|
||||
const result = truncateFilename("averylongfilename.txt", 18);
|
||||
expect(result.length).toBeLessThanOrEqual(18);
|
||||
expect(result).toContain("…");
|
||||
expect(result).toContain(".txt");
|
||||
});
|
||||
it("truncates long name without extension", () => {
|
||||
const result = truncateFilename("averylongnamenoext", 10);
|
||||
expect(result.length).toBeLessThanOrEqual(10);
|
||||
expect(result).toContain("…");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSize", () => {
|
||||
it("formats bytes under 1 KB", () => {
|
||||
expect(formatSize(512)).toBe("512 B");
|
||||
});
|
||||
it("formats bytes under 1 MB as KB", () => {
|
||||
expect(formatSize(2048)).toBe("2.0 KB");
|
||||
});
|
||||
it("formats bytes 1 MB and over as MB", () => {
|
||||
expect(formatSize(2 * 1024 * 1024)).toBe("2.0 MB");
|
||||
});
|
||||
});
|
||||
@ -1,46 +0,0 @@
|
||||
/** Returns an emoji representing the file type based on MIME or extension. */
|
||||
export function fileIcon(file: File): string {
|
||||
const mime = file.type;
|
||||
if (mime.startsWith("image/")) return "🖼️";
|
||||
if (mime.startsWith("video/")) return "🎬";
|
||||
if (mime.startsWith("audio/")) return "🎵";
|
||||
if (mime === "application/pdf") return "📄";
|
||||
if (
|
||||
mime === "application/zip" ||
|
||||
mime === "application/x-zip-compressed" ||
|
||||
file.name.endsWith(".zip") ||
|
||||
file.name.endsWith(".tar.gz") ||
|
||||
file.name.endsWith(".rar")
|
||||
)
|
||||
return "📦";
|
||||
if (
|
||||
mime.includes("javascript") ||
|
||||
mime.includes("typescript") ||
|
||||
(/\.(ts|tsx|js|jsx|py|go|rs|c|cpp|java|sh)$/.exec(file.name))
|
||||
)
|
||||
return "💻";
|
||||
if (
|
||||
mime.includes("text") ||
|
||||
(/\.(txt|md|csv|log)$/.exec(file.name))
|
||||
)
|
||||
return "📝";
|
||||
return "📎";
|
||||
}
|
||||
|
||||
/** Truncate a filename to at most `max` characters, keeping extension. */
|
||||
export function truncateFilename(name: string, max = 18): string {
|
||||
if (name.length <= max) return name;
|
||||
const dot = name.lastIndexOf(".");
|
||||
if (dot > 0) {
|
||||
const ext = name.slice(dot);
|
||||
return name.slice(0, max - ext.length - 1) + "…" + ext;
|
||||
}
|
||||
return name.slice(0, max - 1) + "…";
|
||||
}
|
||||
|
||||
/** Human-readable file size. */
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { MockInstance } from "vitest";
|
||||
import { sliceImage } from "./sliceImage";
|
||||
|
||||
// Captures the last Image instance created so tests can trigger onload/onerror.
|
||||
let capturedImg: {
|
||||
onload: (() => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
class FakeImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
src = "";
|
||||
width = 200;
|
||||
height = 100;
|
||||
constructor() {
|
||||
capturedImg = this;
|
||||
}
|
||||
}
|
||||
|
||||
describe("sliceImage", () => {
|
||||
let revokeUrlSpy: MockInstance<typeof URL.revokeObjectURL>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("Image", FakeImage);
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock");
|
||||
revokeUrlSpy = vi
|
||||
.spyOn(URL, "revokeObjectURL")
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("resolves with pieces when image loads", async () => {
|
||||
const file = new File([""], "img.png", { type: "image/png" });
|
||||
const promise = sliceImage(file, 2);
|
||||
|
||||
// Trigger onload synchronously — sliceImage has already assigned it.
|
||||
capturedImg.onload?.();
|
||||
|
||||
const pieces = await promise;
|
||||
expect(pieces).toHaveLength(4); // 2×2 grid
|
||||
expect(pieces[0]).toMatchObject({ row: 0, col: 0, gridSize: 2 });
|
||||
expect(pieces[3]).toMatchObject({ row: 1, col: 1, gridSize: 2 });
|
||||
expect(revokeUrlSpy).toHaveBeenCalledWith("blob:mock");
|
||||
});
|
||||
|
||||
it("includes pieceWidth and pieceHeight from image dimensions", async () => {
|
||||
const file = new File([""], "img.png");
|
||||
const promise = sliceImage(file, 2);
|
||||
capturedImg.onload?.();
|
||||
const pieces = await promise;
|
||||
// FakeImage.width=200, height=100, gridSize=2 → pieceWidth=100, pieceHeight=50
|
||||
expect(pieces[0].pieceWidth).toBe(100);
|
||||
expect(pieces[0].pieceHeight).toBe(50);
|
||||
});
|
||||
|
||||
it("resolves with 1 piece for 1×1 grid", async () => {
|
||||
const file = new File([""], "img.png");
|
||||
const promise = sliceImage(file, 1);
|
||||
capturedImg.onload?.();
|
||||
const pieces = await promise;
|
||||
expect(pieces).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects when offscreen canvas getContext returns null", async () => {
|
||||
// Save and override the prototype mock to return null for this test only
|
||||
const savedImpl = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = (() => null) as typeof HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
const file = new File([""], "img.png");
|
||||
const promise = sliceImage(file, 1);
|
||||
capturedImg.onload?.();
|
||||
await expect(promise).rejects.toThrow("Canvas 2D context not available");
|
||||
expect(revokeUrlSpy).toHaveBeenCalledWith("blob:mock");
|
||||
|
||||
// Restore so subsequent tests in this file have the mock context
|
||||
HTMLCanvasElement.prototype.getContext = savedImpl;
|
||||
});
|
||||
|
||||
it("rejects when image fails to load", async () => {
|
||||
const file = new File([""], "broken.jpg");
|
||||
const promise = sliceImage(file, 2);
|
||||
capturedImg.onerror?.();
|
||||
await expect(promise).rejects.toThrow("Failed to load image for slicing");
|
||||
expect(revokeUrlSpy).toHaveBeenCalledWith("blob:mock");
|
||||
});
|
||||
});
|
||||
@ -1,59 +0,0 @@
|
||||
import type { PuzzlePiece } from "../types";
|
||||
|
||||
/** Slice an image file into a gridSize×gridSize grid of data-URL pieces. */
|
||||
export function sliceImage(file: File, gridSize: number): Promise<PuzzlePiece[]> {
|
||||
return new Promise<PuzzlePiece[]>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
img.onload = () => {
|
||||
const pieceWidth = Math.floor(img.width / gridSize);
|
||||
const pieceHeight = Math.floor(img.height / gridSize);
|
||||
const pieces: PuzzlePiece[] = [];
|
||||
|
||||
for (let row = 0; row < gridSize; row++) {
|
||||
for (let col = 0; col < gridSize; col++) {
|
||||
const offscreen = document.createElement("canvas");
|
||||
offscreen.width = pieceWidth;
|
||||
offscreen.height = pieceHeight;
|
||||
const ctx = offscreen.getContext("2d");
|
||||
/* istanbul ignore next */
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("Canvas 2D context not available"));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(
|
||||
img,
|
||||
col * pieceWidth,
|
||||
row * pieceHeight,
|
||||
pieceWidth,
|
||||
pieceHeight,
|
||||
0,
|
||||
0,
|
||||
pieceWidth,
|
||||
pieceHeight,
|
||||
);
|
||||
pieces.push({
|
||||
row,
|
||||
col,
|
||||
gridSize,
|
||||
imageUrl: offscreen.toDataURL(),
|
||||
pieceWidth,
|
||||
pieceHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(pieces);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("Failed to load image for slicing"));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { uploadFiles } from "./uploadFiles";
|
||||
|
||||
describe("uploadFiles", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uploads multiple files sequentially and returns results", async () => {
|
||||
const mockResult = {
|
||||
filename: "stored.txt",
|
||||
originalname: "test.txt",
|
||||
size: 5,
|
||||
savedAt: "2024-01-01",
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResult),
|
||||
}),
|
||||
);
|
||||
|
||||
const files = [
|
||||
new File(["hello"], "a.txt"),
|
||||
new File(["world"], "b.txt"),
|
||||
];
|
||||
const results = await uploadFiles(files);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it("throws when server returns non-ok response", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
statusText: "Bad Request",
|
||||
}),
|
||||
);
|
||||
|
||||
const files = [new File(["x"], "fail.txt")];
|
||||
await expect(uploadFiles(files)).rejects.toThrow(
|
||||
"Upload failed for fail.txt: Bad Request",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
const results = await uploadFiles([]);
|
||||
expect(results).toEqual([]);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,28 +0,0 @@
|
||||
const BACKEND_URL = "http://localhost:3000/files/upload";
|
||||
|
||||
export interface UploadResult {
|
||||
filename: string;
|
||||
originalname: string;
|
||||
size: number;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
/** Upload a single file to the backend. */
|
||||
async function uploadOne(file: File): Promise<UploadResult> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch(BACKEND_URL, { method: "POST", body: form });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Upload failed for ${file.name}: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<UploadResult>;
|
||||
}
|
||||
|
||||
/** Upload all caught files sequentially to the backend. */
|
||||
export async function uploadFiles(files: File[]): Promise<UploadResult[]> {
|
||||
const results: UploadResult[] = [];
|
||||
for (const file of files) {
|
||||
results.push(await uploadOne(file));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { MockInstance } from "vitest";
|
||||
import { zipDownload } from "./zipDownload";
|
||||
|
||||
describe("zipDownload", () => {
|
||||
let createUrlSpy: MockInstance<typeof URL.createObjectURL>;
|
||||
let revokeUrlSpy: MockInstance<typeof URL.revokeObjectURL>;
|
||||
let clickSpy: MockInstance<typeof HTMLAnchorElement.prototype.click>;
|
||||
|
||||
beforeEach(() => {
|
||||
createUrlSpy = vi
|
||||
.spyOn(URL, "createObjectURL")
|
||||
.mockReturnValue("blob:mock-url");
|
||||
revokeUrlSpy = vi
|
||||
.spyOn(URL, "revokeObjectURL")
|
||||
.mockImplementation(() => undefined);
|
||||
clickSpy = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates a zip blob, triggers download, and cleans up", async () => {
|
||||
const appendSpy = vi.spyOn(document.body, "appendChild");
|
||||
const removeSpy = vi.spyOn(document.body, "removeChild");
|
||||
|
||||
const file = new File(["hello"], "hello.txt");
|
||||
await zipDownload([file]);
|
||||
|
||||
expect(createUrlSpy).toHaveBeenCalled();
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
expect(revokeUrlSpy).toHaveBeenCalledWith("blob:mock-url");
|
||||
expect(appendSpy).toHaveBeenCalled();
|
||||
expect(removeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets download attribute and href on the anchor", async () => {
|
||||
const anchors: HTMLAnchorElement[] = [];
|
||||
// Bound before the spy replaces it, so the implementation below can build
|
||||
// a real element without recursing into its own mock.
|
||||
const realCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
|
||||
const el = realCreateElement(tag);
|
||||
if (el instanceof HTMLAnchorElement) anchors.push(el);
|
||||
return el;
|
||||
});
|
||||
// Restore after spy above since we used bind trick. That discards the
|
||||
// handles from beforeEach, so re-spy and keep the new ones.
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url");
|
||||
const localRevokeSpy = vi
|
||||
.spyOn(URL, "revokeObjectURL")
|
||||
.mockImplementation(() => undefined);
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
const file = new File(["x"], "data.bin");
|
||||
await zipDownload([file]);
|
||||
|
||||
expect(localRevokeSpy).toHaveBeenCalledWith("blob:mock-url");
|
||||
});
|
||||
|
||||
it("works with multiple files", async () => {
|
||||
const files = [
|
||||
new File(["a"], "a.txt"),
|
||||
new File(["b"], "b.txt"),
|
||||
new File(["c"], "c.png", { type: "image/png" }),
|
||||
];
|
||||
await expect(zipDownload(files)).resolves.toBeUndefined();
|
||||
expect(createUrlSpy).toHaveBeenCalledTimes(1);
|
||||
expect(revokeUrlSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -1,18 +0,0 @@
|
||||
import JSZip from "jszip";
|
||||
|
||||
/** Bundle the given files into a zip and trigger a browser download. */
|
||||
export async function zipDownload(files: File[]): Promise<void> {
|
||||
const zip = new JSZip();
|
||||
for (const file of files) {
|
||||
zip.file(file.name, file);
|
||||
}
|
||||
const blob = await zip.generateAsync({ type: "blob" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "caught-files.zip";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("Missing #root element");
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@ -1,30 +0,0 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const mockGradient = { addColorStop: vi.fn() };
|
||||
|
||||
export const mockCtx = {
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 0 })),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
createLinearGradient: vi.fn(() => mockGradient),
|
||||
fillStyle: "" as string | CanvasGradient | CanvasPattern,
|
||||
strokeStyle: "" as string | CanvasGradient | CanvasPattern,
|
||||
lineWidth: 1,
|
||||
lineJoin: "miter" as CanvasLineJoin,
|
||||
font: "",
|
||||
textAlign: "start" as CanvasTextAlign,
|
||||
textBaseline: "alphabetic" as CanvasTextBaseline,
|
||||
globalAlpha: 1,
|
||||
};
|
||||
@ -1,21 +0,0 @@
|
||||
/**
|
||||
* DOM query helpers shared by the component tests.
|
||||
*
|
||||
* Lives under src/test/ because the coverage config excludes that path — a
|
||||
* helper placed beside a component would be counted as production code and
|
||||
* break the 100% gate.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Query one element and narrow it, failing the test if it is absent.
|
||||
*
|
||||
* Both `as HTMLElement` and `!` are rejected here — the former by
|
||||
* non-nullable-type-assertion-style, the latter by no-non-null-assertion — so
|
||||
* the narrowing is done with a real check that also gives a useful message.
|
||||
*/
|
||||
export const must = (el: Element | null, what: string): HTMLElement => {
|
||||
if (!(el instanceof HTMLElement)) {
|
||||
throw new Error(`expected to find ${what}`);
|
||||
}
|
||||
return el;
|
||||
};
|
||||
@ -1,63 +0,0 @@
|
||||
import { act } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import type { PuzzlePiece } from "../types";
|
||||
|
||||
/**
|
||||
* Shared fixtures for the usePuzzleGameLoop tests.
|
||||
*
|
||||
* Lives under src/test/ because the coverage config excludes that path — a
|
||||
* fixture placed beside the hook would be counted as production code and break
|
||||
* the 100% gate.
|
||||
*/
|
||||
|
||||
export const makeRef = <T>(val: T): React.RefObject<T> => ({
|
||||
current: val,
|
||||
});
|
||||
|
||||
export const makePiece = (row: number, col: number): PuzzlePiece => ({
|
||||
row,
|
||||
col,
|
||||
gridSize: 1,
|
||||
imageUrl: "data:image/png;base64,mock",
|
||||
pieceWidth: 50,
|
||||
pieceHeight: 50,
|
||||
});
|
||||
|
||||
export const makeCanvas = (w = 800, h = 90): HTMLCanvasElement => {
|
||||
const c = document.createElement("canvas");
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
return c;
|
||||
};
|
||||
|
||||
/** Drives one queued animation frame, if any is pending. */
|
||||
export type FlushTick = () => void;
|
||||
|
||||
/**
|
||||
* Installs the requestAnimationFrame harness the loop tests drive by hand.
|
||||
*
|
||||
* Returns a `flushTick` bound to this suite's own queue, so each describe block
|
||||
* gets an isolated set of callbacks rather than sharing module state.
|
||||
*
|
||||
* @returns A function that runs the most recently queued frame.
|
||||
*/
|
||||
export function installRafHarness(): FlushTick {
|
||||
const rafCallbacks: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((cb: FrameRequestCallback) => {
|
||||
rafCallbacks.push(cb);
|
||||
return rafCallbacks.length;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
return () => {
|
||||
const cb = rafCallbacks.pop();
|
||||
if (cb) act(() => { cb(0); });
|
||||
};
|
||||
}
|
||||
|
||||
/** Lets pending promise callbacks settle inside act(). */
|
||||
export const flushPromises = async (): Promise<void> => {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
};
|
||||
@ -1,15 +0,0 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi, beforeEach } from "vitest";
|
||||
import { mockCtx } from "./canvasMock";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn(
|
||||
() => mockCtx,
|
||||
) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
HTMLCanvasElement.prototype.toDataURL = vi.fn(
|
||||
() => "data:image/png;base64,mock",
|
||||
);
|
||||
@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Where the app is. The done phase carries its result, so "finished with no
|
||||
* result" cannot be represented and the render never has to assert one away.
|
||||
*/
|
||||
export type GamePhase =
|
||||
| { kind: "drop" }
|
||||
| { kind: "mode" }
|
||||
| { kind: "playing" }
|
||||
| { kind: "done"; result: GameResult };
|
||||
|
||||
export type TransferMode = "download" | "upload" | "puzzle";
|
||||
|
||||
export interface FallingFileItem {
|
||||
readonly kind: "file";
|
||||
readonly id: string;
|
||||
readonly file: File;
|
||||
x: number;
|
||||
y: number;
|
||||
readonly speed: number;
|
||||
readonly startFrame: number;
|
||||
status: "falling" | "caught" | "missed";
|
||||
}
|
||||
|
||||
export interface PuzzlePiece {
|
||||
readonly row: number;
|
||||
readonly col: number;
|
||||
readonly gridSize: number;
|
||||
readonly imageUrl: string;
|
||||
readonly pieceWidth: number;
|
||||
readonly pieceHeight: number;
|
||||
}
|
||||
|
||||
export interface FallingPuzzleItem {
|
||||
readonly kind: "puzzle";
|
||||
readonly id: string;
|
||||
readonly piece: PuzzlePiece;
|
||||
x: number;
|
||||
y: number;
|
||||
readonly speed: number;
|
||||
readonly startFrame: number;
|
||||
status: "falling" | "caught" | "missed";
|
||||
}
|
||||
|
||||
export type FallingItem = FallingFileItem | FallingPuzzleItem;
|
||||
|
||||
export interface BasketState {
|
||||
x: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface FileGameResult {
|
||||
caught: File[];
|
||||
missed: File[];
|
||||
}
|
||||
|
||||
export interface PuzzleGameResult {
|
||||
caughtPieces: FallingPuzzleItem[];
|
||||
missedPieces: FallingPuzzleItem[];
|
||||
gridSize: number;
|
||||
}
|
||||
|
||||
/** The outcome of a finished game, tagged by which mode produced it. */
|
||||
export type GameResult =
|
||||
| { kind: "file"; value: FileGameResult }
|
||||
| { kind: "puzzle"; value: PuzzleGameResult };
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "esnext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
// vitest 4 no longer contributes the `test` key to vite's own defineConfig, so
|
||||
// the config is typed by importing defineConfig from vitest/config instead of
|
||||
// the (now inert) triple-slash reference.
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: ["src/main.tsx", "src/test/**"],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,2 +0,0 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
@ -60,11 +60,11 @@ file must be repointed in the same step. Re-run the script with
|
||||
|
||||
`meta/scripts/check_directory_depth.sh --all` at baseline:
|
||||
|
||||
| Bucket | Files |
|
||||
|---|---|
|
||||
| Total violations | 556 |
|
||||
| Disappear with the archives/extractions | 349 |
|
||||
| **Remain — the step D worklist** | **207** |
|
||||
| Bucket | Files |
|
||||
| --------------------------------------- | ------- |
|
||||
| Total violations | 556 |
|
||||
| Disappear with the archives/extractions | 349 |
|
||||
| **Remain — the step D worklist** | **207** |
|
||||
|
||||
Step D worklist by directory:
|
||||
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "Archive bucket_catch and poker-stakes to testsAndMisc-archive",
|
||||
"objective": "Remove two self-contained apps from testsAndMisc, preserving their full git history in kuhyx/testsAndMisc-archive, as the zero-risk opening step of the repo restructure. Neither unit is referenced by any other code, systemd unit, or symlink, which is why they go first.",
|
||||
"acceptance_criteria": [
|
||||
"Both directories are present in kuhyx/testsAndMisc-archive at their original repo-relative paths, confirmed through the GitHub API rather than a local clone.",
|
||||
"The archived commit count for each unit equals the number of monorepo commits touching it: bucket_catch 10, poker-stakes 2.",
|
||||
"diff -r between the archived copy and the pre-removal working tree reports no content differences for either unit.",
|
||||
"Tracked file counts match exactly: bucket_catch 64, poker-stakes 65.",
|
||||
".github/workflows/poker-stakes-ci.yml is removed in the same commit as the app it builds, so no workflow is left pointing at a missing path.",
|
||||
"No file under .github/workflows/ still references either unit.",
|
||||
"meta/scripts/report_live_state.sh output is unchanged, proving neither unit had live system state pointing at it.",
|
||||
"pre-commit passes without --no-verify."
|
||||
],
|
||||
"out_of_scope": [
|
||||
"billsplit, which is archived separately because it is coupled to python_pkg/billsplit_coverage and an app_icons registry entry that must be deleted in the same commit.",
|
||||
"Rewriting historical planning notes that mention these names; they are dated records, not live configuration.",
|
||||
"Wiring the directory-depth gate, which stays a dry run until the tree conforms."
|
||||
],
|
||||
"verifier": "gh api repos/kuhyx/testsAndMisc-archive/contents lists both units; git ls-files counts match source; diff -r is clean; ./meta/scripts/check_directory_depth.sh --all drops from 556 to 451; ./meta/scripts/report_live_state.sh still reports 27 reference lines; pre-commit run over the staged set passes."
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
{
|
||||
"intent": "Archive bucket_catch and poker-stakes out of testsAndMisc into testsAndMisc-archive, with full per-directory git history preserved, as the first (zero-risk) step of the repo restructure.",
|
||||
"scope": [
|
||||
"bucket_catch/ (64 files) - removed",
|
||||
"poker-stakes/ (65 files) - removed",
|
||||
".github/workflows/poker-stakes-ci.yml - removed with its app",
|
||||
"AGENTS.md - repo layout table updated",
|
||||
"Non-goal: billsplit is archived separately, since it is coupled to python_pkg/billsplit_coverage and the app_icons registry"
|
||||
],
|
||||
"changes": [
|
||||
"Extracted each directory with git filter-repo --path, preserving every commit that touched it (bucket_catch 10, poker-stakes 2 - both matching git log counts in the monorepo).",
|
||||
"Merged both into kuhyx/testsAndMisc-archive with --allow-unrelated-histories, keeping original repo-relative paths as that archive already does for C/, CPP/, horatio/ and the rest.",
|
||||
"Removed both trees plus poker-stakes-ci.yml from the monorepo (130 files, 21869 deletions) and dropped their rows from the AGENTS.md layout table."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "git ls-files <unit> | wc -l, in archive vs source",
|
||||
"result": "pass",
|
||||
"evidence": "bucket_catch 64 = 64, poker-stakes 65 = 65."
|
||||
},
|
||||
{
|
||||
"command": "diff -r --brief <archive>/<unit> <source>/<unit>",
|
||||
"result": "pass",
|
||||
"evidence": "No content differences for either unit (excluding untracked node_modules/build dirs that were never tracked)."
|
||||
},
|
||||
{
|
||||
"command": "gh api repos/kuhyx/testsAndMisc-archive/contents --jq '.[].name'",
|
||||
"result": "pass",
|
||||
"evidence": "bucket_catch and poker-stakes both listed alongside the pre-existing C, CPP, TS, horatio, linux_configuration, pomodoro_app, python_pkg, robotgo_demo, sonic_pi."
|
||||
},
|
||||
{
|
||||
"command": "./meta/scripts/check_directory_depth.sh --all",
|
||||
"result": "pass (improvement)",
|
||||
"evidence": "Depth violations dropped 556 -> 451, consistent with the 105 deep files these two units contributed."
|
||||
},
|
||||
{
|
||||
"command": "grep -rln 'bucket_catch|poker-stakes' .github/workflows/",
|
||||
"result": "pass",
|
||||
"evidence": "No matches - no workflow still references a removed unit."
|
||||
},
|
||||
{
|
||||
"command": "./meta/scripts/report_live_state.sh",
|
||||
"result": "pass",
|
||||
"evidence": "27 live reference lines, unchanged from baseline: neither unit had any systemd unit, /etc config or symlink pointing at it."
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"The archive push is the only copy of these trees outside this repo's history; verified present via the GitHub API before deleting locally.",
|
||||
"Historical planning notes (NEXT_SESSION_PROMPT.md, refactor_claude_todo_resume.md, docs/app-split-recipes.md) still mention both names. Left as-is: they are dated records of past work, not live configuration."
|
||||
],
|
||||
"rollback": [
|
||||
"git revert the removal commit to restore both trees and the workflow.",
|
||||
"The archive repo keeps its copy either way; re-verify with pre-commit run --all-files after any revert."
|
||||
]
|
||||
}
|
||||
3
poker-stakes/.gitignore
vendored
3
poker-stakes/.gitignore
vendored
@ -1,3 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
@ -1,47 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import react from '@eslint-react/eslint-plugin'
|
||||
import vitest from '@vitest/eslint-plugin'
|
||||
import { defineConfig } from 'eslint/config'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default defineConfig(
|
||||
{ ignores: ['dist', 'coverage', 'node_modules'] },
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.strictTypeChecked,
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
react.configs['strict-type-checked'],
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': 'error',
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
'@typescript-eslint/no-shadow': 'error',
|
||||
'no-console': 'error',
|
||||
'eqeqeq': ['error', 'always'],
|
||||
'prefer-const': ['error', { destructuring: 'all' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.test.{ts,tsx}', 'src/test/**/*.ts'],
|
||||
extends: [vitest.configs.recommended],
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
},
|
||||
{
|
||||
files: ['eslint.config.js'],
|
||||
extends: [tseslint.configs.disableTypeChecked],
|
||||
},
|
||||
)
|
||||
@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Poker Stakes — lose the hand, pay the price</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4852
poker-stakes/package-lock.json
generated
4852
poker-stakes/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "poker-stakes",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"test": "vitest run",
|
||||
"coverage": "vitest run --coverage",
|
||||
"check": "npm run typecheck && npm run lint && npm run coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint-react/eslint-plugin": "5.18.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@testing-library/jest-dom": "7.0.0",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@vitejs/plugin-react": "6.0.5",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/eslint-plugin": "1.6.24",
|
||||
"eslint": "10.8.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.3",
|
||||
"globals": "17.8.0",
|
||||
"jsdom": "30.0.1",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.65.0",
|
||||
"vite": "8.2.0",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@ -1,196 +0,0 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { DEBTS_STORAGE_KEY } from './stakes/storage'
|
||||
import type { SettledDebt } from './stakes/types'
|
||||
import { App } from './App'
|
||||
|
||||
/** In-memory Storage so each test gets an isolated ledger, never the real localStorage. */
|
||||
const memoryStorage = (seed: Record<string, string> = {}): Storage => {
|
||||
const map = new Map<string, string>(Object.entries(seed))
|
||||
return {
|
||||
get length(): number {
|
||||
return map.size
|
||||
},
|
||||
clear: (): void => {
|
||||
map.clear()
|
||||
},
|
||||
getItem: (key: string): string | null => map.get(key) ?? null,
|
||||
key: (index: number): string | null => [...map.keys()][index] ?? null,
|
||||
removeItem: (key: string): void => {
|
||||
map.delete(key)
|
||||
},
|
||||
setItem: (key: string, value: string): void => {
|
||||
map.set(key, value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const debt = (overrides: Partial<SettledDebt> = {}): SettledDebt => ({
|
||||
id: 'd1',
|
||||
taskTypeId: 'pushups',
|
||||
owedUnits: 100,
|
||||
reason: 'cashOut',
|
||||
settledAt: 1,
|
||||
paidUnits: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const storageWithDebts = (debts: SettledDebt[]): Storage =>
|
||||
memoryStorage({ [DEBTS_STORAGE_KEY]: JSON.stringify(debts) })
|
||||
|
||||
/** BuyInScreen's pre-filled chip count — the value every test below sits down with. */
|
||||
const DEFAULT_BUY_IN = 100
|
||||
|
||||
/** The betting controls, or null between hands / before any session exists. */
|
||||
const bettingControls = (): HTMLElement | null => document.querySelector<HTMLElement>('.controls')
|
||||
|
||||
/** The first legal action currently offered, or null if no decision is pending. */
|
||||
const firstLegalAction = (): HTMLElement | null => {
|
||||
const controls = bettingControls()
|
||||
if (controls === null) {
|
||||
return null
|
||||
}
|
||||
return within(controls).getAllByRole('button')[0] ?? null
|
||||
}
|
||||
|
||||
/** Plays hands until the session ends, always taking the first legal action offered. */
|
||||
const playUntilSettled = async (user: ReturnType<typeof userEvent.setup>, maxActions = 400): Promise<void> => {
|
||||
for (let i = 0; i < maxActions; i += 1) {
|
||||
const deal = screen.queryByText('Deal next hand')
|
||||
if (deal !== null) {
|
||||
await user.click(deal)
|
||||
continue
|
||||
}
|
||||
const action = firstLegalAction()
|
||||
if (action === null) {
|
||||
return
|
||||
}
|
||||
await user.click(action)
|
||||
}
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('renders the title and the buy-in screen before any session starts', () => {
|
||||
render(<App storage={memoryStorage()} seed={1} />)
|
||||
expect(screen.getByText('Poker Stakes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sit down')).toBeInTheDocument()
|
||||
expect(screen.getByText('No debts settled yet.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to the real localStorage and a clock-derived seed when no props are given', () => {
|
||||
render(<App />)
|
||||
expect(screen.getByText('Poker Stakes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sit down')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('buys in and deals the opening hand straight away', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<App storage={memoryStorage()} seed={7} />)
|
||||
await user.click(screen.getByText('Sit down'))
|
||||
expect(screen.queryByText('Sit down')).not.toBeInTheDocument()
|
||||
// Sitting down deals the opening hand immediately, so the player faces a decision at once.
|
||||
expect(bettingControls()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('records a debt in the history panel once the session settles', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<App storage={memoryStorage()} seed={7} />)
|
||||
await user.click(screen.getByText('Sit down'))
|
||||
await playUntilSettled(user)
|
||||
// Settling returns to the buy-in screen; the debt is now in the durable ledger. It appears
|
||||
// twice — once in the buy-in gate, once in the history panel — which is the intended layout.
|
||||
expect(screen.queryByText('No debts settled yet.')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText(/Pushups: /).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('blocks a new buy-in while a debt is outstanding, and unblocks once it is logged off', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<App storage={storageWithDebts([debt({ owedUnits: 10, paidUnits: 0 })])} seed={7} />)
|
||||
expect(screen.queryByText('Sit down')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/outstanding debt/)).toBeInTheDocument()
|
||||
|
||||
// The gate and the history panel each render a control for the same debt; either clears it.
|
||||
const [gateInput] = screen.getAllByLabelText('Log units done for Pushups')
|
||||
const [logButton] = screen.getAllByText('Log progress')
|
||||
if (gateInput === undefined || logButton === undefined) {
|
||||
throw new Error('expected the outstanding-debt controls to render')
|
||||
}
|
||||
await user.type(gateInput, '10')
|
||||
await user.click(logButton)
|
||||
|
||||
expect(screen.getByText('Sit down')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('blocks buying in when the stored ledger is unreadable (fail-closed)', () => {
|
||||
render(<App storage={memoryStorage({ [DEBTS_STORAGE_KEY]: 'not json' })} seed={7} />)
|
||||
expect(screen.queryByText('Sit down')).not.toBeInTheDocument()
|
||||
// Both surfaces fail closed independently: the buy-in gate and the history panel.
|
||||
expect(
|
||||
screen.getByText('Your debt history could not be read. Buying in is blocked until this is resolved.'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Your debt history could not be read.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cashes out between hands and settles the resulting debt', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<App storage={memoryStorage()} seed={7} />)
|
||||
await user.click(screen.getByText('Sit down'))
|
||||
|
||||
// Play the opening hand to completion so we land in `betweenHands`, where cash-out is legal.
|
||||
for (let i = 0; i < 200 && screen.queryByText('Deal next hand') === null; i += 1) {
|
||||
const action = firstLegalAction()
|
||||
if (action === null) {
|
||||
break
|
||||
}
|
||||
await user.click(action)
|
||||
}
|
||||
|
||||
// Pin the actual numbers: the panel previews `2*buyIn - stack`, and the settled ledger row
|
||||
// must show exactly that same figure. A looser assertion here would pass whether or not a
|
||||
// debt was recorded at all.
|
||||
const preview = screen.getByText(/Current stack: \d+ chips\./)
|
||||
const previewText = preview.textContent
|
||||
const parsed = /Current stack: (\d+) chips\. Cashing out now would owe (\d+) units\./.exec(previewText)
|
||||
if (parsed === null) {
|
||||
throw new Error(`cash-out panel did not match the expected copy: ${previewText}`)
|
||||
}
|
||||
const [, stackText, owedText] = parsed
|
||||
const finalChips = Number(stackText)
|
||||
const previewOwed = Number(owedText)
|
||||
expect(previewOwed).toBe(Math.max(0, 2 * DEFAULT_BUY_IN - finalChips))
|
||||
|
||||
await user.click(screen.getByText('Leave table'))
|
||||
|
||||
// Cashing out settles the session, so the app returns to the (now debt-gated) buy-in screen,
|
||||
// and the ledger row carries the same owed figure the preview promised.
|
||||
expect(screen.getByText(/outstanding debt/)).toBeInTheDocument()
|
||||
expect(screen.getAllByText(`Pushups: 0 / ${String(previewOwed)} done`).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('settles a bust at exactly twice the buy-in', async () => {
|
||||
const user = userEvent.setup()
|
||||
const storage = memoryStorage()
|
||||
render(<App storage={storage} seed={7} />)
|
||||
await user.click(screen.getByText('Sit down'))
|
||||
await playUntilSettled(user)
|
||||
|
||||
const stored: unknown = JSON.parse(storage.getItem(DEBTS_STORAGE_KEY) ?? '[]')
|
||||
expect(Array.isArray(stored)).toBe(true)
|
||||
const debts = stored as SettledDebt[]
|
||||
expect(debts.length).toBeGreaterThan(0)
|
||||
const settled = debts[debts.length - 1]
|
||||
if (settled === undefined) {
|
||||
throw new Error('expected a settled debt')
|
||||
}
|
||||
// Seed 7 + always-take-the-first-legal-action busts deterministically, so this pins the
|
||||
// bust case exactly rather than accepting either branch. Busting to zero is the formula's
|
||||
// natural maximum, not a special case: 2*buyIn - 0.
|
||||
expect(settled.reason).toBe('bust')
|
||||
expect(settled.owedUnits).toBe(2 * DEFAULT_BUY_IN)
|
||||
})
|
||||
})
|
||||
@ -1,58 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { BettingControls } from './ui/BettingControls'
|
||||
import { BuyInScreen } from './ui/BuyInScreen'
|
||||
import { CashOutPanel } from './ui/CashOutPanel'
|
||||
import { DebtHistoryPanel } from './ui/DebtHistoryPanel'
|
||||
import { HandLog } from './ui/HandLog'
|
||||
import { Table } from './ui/Table'
|
||||
import { useGame } from './ui/useGame'
|
||||
|
||||
interface AppProps {
|
||||
/** Injected so tests can drive a scripted in-memory ledger; defaults to the real localStorage. */
|
||||
storage?: Storage
|
||||
/** Injected so tests can script a deterministic deal; defaults to a per-mount clock read. */
|
||||
seed?: number
|
||||
}
|
||||
|
||||
export const App = ({ storage = localStorage, seed }: AppProps = {}): ReactElement => {
|
||||
// Lazy init: `Date.now()` must be read once per mount, not on every render — an inline default
|
||||
// would make the component impure and re-seed the deck on each re-render.
|
||||
const [dealSeed] = useState(() => seed ?? Date.now())
|
||||
const { state, buyIn, dealHand, submitPlayerAction, cashOut, logDebtProgress } = useGame(storage, dealSeed)
|
||||
const { debtLoad, buyIn: activeBuyIn, session, handPhase } = state
|
||||
|
||||
return (
|
||||
<main className="frame">
|
||||
<header className="masthead">
|
||||
<h1>Poker Stakes</h1>
|
||||
<p>Lose the hand, pay the price — in pushups, problems or reviews.</p>
|
||||
</header>
|
||||
{session === null || activeBuyIn === null ? (
|
||||
<BuyInScreen debtLoad={debtLoad} onBuyIn={buyIn} onLogDebtProgress={logDebtProgress} />
|
||||
) : (
|
||||
<>
|
||||
<Table session={session} handPhase={handPhase} />
|
||||
{handPhase?.kind === 'awaiting' && (
|
||||
<BettingControls betting={handPhase.step.betting} onAction={submitPlayerAction} />
|
||||
)}
|
||||
{/*
|
||||
`handPhase` is never null while a session exists — `useGame.buyIn` deals the opening
|
||||
hand as part of sitting down — so the only two states here are the awaiting/between
|
||||
pair above and below.
|
||||
*/}
|
||||
{handPhase?.kind === 'betweenHands' && (
|
||||
<>
|
||||
<HandLog lastResult={handPhase.lastResult} />
|
||||
<button type="button" className="button button--primary" onClick={dealHand}>
|
||||
Deal next hand
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<CashOutPanel session={session} buyIn={activeBuyIn} handPhase={handPhase} onCashOut={cashOut} />
|
||||
</>
|
||||
)}
|
||||
<DebtHistoryPanel debtLoad={debtLoad} onLogDebtProgress={logDebtProgress} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -1,244 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyAction, legalActions } from './bettingRound'
|
||||
import type { BettingState, SeatState } from './bettingRound'
|
||||
|
||||
const seat = (overrides: Partial<SeatState> = {}): SeatState => ({
|
||||
stack: 100,
|
||||
committed: 0,
|
||||
hasActed: false,
|
||||
folded: false,
|
||||
allIn: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const state = (overrides: Partial<BettingState> = {}): BettingState => ({
|
||||
street: 'preflop',
|
||||
pot: 0,
|
||||
toAct: 'player',
|
||||
seats: { player: seat(), opponent: seat() },
|
||||
betToCall: 0,
|
||||
minRaiseSize: 10,
|
||||
roundOver: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const totalChips = (s: BettingState): number =>
|
||||
s.pot + s.seats.player.stack + s.seats.player.committed + s.seats.opponent.stack + s.seats.opponent.committed
|
||||
|
||||
describe('legalActions', () => {
|
||||
it('returns no actions once the round is over', () => {
|
||||
expect(legalActions(state({ roundOver: true }))).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no actions for a folded actor', () => {
|
||||
expect(legalActions(state({ seats: { player: seat({ folded: true }), opponent: seat() } }))).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no actions for an all-in actor', () => {
|
||||
expect(
|
||||
legalActions(state({ seats: { player: seat({ allIn: true, stack: 0 }), opponent: seat() } })),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('offers check (not call) when nothing is owed', () => {
|
||||
const actions = legalActions(state({ betToCall: 0 }))
|
||||
expect(actions).toContainEqual({ type: 'fold' })
|
||||
expect(actions).toContainEqual({ type: 'check' })
|
||||
expect(actions.some((a) => a.type === 'call')).toBe(false)
|
||||
})
|
||||
|
||||
it('offers call (not check) when a bet is owed', () => {
|
||||
const actions = legalActions(
|
||||
state({ betToCall: 20, seats: { player: seat({ committed: 0 }), opponent: seat({ committed: 20 }) } }),
|
||||
)
|
||||
expect(actions).toContainEqual({ type: 'fold' })
|
||||
expect(actions).toContainEqual({ type: 'call' })
|
||||
expect(actions.some((a) => a.type === 'check')).toBe(false)
|
||||
})
|
||||
|
||||
it('offers bet (not raise) as the open-action name when nothing is committed yet', () => {
|
||||
const actions = legalActions(state({ betToCall: 0 }))
|
||||
expect(actions.some((a) => a.type === 'bet')).toBe(true)
|
||||
expect(actions.some((a) => a.type === 'raise')).toBe(false)
|
||||
})
|
||||
|
||||
it('offers raise (not bet) as the open-action name when a bet is already in', () => {
|
||||
const actions = legalActions(
|
||||
state({ betToCall: 20, seats: { player: seat({ committed: 0 }), opponent: seat({ committed: 20 }) } }),
|
||||
)
|
||||
expect(actions.some((a) => a.type === 'raise')).toBe(true)
|
||||
expect(actions.some((a) => a.type === 'bet')).toBe(false)
|
||||
})
|
||||
|
||||
it('offers a single bet/raise size (all-in only) when stack is too small for a full min-raise', () => {
|
||||
const actions = legalActions(
|
||||
state({
|
||||
betToCall: 20,
|
||||
minRaiseSize: 50,
|
||||
seats: { player: seat({ committed: 0, stack: 25 }), opponent: seat({ committed: 20 }) },
|
||||
}),
|
||||
)
|
||||
const raises = actions.filter((a) => a.type === 'raise')
|
||||
expect(raises).toHaveLength(1)
|
||||
expect(raises[0]).toEqual({ type: 'raise', amount: 25 })
|
||||
})
|
||||
|
||||
it('offers no bet/raise when the actor can only call all-in (stack equals amount owed)', () => {
|
||||
const actions = legalActions(
|
||||
state({
|
||||
betToCall: 20,
|
||||
seats: { player: seat({ committed: 0, stack: 20 }), opponent: seat({ committed: 20 }) },
|
||||
}),
|
||||
)
|
||||
expect(actions.some((a) => a.type === 'bet' || a.type === 'raise')).toBe(false)
|
||||
expect(actions).toContainEqual({ type: 'call' })
|
||||
})
|
||||
|
||||
it('offers both a minimum and an all-in size when stack exceeds the minimum raise', () => {
|
||||
const actions = legalActions(state({ betToCall: 0, minRaiseSize: 10, seats: { player: seat({ stack: 100 }), opponent: seat() } }))
|
||||
const bets = actions.filter((a) => a.type === 'bet')
|
||||
expect(bets).toHaveLength(2)
|
||||
expect(bets[0]).toEqual({ type: 'bet', amount: 10 })
|
||||
expect(bets[1]).toEqual({ type: 'bet', amount: 100 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyAction: fold', () => {
|
||||
it('marks the actor folded and ends the round', () => {
|
||||
const s = state()
|
||||
const next = applyAction(s, { type: 'fold' })
|
||||
expect(next.seats.player.folded).toBe(true)
|
||||
expect(next.roundOver).toBe(true)
|
||||
expect(totalChips(next)).toBe(totalChips(s))
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyAction: check', () => {
|
||||
it('passes action to the other seat without ending the round if they have not acted', () => {
|
||||
const s = state()
|
||||
const next = applyAction(s, { type: 'check' })
|
||||
expect(next.toAct).toBe('opponent')
|
||||
expect(next.roundOver).toBe(false)
|
||||
})
|
||||
|
||||
it('ends the round when both seats have checked', () => {
|
||||
const s = state()
|
||||
const afterPlayer = applyAction(s, { type: 'check' })
|
||||
const afterOpponent = applyAction(afterPlayer, { type: 'check' })
|
||||
expect(afterOpponent.roundOver).toBe(true)
|
||||
expect(totalChips(afterOpponent)).toBe(totalChips(s))
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyAction: call', () => {
|
||||
it('matches the bet, ends the round, and preserves total chips (opponent already acted)', () => {
|
||||
const s = state({
|
||||
betToCall: 20,
|
||||
toAct: 'player',
|
||||
seats: {
|
||||
player: seat({ committed: 0, stack: 100 }),
|
||||
opponent: seat({ committed: 20, stack: 80, hasActed: true }),
|
||||
},
|
||||
})
|
||||
const next = applyAction(s, { type: 'call' })
|
||||
expect(next.seats.player.committed).toBe(20)
|
||||
expect(next.seats.player.stack).toBe(80)
|
||||
expect(next.roundOver).toBe(true)
|
||||
expect(totalChips(next)).toBe(totalChips(s))
|
||||
})
|
||||
|
||||
it('does NOT end the round when the other seat has not yet acted (heads-up big-blind option)', () => {
|
||||
// SB completes preflop to match the BB's posted blind; BB has not acted this round yet.
|
||||
const s = state({
|
||||
betToCall: 10,
|
||||
toAct: 'player',
|
||||
seats: {
|
||||
player: seat({ committed: 0, stack: 100 }),
|
||||
opponent: seat({ committed: 10, stack: 90, hasActed: false }),
|
||||
},
|
||||
})
|
||||
const next = applyAction(s, { type: 'call' })
|
||||
expect(next.roundOver).toBe(false)
|
||||
expect(next.toAct).toBe('opponent')
|
||||
expect(totalChips(next)).toBe(totalChips(s))
|
||||
})
|
||||
|
||||
it('goes all-in when the stack is smaller than the call amount owed is not offered, but a call for exactly the stack works', () => {
|
||||
const s = state({
|
||||
betToCall: 20,
|
||||
seats: { player: seat({ committed: 0, stack: 20 }), opponent: seat({ committed: 20 }) },
|
||||
})
|
||||
const next = applyAction(s, { type: 'call' })
|
||||
expect(next.seats.player.stack).toBe(0)
|
||||
expect(next.seats.player.allIn).toBe(true)
|
||||
expect(totalChips(next)).toBe(totalChips(s))
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyAction: bet/raise', () => {
|
||||
it('opens a bet, updates betToCall and minRaiseSize, and requires the other seat to act again', () => {
|
||||
const s = state({ minRaiseSize: 10 })
|
||||
const next = applyAction(s, { type: 'bet', amount: 10 })
|
||||
expect(next.betToCall).toBe(10)
|
||||
expect(next.minRaiseSize).toBe(10)
|
||||
expect(next.toAct).toBe('opponent')
|
||||
expect(next.roundOver).toBe(false)
|
||||
expect(next.seats.opponent.hasActed).toBe(false)
|
||||
expect(totalChips(next)).toBe(totalChips(s))
|
||||
})
|
||||
|
||||
// A short all-in (a raise that adds LESS than the current minimum) must not lower the floor for
|
||||
// whoever acts next — that is what `Math.max(state.minRaiseSize, raiseSize)` protects. Without
|
||||
// the floor, a 5-chip all-in over a min-raise of 50 would let the next player re-raise by 5.
|
||||
it('does not lower minRaiseSize when an all-in raise is smaller than the current minimum', () => {
|
||||
const s = state({
|
||||
betToCall: 20,
|
||||
minRaiseSize: 50,
|
||||
toAct: 'opponent',
|
||||
seats: {
|
||||
player: seat({ committed: 20, stack: 80, hasActed: true }),
|
||||
opponent: seat({ committed: 0, stack: 25 }),
|
||||
},
|
||||
})
|
||||
// All-in for 25 total: only 5 more than the 20 to call, well under the 50 min-raise.
|
||||
const next = applyAction(s, { type: 'raise', amount: 25 })
|
||||
expect(next.betToCall).toBe(25)
|
||||
expect(next.minRaiseSize).toBe(50)
|
||||
expect(next.seats.opponent.allIn).toBe(true)
|
||||
expect(totalChips(next)).toBe(totalChips(s))
|
||||
})
|
||||
|
||||
it('raises on top of an existing bet and tracks the new min-raise size', () => {
|
||||
const s = state({
|
||||
betToCall: 10,
|
||||
minRaiseSize: 10,
|
||||
toAct: 'opponent',
|
||||
seats: { player: seat({ committed: 10, stack: 90, hasActed: true }), opponent: seat({ committed: 0, stack: 100 }) },
|
||||
})
|
||||
const next = applyAction(s, { type: 'raise', amount: 30 })
|
||||
expect(next.betToCall).toBe(30)
|
||||
expect(next.minRaiseSize).toBe(20)
|
||||
expect(next.toAct).toBe('player')
|
||||
expect(totalChips(next)).toBe(totalChips(s))
|
||||
})
|
||||
|
||||
it('caps spend at the actor stack and marks all-in when betting the full stack', () => {
|
||||
const s = state({ seats: { player: seat({ stack: 40 }), opponent: seat() } })
|
||||
const next = applyAction(s, { type: 'bet', amount: 40 })
|
||||
expect(next.seats.player.stack).toBe(0)
|
||||
expect(next.seats.player.allIn).toBe(true)
|
||||
expect(totalChips(next)).toBe(totalChips(s))
|
||||
})
|
||||
})
|
||||
|
||||
describe('a full betting round preserves total chips end to end', () => {
|
||||
it('bet, raise, call sequence', () => {
|
||||
let s = state({ minRaiseSize: 10, seats: { player: seat({ stack: 200 }), opponent: seat({ stack: 200 }) } })
|
||||
const initialTotal = totalChips(s)
|
||||
s = applyAction(s, { type: 'bet', amount: 20 })
|
||||
s = applyAction(s, { type: 'raise', amount: 60 })
|
||||
s = applyAction(s, { type: 'call' })
|
||||
expect(s.roundOver).toBe(true)
|
||||
expect(totalChips(s)).toBe(initialTotal)
|
||||
})
|
||||
})
|
||||
@ -1,139 +0,0 @@
|
||||
import type { Seat, Street } from './types'
|
||||
|
||||
export type Action =
|
||||
| { type: 'fold' }
|
||||
| { type: 'check' }
|
||||
| { type: 'call' }
|
||||
| { type: 'bet'; amount: number }
|
||||
| { type: 'raise'; amount: number }
|
||||
|
||||
export interface SeatState {
|
||||
stack: number
|
||||
committed: number // chips this seat has put in during the CURRENT betting round
|
||||
hasActed: boolean
|
||||
folded: boolean
|
||||
allIn: boolean
|
||||
}
|
||||
|
||||
export interface BettingState {
|
||||
street: Street
|
||||
pot: number // chips committed in PREVIOUS rounds, not yet including this round
|
||||
toAct: Seat
|
||||
seats: Record<Seat, SeatState>
|
||||
betToCall: number // highest `committed` this round
|
||||
minRaiseSize: number // minimum additional amount a raise must add beyond a call, per no-limit rules
|
||||
roundOver: boolean
|
||||
}
|
||||
|
||||
const other = (seat: Seat): Seat => (seat === 'player' ? 'opponent' : 'player')
|
||||
|
||||
/**
|
||||
* Chips this seat still needs to add to match betToCall.
|
||||
*
|
||||
* The `Math.max(0, ...)` floor is defensive only: `applyAction` always sets `betToCall` to the
|
||||
* highest `committed` in the round, so no seat's `committed` can exceed it and the subtraction is
|
||||
* never negative in practice — the full suite passes with the floor removed. It stays because it
|
||||
* costs nothing and makes the "never negative" contract local to this function rather than an
|
||||
* invariant a reader has to reconstruct from every `betToCall` write site.
|
||||
*/
|
||||
const owedToCall = (state: BettingState, seat: Seat): number =>
|
||||
Math.max(0, state.betToCall - state.seats[seat].committed)
|
||||
|
||||
/** Generates the legal actions for the seat to act. Illegal actions are never representable in the UI. */
|
||||
export const legalActions = (state: BettingState): readonly Action[] => {
|
||||
if (state.roundOver) {
|
||||
return []
|
||||
}
|
||||
const actor = state.seats[state.toAct]
|
||||
if (actor.folded || actor.allIn) {
|
||||
return []
|
||||
}
|
||||
|
||||
const toCall = owedToCall(state, state.toAct)
|
||||
const actions: Action[] = [{ type: 'fold' }]
|
||||
|
||||
if (toCall === 0) {
|
||||
actions.push({ type: 'check' })
|
||||
} else {
|
||||
actions.push({ type: 'call' })
|
||||
}
|
||||
|
||||
const maxAdditional = actor.stack
|
||||
const minOpenOrRaiseAdditional = toCall + state.minRaiseSize
|
||||
if (maxAdditional > toCall) {
|
||||
const kind = state.betToCall === 0 ? 'bet' : 'raise'
|
||||
const minAdditional = Math.min(minOpenOrRaiseAdditional, maxAdditional)
|
||||
// Every legal size from the minimum up to an all-in is available; expose min and all-in as
|
||||
// concrete options rather than an open-ended range, since the UI drives a discrete control.
|
||||
actions.push({ type: kind, amount: minAdditional })
|
||||
if (maxAdditional > minAdditional) {
|
||||
actions.push({ type: kind, amount: maxAdditional })
|
||||
}
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
const applyChips = (seat: SeatState, additional: number): SeatState => {
|
||||
const spend = Math.min(additional, seat.stack)
|
||||
return {
|
||||
...seat,
|
||||
stack: seat.stack - spend,
|
||||
committed: seat.committed + spend,
|
||||
allIn: seat.stack - spend === 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies an action, returning the next BettingState. Assumes the action came from legalActions(state). */
|
||||
export const applyAction = (state: BettingState, action: Action): BettingState => {
|
||||
const actingSeat = state.toAct
|
||||
const opponentSeat = other(actingSeat)
|
||||
|
||||
if (action.type === 'fold') {
|
||||
return {
|
||||
...state,
|
||||
seats: { ...state.seats, [actingSeat]: { ...state.seats[actingSeat], folded: true, hasActed: true } },
|
||||
roundOver: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === 'check') {
|
||||
const nextSeats: Record<Seat, SeatState> = {
|
||||
...state.seats,
|
||||
[actingSeat]: { ...state.seats[actingSeat], hasActed: true },
|
||||
}
|
||||
const bothActed = nextSeats[opponentSeat].hasActed
|
||||
return { ...state, seats: nextSeats, toAct: opponentSeat, roundOver: bothActed }
|
||||
}
|
||||
|
||||
if (action.type === 'call') {
|
||||
const toCall = owedToCall(state, actingSeat)
|
||||
const nextSeats: Record<Seat, SeatState> = {
|
||||
...state.seats,
|
||||
[actingSeat]: { ...applyChips(state.seats[actingSeat], toCall), hasActed: true },
|
||||
}
|
||||
// A call closes the round UNLESS the other seat has never acted this round yet (the
|
||||
// heads-up big-blind-option case: SB completes preflop, BB still gets first say).
|
||||
const bothActed = nextSeats[opponentSeat].hasActed
|
||||
return { ...state, seats: nextSeats, toAct: opponentSeat, roundOver: bothActed }
|
||||
}
|
||||
|
||||
// bet or raise
|
||||
const nextActingSeatState: SeatState = {
|
||||
...applyChips(state.seats[actingSeat], action.amount),
|
||||
hasActed: true,
|
||||
}
|
||||
const raiseSize = nextActingSeatState.committed - state.betToCall
|
||||
return {
|
||||
...state,
|
||||
seats: {
|
||||
...state.seats,
|
||||
[actingSeat]: nextActingSeatState,
|
||||
[opponentSeat]: { ...state.seats[opponentSeat], hasActed: false },
|
||||
},
|
||||
betToCall: nextActingSeatState.committed,
|
||||
minRaiseSize: Math.max(state.minRaiseSize, raiseSize),
|
||||
toAct: opponentSeat,
|
||||
roundOver: false,
|
||||
}
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildDeck, createDealCursor, dealCards, shuffleDeck } from './deck'
|
||||
import { createRng } from './rng'
|
||||
import type { Card } from './types'
|
||||
|
||||
const cardKey = (card: Card): string => `${String(card.rank)}-${card.suit}`
|
||||
|
||||
describe('buildDeck', () => {
|
||||
it('builds exactly 52 unique cards', () => {
|
||||
const deck = buildDeck()
|
||||
expect(deck).toHaveLength(52)
|
||||
expect(new Set(deck.map(cardKey)).size).toBe(52)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shuffleDeck', () => {
|
||||
it('returns a permutation of the input deck', () => {
|
||||
const deck = buildDeck()
|
||||
const shuffled = shuffleDeck(createRng(1), deck)
|
||||
expect(shuffled).toHaveLength(52)
|
||||
expect(new Set(shuffled.map(cardKey))).toEqual(new Set(deck.map(cardKey)))
|
||||
})
|
||||
|
||||
it('is deterministic for a given seed', () => {
|
||||
const deck = buildDeck()
|
||||
const a = shuffleDeck(createRng(42), deck)
|
||||
const b = shuffleDeck(createRng(42), deck)
|
||||
expect(a).toEqual(b)
|
||||
})
|
||||
|
||||
it('produces a different order for a different seed', () => {
|
||||
const deck = buildDeck()
|
||||
const a = shuffleDeck(createRng(1), deck)
|
||||
const b = shuffleDeck(createRng(2), deck)
|
||||
expect(a).not.toEqual(b)
|
||||
})
|
||||
|
||||
it('does not mutate the input deck', () => {
|
||||
const deck = buildDeck()
|
||||
const before = [...deck]
|
||||
shuffleDeck(createRng(7), deck)
|
||||
expect(deck).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dealCards', () => {
|
||||
it('deals the requested count and advances the cursor', () => {
|
||||
const deck = shuffleDeck(createRng(3), buildDeck())
|
||||
const cursor0 = createDealCursor(deck)
|
||||
const { cards, cursor: cursor1 } = dealCards(cursor0, 2)
|
||||
expect(cards).toEqual(deck.slice(0, 2))
|
||||
expect(cursor1.next).toBe(2)
|
||||
})
|
||||
|
||||
it('deals sequential batches without overlap', () => {
|
||||
const deck = shuffleDeck(createRng(4), buildDeck())
|
||||
const { cursor: afterHole } = dealCards(createDealCursor(deck), 4)
|
||||
const { cards: flop, cursor: afterFlop } = dealCards(afterHole, 3)
|
||||
expect(flop).toEqual(deck.slice(4, 7))
|
||||
expect(afterFlop.next).toBe(7)
|
||||
})
|
||||
|
||||
it('throws when asked for more cards than remain', () => {
|
||||
const deck = buildDeck()
|
||||
const cursor = createDealCursor(deck)
|
||||
expect(() => dealCards(cursor, 53)).toThrow('not enough cards remaining')
|
||||
})
|
||||
})
|
||||
@ -1,52 +0,0 @@
|
||||
import type { Rng } from './rng'
|
||||
import { nextInt } from './rng'
|
||||
import type { Card, Rank, Suit } from './types'
|
||||
|
||||
const SUITS: readonly Suit[] = ['clubs', 'diamonds', 'hearts', 'spades']
|
||||
const RANKS: readonly Rank[] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
|
||||
|
||||
/** Builds a deterministic 52-card deck in fixed suit/rank order. */
|
||||
export const buildDeck = (): readonly Card[] => {
|
||||
const deck: Card[] = []
|
||||
for (const suit of SUITS) {
|
||||
for (const rank of RANKS) {
|
||||
deck.push({ rank, suit })
|
||||
}
|
||||
}
|
||||
return deck
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-sampling shuffle: repeatedly pulls a random remaining card into
|
||||
* the output. Uses splice (never indexed reads) so no element access is ever
|
||||
* typed `Card | undefined` under noUncheckedIndexedAccess.
|
||||
*/
|
||||
export const shuffleDeck = (rng: Rng, deck: readonly Card[]): Card[] => {
|
||||
const pool: Card[] = [...deck]
|
||||
const out: Card[] = []
|
||||
while (pool.length > 0) {
|
||||
for (const card of pool.splice(nextInt(rng, pool.length), 1)) {
|
||||
out.push(card)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface DealCursor {
|
||||
deck: readonly Card[]
|
||||
next: number
|
||||
}
|
||||
|
||||
export const createDealCursor = (deck: readonly Card[]): DealCursor => ({ deck, next: 0 })
|
||||
|
||||
/** Deals `count` cards from the cursor, returning the cards and an advanced cursor. */
|
||||
export const dealCards = (
|
||||
cursor: DealCursor,
|
||||
count: number,
|
||||
): { cards: Card[]; cursor: DealCursor } => {
|
||||
if (cursor.next + count > cursor.deck.length) {
|
||||
throw new Error('dealCards: not enough cards remaining in deck')
|
||||
}
|
||||
const cards = cursor.deck.slice(cursor.next, cursor.next + count)
|
||||
return { cards, cursor: { deck: cursor.deck, next: cursor.next + count } }
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { compareHandScore, evaluate7, rankLabel } from './handEval'
|
||||
import type { HandScore } from './handEval'
|
||||
import { card, seven } from '../test/handEvalFixtures'
|
||||
|
||||
describe('evaluate7', () => {
|
||||
it('picks the best 5 of 7 when the best hand uses only some hole+board cards', () => {
|
||||
const hand = seven([
|
||||
card(14, 'clubs'),
|
||||
card(14, 'diamonds'),
|
||||
card(14, 'hearts'),
|
||||
card(14, 'spades'),
|
||||
card(2, 'clubs'),
|
||||
card(3, 'diamonds'),
|
||||
card(4, 'hearts'),
|
||||
])
|
||||
const score = evaluate7(hand)
|
||||
expect(score.category).toBe('quads')
|
||||
})
|
||||
|
||||
it('matches the best rank5 result across all 21 combinations', () => {
|
||||
const cards = seven([
|
||||
card(10, 'clubs'),
|
||||
card(11, 'clubs'),
|
||||
card(12, 'clubs'),
|
||||
card(13, 'clubs'),
|
||||
card(14, 'clubs'),
|
||||
card(2, 'hearts'),
|
||||
card(2, 'diamonds'),
|
||||
])
|
||||
const score = evaluate7(cards)
|
||||
expect(score.category).toBe('straightFlush')
|
||||
expect(score.tiebreakers).toEqual([14])
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareHandScore category comparison', () => {
|
||||
it('ranks a lower category below a higher one in both directions', () => {
|
||||
const high: HandScore = { category: 'quads', tiebreakers: [2] }
|
||||
const low: HandScore = { category: 'highCard', tiebreakers: [14] }
|
||||
expect(compareHandScore(high, low)).toBe(1)
|
||||
expect(compareHandScore(low, high)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareHandScore tiebreaker padding', () => {
|
||||
it('treats a missing tiebreaker slot as lower than a present one', () => {
|
||||
const a: HandScore = { category: 'highCard', tiebreakers: [14, 10] }
|
||||
const b: HandScore = { category: 'highCard', tiebreakers: [14] }
|
||||
expect(compareHandScore(a, b)).toBe(1)
|
||||
expect(compareHandScore(b, a)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rankLabel', () => {
|
||||
it('labels face cards and ace', () => {
|
||||
expect(rankLabel(14)).toBe('A')
|
||||
expect(rankLabel(13)).toBe('K')
|
||||
expect(rankLabel(12)).toBe('Q')
|
||||
expect(rankLabel(11)).toBe('J')
|
||||
})
|
||||
|
||||
it('labels number cards as their numeral', () => {
|
||||
expect(rankLabel(10)).toBe('10')
|
||||
expect(rankLabel(2)).toBe('2')
|
||||
})
|
||||
})
|
||||
@ -1,174 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildDeck } from './deck'
|
||||
import { compareHandScore, rank5 } from './handEval'
|
||||
import type { HandCategory } from './handEval'
|
||||
import type { Card } from './types'
|
||||
import { card, combinations5, five } from '../test/handEvalFixtures'
|
||||
|
||||
describe('rank5 exhaustive classification', () => {
|
||||
it('classifies every 5-card hand out of 52 into the correct standard-count category', () => {
|
||||
const deck = buildDeck()
|
||||
const histogram: Record<HandCategory, number> = {
|
||||
highCard: 0,
|
||||
onePair: 0,
|
||||
twoPair: 0,
|
||||
trips: 0,
|
||||
straight: 0,
|
||||
flush: 0,
|
||||
fullHouse: 0,
|
||||
quads: 0,
|
||||
straightFlush: 0,
|
||||
}
|
||||
|
||||
let total = 0
|
||||
for (const [i0, i1, i2, i3, i4] of combinations5(deck.length)) {
|
||||
const hand = five([deck[i0], deck[i1], deck[i2], deck[i3], deck[i4]] as Card[])
|
||||
const { category } = rank5(hand)
|
||||
histogram[category] += 1
|
||||
total += 1
|
||||
}
|
||||
|
||||
expect(total).toBe(2_598_960)
|
||||
// Straight/flush counts here fold the royal flush into straightFlush (A-high straight flush).
|
||||
expect(histogram).toEqual({
|
||||
highCard: 1_302_540,
|
||||
onePair: 1_098_240,
|
||||
twoPair: 123_552,
|
||||
trips: 54_912,
|
||||
straight: 10_200,
|
||||
flush: 5_108,
|
||||
fullHouse: 3_744,
|
||||
quads: 624,
|
||||
straightFlush: 40,
|
||||
})
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
describe('rank5 named fixtures', () => {
|
||||
it('scores a wheel straight (A-2-3-4-5) as 5-high', () => {
|
||||
const hand = five([
|
||||
card(14, 'clubs'),
|
||||
card(2, 'diamonds'),
|
||||
card(3, 'hearts'),
|
||||
card(4, 'spades'),
|
||||
card(5, 'clubs'),
|
||||
])
|
||||
const score = rank5(hand)
|
||||
expect(score.category).toBe('straight')
|
||||
expect(score.tiebreakers).toEqual([5])
|
||||
})
|
||||
|
||||
it('scores a wheel straight flush as 5-high straight flush', () => {
|
||||
const hand = five([
|
||||
card(14, 'clubs'),
|
||||
card(2, 'clubs'),
|
||||
card(3, 'clubs'),
|
||||
card(4, 'clubs'),
|
||||
card(5, 'clubs'),
|
||||
])
|
||||
const score = rank5(hand)
|
||||
expect(score.category).toBe('straightFlush')
|
||||
expect(score.tiebreakers).toEqual([5])
|
||||
})
|
||||
|
||||
it('scores a broadway (ace-high) straight correctly', () => {
|
||||
const hand = five([
|
||||
card(14, 'clubs'),
|
||||
card(13, 'diamonds'),
|
||||
card(12, 'hearts'),
|
||||
card(11, 'spades'),
|
||||
card(10, 'clubs'),
|
||||
])
|
||||
const score = rank5(hand)
|
||||
expect(score.category).toBe('straight')
|
||||
expect(score.tiebreakers).toEqual([14])
|
||||
})
|
||||
|
||||
it('breaks ties between two one-pair hands by kicker', () => {
|
||||
const a = rank5(
|
||||
five([card(9, 'clubs'), card(9, 'diamonds'), card(14, 'hearts'), card(4, 'spades'), card(2, 'clubs')]),
|
||||
)
|
||||
const b = rank5(
|
||||
five([card(9, 'clubs'), card(9, 'diamonds'), card(13, 'hearts'), card(4, 'spades'), card(2, 'clubs')]),
|
||||
)
|
||||
expect(compareHandScore(a, b)).toBe(1)
|
||||
expect(compareHandScore(b, a)).toBe(-1)
|
||||
})
|
||||
|
||||
it('breaks ties between two full houses by trips rank, not pair rank', () => {
|
||||
// 2-2-2-A-A must beat 3-3-3-K-K: trips rank (2 < 3) decides, not the pair kicker (A > K).
|
||||
const twosOverAces = rank5(
|
||||
five([card(2, 'clubs'), card(2, 'diamonds'), card(2, 'hearts'), card(14, 'spades'), card(14, 'clubs')]),
|
||||
)
|
||||
const threesOverKings = rank5(
|
||||
five([card(3, 'clubs'), card(3, 'diamonds'), card(3, 'hearts'), card(13, 'spades'), card(13, 'clubs')]),
|
||||
)
|
||||
expect(compareHandScore(threesOverKings, twosOverAces)).toBe(1)
|
||||
expect(compareHandScore(twosOverAces, threesOverKings)).toBe(-1)
|
||||
})
|
||||
|
||||
it('breaks ties between two two-pair hands by top pair, then bottom pair, then kicker', () => {
|
||||
const acesAndTwos = rank5(
|
||||
five([card(14, 'clubs'), card(14, 'diamonds'), card(2, 'hearts'), card(2, 'spades'), card(5, 'clubs')]),
|
||||
)
|
||||
const acesAndThrees = rank5(
|
||||
five([card(14, 'hearts'), card(14, 'spades'), card(3, 'clubs'), card(3, 'diamonds'), card(4, 'hearts')]),
|
||||
)
|
||||
expect(compareHandScore(acesAndThrees, acesAndTwos)).toBe(1)
|
||||
expect(compareHandScore(acesAndTwos, acesAndThrees)).toBe(-1)
|
||||
})
|
||||
|
||||
it('breaks ties between two quads hands by the quad rank', () => {
|
||||
const fours = rank5(
|
||||
five([card(4, 'clubs'), card(4, 'diamonds'), card(4, 'hearts'), card(4, 'spades'), card(2, 'clubs')]),
|
||||
)
|
||||
const fives = rank5(
|
||||
five([card(5, 'clubs'), card(5, 'diamonds'), card(5, 'hearts'), card(5, 'spades'), card(2, 'diamonds')]),
|
||||
)
|
||||
expect(compareHandScore(fives, fours)).toBe(1)
|
||||
expect(compareHandScore(fours, fives)).toBe(-1)
|
||||
})
|
||||
|
||||
it('breaks ties between two trips hands by trips rank then kickers', () => {
|
||||
const treys = rank5(
|
||||
five([card(3, 'clubs'), card(3, 'diamonds'), card(3, 'hearts'), card(14, 'spades'), card(13, 'clubs')]),
|
||||
)
|
||||
const fours = rank5(
|
||||
five([card(4, 'clubs'), card(4, 'diamonds'), card(4, 'hearts'), card(2, 'spades'), card(3, 'clubs')]),
|
||||
)
|
||||
expect(compareHandScore(fours, treys)).toBe(1)
|
||||
expect(compareHandScore(treys, fours)).toBe(-1)
|
||||
})
|
||||
|
||||
it('breaks ties between two flushes by highest differing card', () => {
|
||||
const aceHighFlush = rank5(
|
||||
five([card(14, 'clubs'), card(9, 'clubs'), card(7, 'clubs'), card(4, 'clubs'), card(2, 'clubs')]),
|
||||
)
|
||||
const kingHighFlush = rank5(
|
||||
five([card(13, 'diamonds'), card(10, 'diamonds'), card(8, 'diamonds'), card(5, 'diamonds'), card(3, 'diamonds')]),
|
||||
)
|
||||
expect(compareHandScore(aceHighFlush, kingHighFlush)).toBe(1)
|
||||
expect(compareHandScore(kingHighFlush, aceHighFlush)).toBe(-1)
|
||||
})
|
||||
|
||||
it('breaks ties between two straights by the high card', () => {
|
||||
const sevenHigh = rank5(
|
||||
five([card(7, 'clubs'), card(6, 'diamonds'), card(5, 'hearts'), card(4, 'spades'), card(3, 'clubs')]),
|
||||
)
|
||||
const eightHigh = rank5(
|
||||
five([card(8, 'clubs'), card(7, 'diamonds'), card(6, 'hearts'), card(5, 'spades'), card(4, 'clubs')]),
|
||||
)
|
||||
expect(compareHandScore(eightHigh, sevenHigh)).toBe(1)
|
||||
expect(compareHandScore(sevenHigh, eightHigh)).toBe(-1)
|
||||
})
|
||||
|
||||
it('recognizes an exact split (identical hand scores)', () => {
|
||||
const a = rank5(
|
||||
five([card(9, 'clubs'), card(9, 'diamonds'), card(14, 'hearts'), card(4, 'spades'), card(2, 'clubs')]),
|
||||
)
|
||||
const b = rank5(
|
||||
five([card(9, 'hearts'), card(9, 'spades'), card(14, 'clubs'), card(4, 'diamonds'), card(2, 'hearts')]),
|
||||
)
|
||||
expect(compareHandScore(a, b)).toBe(0)
|
||||
})
|
||||
})
|
||||
@ -1,182 +0,0 @@
|
||||
import type { Card, Rank } from './types'
|
||||
|
||||
export type HandCategory =
|
||||
| 'highCard'
|
||||
| 'onePair'
|
||||
| 'twoPair'
|
||||
| 'trips'
|
||||
| 'straight'
|
||||
| 'flush'
|
||||
| 'fullHouse'
|
||||
| 'quads'
|
||||
| 'straightFlush'
|
||||
|
||||
const CATEGORY_RANK: Record<HandCategory, number> = {
|
||||
highCard: 0,
|
||||
onePair: 1,
|
||||
twoPair: 2,
|
||||
trips: 3,
|
||||
straight: 4,
|
||||
flush: 5,
|
||||
fullHouse: 6,
|
||||
quads: 7,
|
||||
straightFlush: 8,
|
||||
}
|
||||
|
||||
export interface HandScore {
|
||||
category: HandCategory
|
||||
tiebreakers: readonly number[]
|
||||
}
|
||||
|
||||
type Five = readonly [Card, Card, Card, Card, Card]
|
||||
type Seven = readonly [Card, Card, Card, Card, Card, Card, Card]
|
||||
|
||||
/** -1 if a < b, 0 if equal, 1 if a > b. Higher HandScore wins. */
|
||||
export const compareHandScore = (a: HandScore, b: HandScore): -1 | 0 | 1 => {
|
||||
const catA = CATEGORY_RANK[a.category]
|
||||
const catB = CATEGORY_RANK[b.category]
|
||||
if (catA !== catB) {
|
||||
return catA > catB ? 1 : -1
|
||||
}
|
||||
const len = Math.max(a.tiebreakers.length, b.tiebreakers.length)
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const ta = a.tiebreakers[i] ?? 0
|
||||
const tb = b.tiebreakers[i] ?? 0
|
||||
if (ta !== tb) {
|
||||
return ta > tb ? 1 : -1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const countBy = <T extends string | number>(values: readonly T[]): Map<T, number> => {
|
||||
const counts = new Map<T, number>()
|
||||
for (const value of values) {
|
||||
counts.set(value, (counts.get(value) ?? 0) + 1)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/** Ranks sorted descending, deduped, from a set of cards. */
|
||||
const descendingRanks = (cards: Five): number[] => {
|
||||
const ranks = cards.map((c) => c.rank)
|
||||
return [...new Set(ranks)].sort((a, b) => b - a)
|
||||
}
|
||||
|
||||
/** Straight high card, or null if the 5 ranks (deduped) don't form a run of 5. Wheel (A-2-3-4-5) scores as 5-high. */
|
||||
const straightHigh = (uniqueDescRanks: readonly number[]): number | null => {
|
||||
if (uniqueDescRanks.length !== 5) {
|
||||
return null
|
||||
}
|
||||
const [r0, r1, r2, r3, r4] = uniqueDescRanks as readonly [number, number, number, number, number]
|
||||
const isRun = r0 - r1 === 1 && r1 - r2 === 1 && r2 - r3 === 1 && r3 - r4 === 1
|
||||
if (isRun) {
|
||||
return r0
|
||||
}
|
||||
const isWheel = r0 === 14 && r1 === 5 && r2 === 4 && r3 === 3 && r4 === 2
|
||||
return isWheel ? 5 : null
|
||||
}
|
||||
|
||||
/** Evaluates exactly 5 cards. */
|
||||
export const rank5 = (cards: Five): HandScore => {
|
||||
const suits = cards.map((c) => c.suit)
|
||||
const isFlush = new Set(suits).size === 1
|
||||
|
||||
const rankCounts = countBy(cards.map((c) => c.rank))
|
||||
const uniqueDesc = descendingRanks(cards)
|
||||
const straightTop = straightHigh(uniqueDesc)
|
||||
|
||||
const groups = [...rankCounts.entries()].sort((a, b) => {
|
||||
if (a[1] !== b[1]) {
|
||||
return b[1] - a[1]
|
||||
}
|
||||
return b[0] - a[0]
|
||||
})
|
||||
const groupCounts = groups.map(([, count]) => count)
|
||||
const groupRanks = groups.map(([rank]) => rank)
|
||||
|
||||
if (isFlush && straightTop !== null) {
|
||||
return { category: 'straightFlush', tiebreakers: [straightTop] }
|
||||
}
|
||||
if (groupCounts[0] === 4) {
|
||||
return { category: 'quads', tiebreakers: groupRanks }
|
||||
}
|
||||
if (groupCounts[0] === 3 && groupCounts[1] === 2) {
|
||||
return { category: 'fullHouse', tiebreakers: groupRanks }
|
||||
}
|
||||
if (isFlush) {
|
||||
return { category: 'flush', tiebreakers: uniqueDesc }
|
||||
}
|
||||
if (straightTop !== null) {
|
||||
return { category: 'straight', tiebreakers: [straightTop] }
|
||||
}
|
||||
if (groupCounts[0] === 3) {
|
||||
return { category: 'trips', tiebreakers: groupRanks }
|
||||
}
|
||||
if (groupCounts[0] === 2 && groupCounts[1] === 2) {
|
||||
return { category: 'twoPair', tiebreakers: groupRanks }
|
||||
}
|
||||
if (groupCounts[0] === 2) {
|
||||
return { category: 'onePair', tiebreakers: groupRanks }
|
||||
}
|
||||
return { category: 'highCard', tiebreakers: uniqueDesc }
|
||||
}
|
||||
|
||||
type FiveIndices = readonly [number, number, number, number, number]
|
||||
|
||||
/** All C(7,5) = 21 five-card index combinations out of positions 0-6, written as a literal tuple. */
|
||||
const SEVEN_CHOOSE_FIVE: readonly [FiveIndices, ...FiveIndices[]] = [
|
||||
[0, 1, 2, 3, 4],
|
||||
[0, 1, 2, 3, 5],
|
||||
[0, 1, 2, 3, 6],
|
||||
[0, 1, 2, 4, 5],
|
||||
[0, 1, 2, 4, 6],
|
||||
[0, 1, 2, 5, 6],
|
||||
[0, 1, 3, 4, 5],
|
||||
[0, 1, 3, 4, 6],
|
||||
[0, 1, 3, 5, 6],
|
||||
[0, 1, 4, 5, 6],
|
||||
[0, 2, 3, 4, 5],
|
||||
[0, 2, 3, 4, 6],
|
||||
[0, 2, 3, 5, 6],
|
||||
[0, 2, 4, 5, 6],
|
||||
[0, 3, 4, 5, 6],
|
||||
[1, 2, 3, 4, 5],
|
||||
[1, 2, 3, 4, 6],
|
||||
[1, 2, 3, 5, 6],
|
||||
[1, 2, 4, 5, 6],
|
||||
[1, 3, 4, 5, 6],
|
||||
[2, 3, 4, 5, 6],
|
||||
]
|
||||
|
||||
/** Best 5-card HandScore out of exactly 7 cards. */
|
||||
export const evaluate7 = (cards: Seven): HandScore => {
|
||||
const [firstCombo, ...restCombos] = SEVEN_CHOOSE_FIVE
|
||||
const fiveFor = (combo: FiveIndices): Five =>
|
||||
[cards[combo[0]], cards[combo[1]], cards[combo[2]], cards[combo[3]], cards[combo[4]]] as Five
|
||||
|
||||
let best = rank5(fiveFor(firstCombo))
|
||||
for (const combo of restCombos) {
|
||||
const score = rank5(fiveFor(combo))
|
||||
if (compareHandScore(score, best) > 0) {
|
||||
best = score
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export const rankLabel = (rank: Rank): string => {
|
||||
if (rank === 14) {
|
||||
return 'A'
|
||||
}
|
||||
if (rank === 13) {
|
||||
return 'K'
|
||||
}
|
||||
if (rank === 12) {
|
||||
return 'Q'
|
||||
}
|
||||
if (rank === 11) {
|
||||
return 'J'
|
||||
}
|
||||
return String(rank)
|
||||
}
|
||||
@ -1,199 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Action, BettingState } from './bettingRound'
|
||||
import { legalActions } from './bettingRound'
|
||||
import { playHand } from './handFlow'
|
||||
import type { HandSetup } from './handFlow'
|
||||
import { createRng } from './rng'
|
||||
import type { Seat } from './types'
|
||||
|
||||
const setup = (overrides: Partial<HandSetup> = {}): HandSetup => ({
|
||||
buttonSeat: 'player',
|
||||
bigBlind: 10,
|
||||
smallBlind: 5,
|
||||
stacks: { player: 200, opponent: 200 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const totalStack = (setupArg: HandSetup): number => setupArg.stacks.player + setupArg.stacks.opponent
|
||||
|
||||
/** Wraps a decide() callback, asserting the returned action is always one legalActions offered. */
|
||||
const enforceLegal = (
|
||||
decide: (betting: BettingState, seat: Seat) => Action,
|
||||
): ((betting: BettingState, seat: Seat) => Action) => {
|
||||
return (betting, seat) => {
|
||||
const action = decide(betting, seat)
|
||||
expect(legalActions(betting)).toContainEqual(action)
|
||||
return action
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a decide() callback from a fixed queue of actions, applied in call order regardless of seat. */
|
||||
const scripted = (actions: readonly Action[]): ((betting: BettingState, seat: Seat) => Action) => {
|
||||
const queue = [...actions]
|
||||
return enforceLegal(() => {
|
||||
const next = queue.shift()
|
||||
if (next === undefined) {
|
||||
throw new Error('scripted: ran out of scripted actions')
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/** Always checks/calls — used to run a hand to showdown with minimal betting. */
|
||||
const alwaysCheckOrCall = enforceLegal((betting) => {
|
||||
const toCall = betting.betToCall - betting.seats[betting.toAct].committed
|
||||
return toCall > 0 ? { type: 'call' } : { type: 'check' }
|
||||
})
|
||||
|
||||
describe('playHand: showdown', () => {
|
||||
it('runs a full hand to showdown and awards the pot to the better hand', () => {
|
||||
const s = setup()
|
||||
const result = playHand(createRng(1), s, { decide: alwaysCheckOrCall })
|
||||
expect(result.wentToShowdown).toBe(true)
|
||||
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
|
||||
expect(['player', 'opponent', 'split']).toContain(result.winner)
|
||||
})
|
||||
|
||||
it('is deterministic for a fixed seed', () => {
|
||||
const s = setup()
|
||||
const a = playHand(createRng(99), s, { decide: alwaysCheckOrCall })
|
||||
const b = playHand(createRng(99), s, { decide: alwaysCheckOrCall })
|
||||
expect(a).toEqual(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('playHand: fold preflop', () => {
|
||||
it('awards the pot to the non-folder and refunds nothing beyond the folded blind', () => {
|
||||
const s = setup()
|
||||
const decide = scripted([{ type: 'fold' }])
|
||||
const result = playHand(createRng(2), s, { decide })
|
||||
expect(result.wentToShowdown).toBe(false)
|
||||
expect(result.loserSeat).toBe('player')
|
||||
expect(result.winner).toBe('opponent')
|
||||
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
|
||||
})
|
||||
})
|
||||
|
||||
describe('playHand: fold postflop', () => {
|
||||
it('carries the pot across streets and awards it on a later fold', () => {
|
||||
const s = setup()
|
||||
// Preflop: player calls, opponent checks (round ends). Flop: opponent bets, player folds.
|
||||
const decide = scripted([{ type: 'call' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'fold' }])
|
||||
const result = playHand(createRng(3), s, { decide })
|
||||
expect(result.wentToShowdown).toBe(false)
|
||||
expect(result.loserSeat).toBe('player')
|
||||
expect(result.potAwarded).toBeGreaterThanOrEqual(s.bigBlind * 2)
|
||||
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
|
||||
})
|
||||
})
|
||||
|
||||
describe('playHand: early all-in preserves total chips', () => {
|
||||
/** Shoves all-in whenever a bet/raise is legal, otherwise calls/checks — drives every hand to an all-in quickly. */
|
||||
const shoveOrCheckCall = enforceLegal((betting, seat) => {
|
||||
const actions = legalActions(betting)
|
||||
const shove = actions.find((a) => a.type === 'bet' || a.type === 'raise')
|
||||
if (shove !== undefined) {
|
||||
return shove
|
||||
}
|
||||
return alwaysCheckOrCall(betting, seat)
|
||||
})
|
||||
|
||||
it('handles a short-stacked all-in preflop and resolves at showdown', () => {
|
||||
const s = setup({ stacks: { player: 15, opponent: 200 } })
|
||||
const result = playHand(createRng(4), s, { decide: shoveOrCheckCall })
|
||||
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
|
||||
expect(result.wentToShowdown).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('playHand: fold — opponent as loser', () => {
|
||||
it('awards the pot to the player when the opponent folds', () => {
|
||||
const s = setup()
|
||||
// Preflop: player calls, opponent gets the option and folds.
|
||||
const decide = scripted([{ type: 'call' }, { type: 'fold' }])
|
||||
const result = playHand(createRng(6), s, { decide })
|
||||
expect(result.loserSeat).toBe('opponent')
|
||||
expect(result.winner).toBe('player')
|
||||
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
|
||||
})
|
||||
})
|
||||
|
||||
describe('playHand: uncalled excess is returned in both directions', () => {
|
||||
it('returns excess to the opponent when the opponent bets more and the player folds', () => {
|
||||
const s = setup()
|
||||
// Preflop: call, check. Flop: opponent bets, player folds — opponent's committed ends up
|
||||
// higher than player's, exercising the "opponent higher" branch of returnUncalledExcess.
|
||||
const decide = scripted([{ type: 'call' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'fold' }])
|
||||
const result = playHand(createRng(3), s, { decide })
|
||||
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
|
||||
})
|
||||
|
||||
it('returns excess to the player when the player bets more and the opponent folds', () => {
|
||||
const s = setup()
|
||||
// Preflop: call, check. Flop: opponent checks first, player bets, opponent folds — player's
|
||||
// committed ends up higher, exercising the "player higher" branch of returnUncalledExcess.
|
||||
const decide = scripted([{ type: 'call' }, { type: 'check' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'fold' }])
|
||||
const result = playHand(createRng(3), s, { decide })
|
||||
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
|
||||
})
|
||||
})
|
||||
|
||||
describe('playHand: split pot', () => {
|
||||
it('splits the pot evenly on an exact tie (deterministic seed)', () => {
|
||||
// Seed 15 with check/call-only play produces a genuine board-play tie at showdown.
|
||||
const s = setup()
|
||||
const result = playHand(createRng(15), s, { decide: alwaysCheckOrCall })
|
||||
expect(result.winner).toBe('split')
|
||||
expect(result.potAwarded % 2).toBe(0)
|
||||
expect(result.finalStacks).toEqual({ player: 200, opponent: 200 })
|
||||
})
|
||||
|
||||
it('conserves total chips across many seeds regardless of outcome', () => {
|
||||
const s = setup()
|
||||
for (let seed = 1; seed <= 50; seed += 1) {
|
||||
const result = playHand(createRng(seed), s, { decide: alwaysCheckOrCall })
|
||||
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
|
||||
expect(result.potAwarded % 2).toBe(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('playHand: button rotation', () => {
|
||||
it('lets the opponent be the button/small blind and act first preflop', () => {
|
||||
const s = setup({ buttonSeat: 'opponent' })
|
||||
const result = playHand(createRng(5), s, { decide: alwaysCheckOrCall })
|
||||
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
|
||||
})
|
||||
})
|
||||
|
||||
describe('playHand: a seat all-in from a blind post never acts again', () => {
|
||||
it('never offers a decision to a 0-stack seat on a later street (button posts SB all-in)', () => {
|
||||
// Button posts smallBlind=1 from a 1-chip stack, going all-in preflop. A fresh, buggy
|
||||
// `allIn: false` reset on flop/turn/river would let this 0-stack seat fold/check again.
|
||||
const s = setup({ buttonSeat: 'player', bigBlind: 2, smallBlind: 1, stacks: { player: 1, opponent: 200 } })
|
||||
const seenActingWithZeroStack: Seat[] = []
|
||||
const decide = (betting: BettingState, seat: Seat): Action => {
|
||||
if (betting.seats[seat].stack === 0) {
|
||||
seenActingWithZeroStack.push(seat)
|
||||
}
|
||||
return alwaysCheckOrCall(betting, seat)
|
||||
}
|
||||
const result = playHand(createRng(1), s, { decide })
|
||||
expect(seenActingWithZeroStack).toEqual([])
|
||||
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
|
||||
})
|
||||
|
||||
it('never offers a decision to a 0-stack seat on a later street (non-button posts BB all-in)', () => {
|
||||
const s = setup({ buttonSeat: 'player', bigBlind: 2, smallBlind: 1, stacks: { player: 200, opponent: 2 } })
|
||||
const seenActingWithZeroStack: Seat[] = []
|
||||
const decide = (betting: BettingState, seat: Seat): Action => {
|
||||
if (betting.seats[seat].stack === 0) {
|
||||
seenActingWithZeroStack.push(seat)
|
||||
}
|
||||
return alwaysCheckOrCall(betting, seat)
|
||||
}
|
||||
const result = playHand(createRng(1), s, { decide })
|
||||
expect(seenActingWithZeroStack).toEqual([])
|
||||
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
|
||||
})
|
||||
})
|
||||
@ -1,182 +0,0 @@
|
||||
import type { Action, BettingState } from './bettingRound'
|
||||
import { applyAction, legalActions } from './bettingRound'
|
||||
import { createDealCursor, dealCards, shuffleDeck } from './deck'
|
||||
import { buildDeck } from './deck'
|
||||
import { compareHandScore, evaluate7 } from './handEval'
|
||||
import type { HandScore } from './handEval'
|
||||
import type { Rng } from './rng'
|
||||
import type { Card, Seat } from './types'
|
||||
|
||||
export interface HeadsUpStacks {
|
||||
player: number
|
||||
opponent: number
|
||||
}
|
||||
|
||||
export interface HandSetup {
|
||||
buttonSeat: Seat // heads-up: button posts the small blind and acts first preflop
|
||||
bigBlind: number
|
||||
smallBlind: number
|
||||
stacks: HeadsUpStacks
|
||||
}
|
||||
|
||||
export interface HandResult {
|
||||
winner: Seat | 'split'
|
||||
potAwarded: number
|
||||
wentToShowdown: boolean
|
||||
finalStacks: HeadsUpStacks
|
||||
loserSeat: Seat | null
|
||||
board: readonly Card[]
|
||||
/** Both seats' hole cards, but ONLY at a real showdown — a folder's cards are never revealed. */
|
||||
revealedHoleCards: Record<Seat, readonly Card[]> | null
|
||||
}
|
||||
|
||||
import {
|
||||
initialBettingState,
|
||||
other,
|
||||
potFromBetting,
|
||||
returnUncalledExcess,
|
||||
stacksFromBetting,
|
||||
STREET_ORDER,
|
||||
} from './handFlowBetting'
|
||||
|
||||
export { potFromBetting } from './handFlowBetting'
|
||||
|
||||
|
||||
export interface HandFlowCallbacks {
|
||||
/**
|
||||
* Called whenever it's a seat's turn; must return one of legalActions(betting). Receives ONLY
|
||||
* the acting seat's own hole cards — never the opponent's — so a decision function can never
|
||||
* see cards it shouldn't, by construction rather than by discipline.
|
||||
*/
|
||||
decide: (betting: BettingState, seat: Seat, board: readonly Card[], ownHoleCards: readonly Card[]) => Action
|
||||
}
|
||||
|
||||
/** One paused decision point within a hand: who must act, and on what they may see. */
|
||||
export interface HandStep {
|
||||
betting: BettingState
|
||||
seat: Seat
|
||||
board: readonly Card[]
|
||||
ownHoleCards: readonly Card[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a single hand end-to-end as a generator: posts blinds, deals hole cards, and pauses at
|
||||
* every decision point (`yield`ing a HandStep) instead of calling a callback directly. The caller
|
||||
* resumes it by passing the chosen Action into `.next(action)`. This lets a UI drive the hand one
|
||||
* decision at a time (suspend on the player's turn, wait for a click) without making the engine
|
||||
* itself async — it's suspension, not IO.
|
||||
*/
|
||||
export function* playHandSteps(rng: Rng, setup: HandSetup): Generator<HandStep, HandResult, Action> {
|
||||
const deck = shuffleDeck(rng, buildDeck())
|
||||
let cursor = createDealCursor(deck)
|
||||
|
||||
const dealtPlayer = dealCards(cursor, 2)
|
||||
cursor = dealtPlayer.cursor
|
||||
const dealtOpponent = dealCards(cursor, 2)
|
||||
cursor = dealtOpponent.cursor
|
||||
const holeCards: Record<Seat, readonly Card[]> = {
|
||||
player: dealtPlayer.cards,
|
||||
opponent: dealtOpponent.cards,
|
||||
}
|
||||
|
||||
let board: Card[] = []
|
||||
let betting = initialBettingState(setup, 'preflop', 0, setup.stacks)
|
||||
|
||||
for (const street of STREET_ORDER) {
|
||||
if (street !== 'preflop') {
|
||||
const settled = returnUncalledExcess(betting)
|
||||
const communityCount = street === 'flop' ? 3 : 1
|
||||
const dealt = dealCards(cursor, communityCount)
|
||||
cursor = dealt.cursor
|
||||
board = [...board, ...dealt.cards]
|
||||
betting = initialBettingState(setup, street, potFromBetting(settled), stacksFromBetting(settled))
|
||||
}
|
||||
|
||||
while (legalActions(betting).length > 0) {
|
||||
const action = yield { betting, seat: betting.toAct, board, ownHoleCards: holeCards[betting.toAct] }
|
||||
betting = applyAction(betting, action)
|
||||
}
|
||||
|
||||
if (betting.seats.player.folded || betting.seats.opponent.folded) {
|
||||
return resolveByFold(betting, board)
|
||||
}
|
||||
}
|
||||
|
||||
return resolveByShowdown(betting, board, holeCards)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a single hand end-to-end: posts blinds, deals hole cards, runs betting rounds interleaved
|
||||
* with community-card deals, and resolves the pot at fold or showdown. Thin synchronous driver
|
||||
* over `playHandSteps` for callers (tests, the PC-only path) that don't need to suspend mid-hand.
|
||||
*/
|
||||
export const playHand = (rng: Rng, setup: HandSetup, callbacks: HandFlowCallbacks): HandResult => {
|
||||
const gen = playHandSteps(rng, setup)
|
||||
let step = gen.next()
|
||||
while (!step.done) {
|
||||
const { betting, seat, board, ownHoleCards } = step.value
|
||||
step = gen.next(callbacks.decide(betting, seat, board, ownHoleCards))
|
||||
}
|
||||
return step.value
|
||||
}
|
||||
|
||||
const resolveByFold = (betting: BettingState, board: readonly Card[]): HandResult => {
|
||||
const settled = returnUncalledExcess(betting)
|
||||
const pot = potFromBetting(settled)
|
||||
const loserSeat: Seat = settled.seats.player.folded ? 'player' : 'opponent'
|
||||
const winnerSeat = other(loserSeat)
|
||||
const finalStacks: HeadsUpStacks = {
|
||||
player: settled.seats.player.stack + (winnerSeat === 'player' ? pot : 0),
|
||||
opponent: settled.seats.opponent.stack + (winnerSeat === 'opponent' ? pot : 0),
|
||||
}
|
||||
// A folder's cards are never revealed, even to show what they held.
|
||||
return { winner: winnerSeat, potAwarded: pot, wentToShowdown: false, finalStacks, loserSeat, board, revealedHoleCards: null }
|
||||
}
|
||||
|
||||
const resolveByShowdown = (
|
||||
betting: BettingState,
|
||||
board: readonly Card[],
|
||||
holeCards: Record<Seat, readonly Card[]>,
|
||||
): HandResult => {
|
||||
const settled = returnUncalledExcess(betting)
|
||||
const pot = potFromBetting(settled)
|
||||
const playerCards = [...holeCards.player, ...board] as [Card, Card, Card, Card, Card, Card, Card]
|
||||
const opponentCards = [...holeCards.opponent, ...board] as [Card, Card, Card, Card, Card, Card, Card]
|
||||
const playerScore: HandScore = evaluate7(playerCards)
|
||||
const opponentScore: HandScore = evaluate7(opponentCards)
|
||||
const cmp = compareHandScore(playerScore, opponentScore)
|
||||
|
||||
const stacks = stacksFromBetting(settled)
|
||||
if (cmp === 0) {
|
||||
// Split pot: heads-up play always returns uncalled excess before showdown, so both seats'
|
||||
// committed amounts are equal and the pot is provably even — no odd-chip remainder rule needed.
|
||||
const half = pot / 2
|
||||
return {
|
||||
winner: 'split',
|
||||
potAwarded: pot,
|
||||
wentToShowdown: true,
|
||||
finalStacks: {
|
||||
player: stacks.player + half,
|
||||
opponent: stacks.opponent + half,
|
||||
},
|
||||
loserSeat: null,
|
||||
board,
|
||||
revealedHoleCards: holeCards,
|
||||
}
|
||||
}
|
||||
|
||||
const winnerSeat: Seat = cmp > 0 ? 'player' : 'opponent'
|
||||
const loserSeat = other(winnerSeat)
|
||||
return {
|
||||
winner: winnerSeat,
|
||||
potAwarded: pot,
|
||||
wentToShowdown: true,
|
||||
finalStacks: {
|
||||
player: stacks.player + (winnerSeat === 'player' ? pot : 0),
|
||||
opponent: stacks.opponent + (winnerSeat === 'opponent' ? pot : 0),
|
||||
},
|
||||
loserSeat,
|
||||
board,
|
||||
revealedHoleCards: holeCards,
|
||||
}
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
import type { BettingState, SeatState } from './bettingRound'
|
||||
import type { Seat, Street } from './types'
|
||||
import type { HandSetup, HeadsUpStacks } from './handFlow'
|
||||
|
||||
/**
|
||||
* Betting-state helpers for handFlow, split out to stay under the 250-line cap.
|
||||
*/
|
||||
|
||||
export const other = (seat: Seat): Seat => (seat === 'player' ? 'opponent' : 'player')
|
||||
|
||||
export const STREET_ORDER: readonly Street[] = ['preflop', 'flop', 'turn', 'river']
|
||||
|
||||
export const initialBettingState = (setup: HandSetup, street: Street, pot: number, stacks: HeadsUpStacks): BettingState => {
|
||||
const nonButton = other(setup.buttonSeat)
|
||||
// A seat already at 0 chips (all-in from an earlier street's blind/bet) stays all-in on every
|
||||
// later street — a fresh `allIn: false` here would wrongly offer fold/check on a 0 stack.
|
||||
const buttonSeat: SeatState = {
|
||||
stack: stacks[setup.buttonSeat],
|
||||
committed: 0,
|
||||
hasActed: false,
|
||||
folded: false,
|
||||
allIn: stacks[setup.buttonSeat] === 0,
|
||||
}
|
||||
const nonButtonSeat: SeatState = {
|
||||
stack: stacks[nonButton],
|
||||
committed: 0,
|
||||
hasActed: false,
|
||||
folded: false,
|
||||
allIn: stacks[nonButton] === 0,
|
||||
}
|
||||
|
||||
if (street === 'preflop') {
|
||||
// Heads-up: button posts small blind and acts FIRST preflop.
|
||||
const buttonPosted: SeatState = { ...buttonSeat, ...postBlind(buttonSeat, setup.smallBlind) }
|
||||
const nonButtonPosted: SeatState = { ...nonButtonSeat, ...postBlind(nonButtonSeat, setup.bigBlind) }
|
||||
return {
|
||||
street,
|
||||
pot,
|
||||
toAct: setup.buttonSeat,
|
||||
seats: { [setup.buttonSeat]: buttonPosted, [nonButton]: nonButtonPosted } as Record<Seat, SeatState>,
|
||||
betToCall: nonButtonPosted.committed,
|
||||
minRaiseSize: setup.bigBlind,
|
||||
roundOver: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Postflop: non-button (big blind) acts first.
|
||||
return {
|
||||
street,
|
||||
pot,
|
||||
toAct: nonButton,
|
||||
seats: { [setup.buttonSeat]: buttonSeat, [nonButton]: nonButtonSeat } as Record<Seat, SeatState>,
|
||||
betToCall: 0,
|
||||
minRaiseSize: setup.bigBlind,
|
||||
roundOver: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const postBlind = (seatState: SeatState, amount: number): SeatState => {
|
||||
const spend = Math.min(amount, seatState.stack)
|
||||
return { stack: seatState.stack - spend, committed: spend, hasActed: false, folded: false, allIn: seatState.stack - spend === 0 }
|
||||
}
|
||||
|
||||
/** Returns the uncalled excess of the seat with the higher committed amount, and the state with it returned. */
|
||||
export const returnUncalledExcess = (betting: BettingState): BettingState => {
|
||||
const { player, opponent } = betting.seats
|
||||
if (player.committed === opponent.committed) {
|
||||
return betting
|
||||
}
|
||||
const [higher, higherSeat, lowerCommitted] =
|
||||
player.committed > opponent.committed ? [player, 'player' as Seat, opponent.committed] : [opponent, 'opponent' as Seat, player.committed]
|
||||
const excess = higher.committed - lowerCommitted
|
||||
return {
|
||||
...betting,
|
||||
seats: {
|
||||
...betting.seats,
|
||||
[higherSeat]: { ...higher, committed: higher.committed - excess, stack: higher.stack + excess },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The live pot, including both seats' amounts committed on the current street (not yet swept into `betting.pot`). */
|
||||
export const potFromBetting = (betting: BettingState): number =>
|
||||
betting.pot + betting.seats.player.committed + betting.seats.opponent.committed
|
||||
|
||||
export const stacksFromBetting = (betting: BettingState): HeadsUpStacks => ({
|
||||
player: betting.seats.player.stack,
|
||||
opponent: betting.seats.opponent.stack,
|
||||
})
|
||||
@ -1,110 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { legalActions } from './bettingRound'
|
||||
import type { HandSetup } from './handFlow'
|
||||
import { startInteractiveHand } from './interactiveHand'
|
||||
import { createRng } from './rng'
|
||||
|
||||
const setup = (overrides: Partial<HandSetup> = {}): HandSetup => ({
|
||||
buttonSeat: 'player',
|
||||
bigBlind: 10,
|
||||
smallBlind: 5,
|
||||
stacks: { player: 200, opponent: 200 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const totalStack = (setupArg: HandSetup): number => setupArg.stacks.player + setupArg.stacks.opponent
|
||||
|
||||
/** Drives an interactive hand to completion always checking/calling on the player's turns. */
|
||||
const runToCompletion = (hand: ReturnType<typeof startInteractiveHand>): void => {
|
||||
while (hand.status.kind === 'awaiting') {
|
||||
const betting = hand.status.step.betting
|
||||
const toCall = betting.betToCall - betting.seats.player.committed
|
||||
hand.submit(toCall > 0 ? { type: 'call' } : { type: 'check' })
|
||||
}
|
||||
}
|
||||
|
||||
describe('startInteractiveHand', () => {
|
||||
it('pauses on the player seat only, never surfacing the opponent as awaiting', () => {
|
||||
const s = setup()
|
||||
const hand = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
|
||||
expect(hand.status.kind).toBe('awaiting')
|
||||
if (hand.status.kind !== 'awaiting') {
|
||||
throw new Error('unreachable: asserted above')
|
||||
}
|
||||
expect(hand.status.step.seat).toBe('player')
|
||||
})
|
||||
|
||||
it('every offered awaiting action is legal for the current betting state', () => {
|
||||
const s = setup()
|
||||
const hand = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
|
||||
while (hand.status.kind === 'awaiting') {
|
||||
const { step } = hand.status
|
||||
const legal = legalActions(step.betting)
|
||||
const toCall = step.betting.betToCall - step.betting.seats.player.committed
|
||||
const action = toCall > 0 ? { type: 'call' as const } : { type: 'check' as const }
|
||||
expect(legal).toContainEqual(action)
|
||||
hand.submit(action)
|
||||
}
|
||||
})
|
||||
|
||||
it('runs a full hand to completion via alternating player submits and internal opponent turns', () => {
|
||||
const s = setup()
|
||||
const hand = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
|
||||
runToCompletion(hand)
|
||||
expect(hand.status.kind).toBe('complete')
|
||||
if (hand.status.kind !== 'complete') {
|
||||
throw new Error('unreachable: asserted above')
|
||||
}
|
||||
const { result } = hand.status
|
||||
expect(result.finalStacks.player + result.finalStacks.opponent).toBe(totalStack(s))
|
||||
})
|
||||
|
||||
it('resolves a preflop player fold without ever pausing again', () => {
|
||||
const s = setup()
|
||||
const hand = startInteractiveHand(createRng(2), createRng(2 ^ 0x9e3779b9), s, 1)
|
||||
expect(hand.status.kind).toBe('awaiting')
|
||||
hand.submit({ type: 'fold' })
|
||||
expect(hand.status.kind).toBe('complete')
|
||||
if (hand.status.kind !== 'complete') {
|
||||
throw new Error('unreachable: asserted above')
|
||||
}
|
||||
expect(hand.status.result.loserSeat).toBe('player')
|
||||
})
|
||||
|
||||
it('throws if submit is called with no decision pending', () => {
|
||||
const s = setup()
|
||||
const hand = startInteractiveHand(createRng(2), createRng(2 ^ 0x9e3779b9), s, 1)
|
||||
hand.submit({ type: 'fold' })
|
||||
expect(hand.status.kind).toBe('complete')
|
||||
expect(() => {
|
||||
hand.submit({ type: 'fold' })
|
||||
}).toThrow('no player decision is pending')
|
||||
})
|
||||
|
||||
it('is deterministic for fixed deal and policy seeds', () => {
|
||||
const s = setup()
|
||||
const a = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
|
||||
runToCompletion(a)
|
||||
const b = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, 1)
|
||||
runToCompletion(b)
|
||||
expect(a.status).toEqual(b.status)
|
||||
})
|
||||
|
||||
it('changing opponent iterations does not change the deal (dealRng and policyRng are independent)', () => {
|
||||
const s = setup()
|
||||
const collectBoards = (opponentIterations: number): unknown[] => {
|
||||
const hand = startInteractiveHand(createRng(1), createRng(1 ^ 0x9e3779b9), s, opponentIterations)
|
||||
const boards: unknown[] = []
|
||||
while (hand.status.kind === 'awaiting') {
|
||||
const { step } = hand.status
|
||||
boards.push(step.board)
|
||||
const toCall = step.betting.betToCall - step.betting.seats.player.committed
|
||||
hand.submit(toCall > 0 ? { type: 'call' } : { type: 'check' })
|
||||
}
|
||||
return boards
|
||||
}
|
||||
// Different iteration counts consume policyRng differently; if it leaked into dealing, the
|
||||
// board sequence collected at each player-decision pause would diverge between the two runs.
|
||||
expect(collectBoards(1)).toEqual(collectBoards(5))
|
||||
})
|
||||
})
|
||||
@ -1,64 +0,0 @@
|
||||
import type { Action } from './bettingRound'
|
||||
import { legalActions } from './bettingRound'
|
||||
import type { HandResult, HandSetup, HandStep } from './handFlow'
|
||||
import { playHandSteps } from './handFlow'
|
||||
import { decideAction } from './opponentPolicy'
|
||||
import type { Rng } from './rng'
|
||||
import type { Card } from './types'
|
||||
|
||||
/**
|
||||
* Exactly one of the two states holds at any time — a discriminated union instead of two
|
||||
* independently-nullable fields, so "both null" / "both set" are unrepresentable rather than
|
||||
* defensive-but-unreachable states a caller has to guard against.
|
||||
*/
|
||||
export type HandStatus = { kind: 'awaiting'; step: HandStep } | { kind: 'complete'; result: HandResult }
|
||||
|
||||
export interface InteractiveHand {
|
||||
readonly status: HandStatus
|
||||
/** Submits the player's action for the current `awaiting` step, resolves any opponent turns, and advances. */
|
||||
submit: (action: Action) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives `playHandSteps` to completion, resolving every OPPONENT decision point internally via
|
||||
* `decideAction` (using its own policy RNG, separate from the dealing RNG so tuning `iterations`
|
||||
* never perturbs deal order) and only ever pausing at `status.step` on the PLAYER's turn. This is
|
||||
* the seam a UI drives: read `status`, call `submit(action)` on a click, repeat until `complete`.
|
||||
*/
|
||||
export const startInteractiveHand = (
|
||||
dealRng: Rng,
|
||||
policyRng: Rng,
|
||||
setup: HandSetup,
|
||||
opponentIterations: number,
|
||||
): InteractiveHand => {
|
||||
const gen = playHandSteps(dealRng, setup)
|
||||
|
||||
const advance = (next: IteratorResult<HandStep, HandResult>): HandStatus => {
|
||||
let step = next
|
||||
while (!step.done) {
|
||||
if (step.value.seat === 'player') {
|
||||
return { kind: 'awaiting', step: step.value }
|
||||
}
|
||||
const { betting, board, ownHoleCards } = step.value
|
||||
const legal = legalActions(betting)
|
||||
const ownHole = ownHoleCards as [Card, Card]
|
||||
const action = decideAction(betting, legal, ownHole, board, policyRng, opponentIterations)
|
||||
step = gen.next(action)
|
||||
}
|
||||
return { kind: 'complete', result: step.value }
|
||||
}
|
||||
|
||||
let status: HandStatus = advance(gen.next())
|
||||
|
||||
return {
|
||||
get status(): HandStatus {
|
||||
return status
|
||||
},
|
||||
submit: (action: Action): void => {
|
||||
if (status.kind !== 'awaiting') {
|
||||
throw new Error('startInteractiveHand: no player decision is pending')
|
||||
}
|
||||
status = advance(gen.next(action))
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { legalActions } from './bettingRound'
|
||||
import { startInteractiveHand } from './interactiveHand'
|
||||
import { createRng } from './rng'
|
||||
import { applyHandResult, cashOutSession, deriveBlindSize, startSession } from './session'
|
||||
import { settleSession } from '../stakes/settlement'
|
||||
|
||||
const BUY_IN_CHIPS = 100
|
||||
const SEEDS = 40
|
||||
const MAX_HANDS = 400
|
||||
const POLICY_SEED_SALT = 0x9e3779b9
|
||||
|
||||
/**
|
||||
* Property sweep over many seeds, complementing the scripted per-module tests: those pin specific
|
||||
* scenarios, this asserts the properties that must hold for EVERY session the engine can produce.
|
||||
*
|
||||
* Chip conservation is the important one — a heads-up table is a closed system, so if chips are
|
||||
* ever created or destroyed the stakes model is compromised (the settlement formula reads
|
||||
* `finalChips` directly, so invented chips would become forgiven debt).
|
||||
*/
|
||||
describe('engine invariants across many seeds', () => {
|
||||
it('conserves chips, keeps stacks non-negative, and always settles by the formula', () => {
|
||||
const failures: string[] = []
|
||||
let totalDecisions = 0
|
||||
let sessionsEnded = 0
|
||||
|
||||
for (let seed = 1; seed <= SEEDS; seed += 1) {
|
||||
let session = startSession({ buyInChips: BUY_IN_CHIPS })
|
||||
const dealRng = createRng(seed)
|
||||
const policyRng = createRng(seed ^ POLICY_SEED_SALT)
|
||||
const tableTotal = session.stacks.player + session.stacks.opponent
|
||||
|
||||
for (let hand = 0; hand < MAX_HANDS && session.outcome === null; hand += 1) {
|
||||
const { bigBlind, smallBlind } = deriveBlindSize(BUY_IN_CHIPS)
|
||||
const interactive = startInteractiveHand(
|
||||
dealRng,
|
||||
policyRng,
|
||||
{ buttonSeat: session.buttonSeat, bigBlind, smallBlind, stacks: session.stacks },
|
||||
50,
|
||||
)
|
||||
|
||||
// Always take the first legal action — an arbitrary but deterministic strategy. The
|
||||
// invariants below must hold regardless of how badly the player plays.
|
||||
let guard = 0
|
||||
while (interactive.status.kind === 'awaiting' && guard < 200) {
|
||||
const [first] = legalActions(interactive.status.step.betting)
|
||||
if (first === undefined) {
|
||||
break
|
||||
}
|
||||
interactive.submit(first)
|
||||
totalDecisions += 1
|
||||
guard += 1
|
||||
}
|
||||
|
||||
const status = interactive.status
|
||||
if (status.kind !== 'complete') {
|
||||
failures.push(`seed ${String(seed)} hand ${String(hand)}: hand never completed`)
|
||||
break
|
||||
}
|
||||
|
||||
session = applyHandResult(session, status.result)
|
||||
const sum = session.stacks.player + session.stacks.opponent
|
||||
if (sum !== tableTotal) {
|
||||
failures.push(`seed ${String(seed)} hand ${String(hand)}: chips ${String(sum)} != ${String(tableTotal)}`)
|
||||
}
|
||||
if (session.stacks.player < 0 || session.stacks.opponent < 0) {
|
||||
failures.push(`seed ${String(seed)} hand ${String(hand)}: negative stack`)
|
||||
}
|
||||
}
|
||||
|
||||
if (session.outcome !== null) {
|
||||
sessionsEnded += 1
|
||||
}
|
||||
const ended = session.outcome !== null ? session : cashOutSession(session)
|
||||
const { outcome } = ended
|
||||
if (outcome !== null) {
|
||||
const debt = settleSession({ taskTypeId: 'pushups', chips: BUY_IN_CHIPS }, outcome, 'id', 0)
|
||||
const expected = Math.max(0, 2 * BUY_IN_CHIPS - outcome.finalChips)
|
||||
if (debt.owedUnits !== expected) {
|
||||
failures.push(`seed ${String(seed)}: owed ${String(debt.owedUnits)} != ${String(expected)}`)
|
||||
}
|
||||
if (debt.owedUnits > 2 * BUY_IN_CHIPS) {
|
||||
failures.push(`seed ${String(seed)}: owed ${String(debt.owedUnits)} exceeds the 2x cap`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guards against the sweep silently doing nothing (an empty loop would otherwise "pass").
|
||||
expect(totalDecisions).toBeGreaterThan(500)
|
||||
expect(sessionsEnded).toBe(SEEDS)
|
||||
expect(failures.slice(0, 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
@ -1,141 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Action, BettingState, SeatState } from './bettingRound'
|
||||
import { legalActions } from './bettingRound'
|
||||
import { chooseAction, decideAction, estimateEquity, potOddsToCall } from './opponentPolicy'
|
||||
import { createRng } from './rng'
|
||||
import type { Card } from './types'
|
||||
|
||||
const card = (rank: Card['rank'], suit: Card['suit']): Card => ({ rank, suit })
|
||||
|
||||
const seat = (overrides: Partial<SeatState> = {}): SeatState => ({
|
||||
stack: 100,
|
||||
committed: 0,
|
||||
hasActed: false,
|
||||
folded: false,
|
||||
allIn: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const betting = (overrides: Partial<BettingState> = {}): BettingState => ({
|
||||
street: 'preflop',
|
||||
pot: 0,
|
||||
toAct: 'player',
|
||||
seats: { player: seat(), opponent: seat() },
|
||||
betToCall: 0,
|
||||
minRaiseSize: 10,
|
||||
roundOver: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('chooseAction', () => {
|
||||
it('bets/raises the max size on strong equity when available', () => {
|
||||
const legal: Action[] = [{ type: 'fold' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'bet', amount: 100 }]
|
||||
expect(chooseAction(legal, 0.9, 0.2)).toEqual({ type: 'bet', amount: 100 })
|
||||
})
|
||||
|
||||
it('bets/raises the min size on moderate equity when strong-equity threshold is not met', () => {
|
||||
const legal: Action[] = [{ type: 'fold' }, { type: 'check' }, { type: 'bet', amount: 10 }, { type: 'bet', amount: 100 }]
|
||||
expect(chooseAction(legal, 0.6, 0.2)).toEqual({ type: 'bet', amount: 10 })
|
||||
})
|
||||
|
||||
it('calls/checks when equity clears pot odds but is below the aggressive thresholds', () => {
|
||||
const legal: Action[] = [{ type: 'fold' }, { type: 'call' }]
|
||||
expect(chooseAction(legal, 0.3, 0.2)).toEqual({ type: 'call' })
|
||||
})
|
||||
|
||||
it('folds when equity is below pot odds and folding is available', () => {
|
||||
const legal: Action[] = [{ type: 'fold' }, { type: 'call' }]
|
||||
expect(chooseAction(legal, 0.1, 0.5)).toEqual({ type: 'fold' })
|
||||
})
|
||||
|
||||
it('checks for free (no fold available, e.g. facing no bet) when equity is below thresholds', () => {
|
||||
const legal: Action[] = [{ type: 'check' }]
|
||||
expect(chooseAction(legal, 0.1, 0)).toEqual({ type: 'check' })
|
||||
})
|
||||
|
||||
it('falls back to the only legal action when nothing else matches', () => {
|
||||
const legal: Action[] = [{ type: 'call' }]
|
||||
// High equity but no aggressive option and no fold available: falls through to passive.
|
||||
expect(chooseAction(legal, 0.95, 0.5)).toEqual({ type: 'call' })
|
||||
})
|
||||
|
||||
it('picks the first legal action as a last resort when only aggressive options exist above threshold gating', () => {
|
||||
// Only a single bet is legal and equity/pot-odds are both very low with no fold/passive offered.
|
||||
const legal: Action[] = [{ type: 'bet', amount: 10 }]
|
||||
expect(chooseAction(legal, 0, 1)).toEqual({ type: 'bet', amount: 10 })
|
||||
})
|
||||
|
||||
it('checks/calls when equity is low and there is no fold option available (facing an all-in check)', () => {
|
||||
// No fold offered (e.g. already all-in facing no further decision except check) — falls
|
||||
// through the "fold" branch to the passive one instead.
|
||||
const legal: Action[] = [{ type: 'check' }]
|
||||
expect(chooseAction(legal, 0, 1)).toEqual({ type: 'check' })
|
||||
})
|
||||
|
||||
it('throws when given no legal actions at all', () => {
|
||||
expect(() => chooseAction([], 0.5, 0.5)).toThrow('no legal actions to choose from')
|
||||
})
|
||||
})
|
||||
|
||||
describe('potOddsToCall', () => {
|
||||
it('is zero when nothing is owed', () => {
|
||||
expect(potOddsToCall(betting({ betToCall: 0 }))).toBe(0)
|
||||
})
|
||||
|
||||
it('computes the fraction of the resulting pot the call would represent', () => {
|
||||
const s = betting({
|
||||
betToCall: 20,
|
||||
pot: 0,
|
||||
seats: { player: seat({ committed: 0 }), opponent: seat({ committed: 20 }) },
|
||||
})
|
||||
// toCall = 20, potAfterCall = 0 + 0 + 20 + 20 = 40, odds = 20/40 = 0.5
|
||||
expect(potOddsToCall(s)).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateEquity', () => {
|
||||
it('returns a value in [0, 1]', () => {
|
||||
const ownHole: [Card, Card] = [card(14, 'clubs'), card(14, 'diamonds')]
|
||||
const equity = estimateEquity(ownHole, [], createRng(1), 50)
|
||||
expect(equity).toBeGreaterThanOrEqual(0)
|
||||
expect(equity).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('is deterministic for a fixed seed', () => {
|
||||
const ownHole: [Card, Card] = [card(14, 'clubs'), card(14, 'diamonds')]
|
||||
const a = estimateEquity(ownHole, [], createRng(7), 30)
|
||||
const b = estimateEquity(ownHole, [], createRng(7), 30)
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
it('returns 0 for zero iterations', () => {
|
||||
const ownHole: [Card, Card] = [card(2, 'clubs'), card(3, 'diamonds')]
|
||||
expect(estimateEquity(ownHole, [], createRng(1), 0)).toBe(0)
|
||||
})
|
||||
|
||||
it('rates pocket aces higher than a weak hand on average, over many samples', () => {
|
||||
const strongHole: [Card, Card] = [card(14, 'clubs'), card(14, 'diamonds')]
|
||||
const weakHole: [Card, Card] = [card(2, 'hearts'), card(7, 'spades')]
|
||||
const strongEquity = estimateEquity(strongHole, [], createRng(3), 200)
|
||||
const weakEquity = estimateEquity(weakHole, [], createRng(3), 200)
|
||||
expect(strongEquity).toBeGreaterThan(weakEquity)
|
||||
})
|
||||
|
||||
it('estimates equity with a partially dealt board (flop known)', () => {
|
||||
const ownHole: [Card, Card] = [card(14, 'clubs'), card(13, 'clubs')]
|
||||
const board: Card[] = [card(12, 'clubs'), card(11, 'clubs'), card(2, 'hearts')]
|
||||
const equity = estimateEquity(ownHole, board, createRng(5), 40)
|
||||
expect(equity).toBeGreaterThanOrEqual(0)
|
||||
expect(equity).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decideAction', () => {
|
||||
it('always returns one of the offered legal actions', () => {
|
||||
const s = betting({ betToCall: 0, minRaiseSize: 10, seats: { player: seat({ stack: 100 }), opponent: seat() } })
|
||||
const legal = legalActions(s)
|
||||
const ownHole: [Card, Card] = [card(14, 'clubs'), card(14, 'diamonds')]
|
||||
const action = decideAction(s, legal, ownHole, [], createRng(1), 20)
|
||||
expect(legal).toContainEqual(action)
|
||||
})
|
||||
})
|
||||
@ -1,105 +0,0 @@
|
||||
import type { Action, BettingState } from './bettingRound'
|
||||
import { buildDeck, shuffleDeck } from './deck'
|
||||
import { compareHandScore, evaluate7 } from './handEval'
|
||||
import type { Rng } from './rng'
|
||||
import type { Card } from './types'
|
||||
|
||||
const cardKey = (card: Card): string => `${String(card.rank)}-${card.suit}`
|
||||
|
||||
/**
|
||||
* Monte Carlo equity estimate: samples `iterations` plausible completions of the unknown cards
|
||||
* (opponent's hole cards drawn from this seat's perspective, plus any remaining board cards) and
|
||||
* returns the fraction of samples this seat's final hand wins or ties.
|
||||
*
|
||||
* Only ever sees `ownHole` and the current `board` — never the real opponent's hole cards.
|
||||
*/
|
||||
export const estimateEquity = (
|
||||
ownHole: readonly [Card, Card],
|
||||
board: readonly Card[],
|
||||
rng: Rng,
|
||||
iterations: number,
|
||||
): number => {
|
||||
const known = new Set([...ownHole, ...board].map(cardKey))
|
||||
const remaining = buildDeck().filter((card) => !known.has(cardKey(card)))
|
||||
const boardCardsNeeded = 5 - board.length
|
||||
|
||||
let wins = 0
|
||||
for (let i = 0; i < iterations; i += 1) {
|
||||
const shuffled = shuffleDeck(rng, remaining)
|
||||
const opponentHole = shuffled.slice(0, 2) as [Card, Card]
|
||||
const remainingBoard = shuffled.slice(2, 2 + boardCardsNeeded)
|
||||
const fullBoard = [...board, ...remainingBoard]
|
||||
|
||||
const ownSeven = [...ownHole, ...fullBoard] as [Card, Card, Card, Card, Card, Card, Card]
|
||||
const oppSeven = [...opponentHole, ...fullBoard] as [Card, Card, Card, Card, Card, Card, Card]
|
||||
const cmp = compareHandScore(evaluate7(ownSeven), evaluate7(oppSeven))
|
||||
if (cmp >= 0) {
|
||||
wins += 1
|
||||
}
|
||||
}
|
||||
return iterations === 0 ? 0 : wins / iterations
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks one of the legal actions using a hand-strength/pot-odds heuristic. Never constructs an
|
||||
* action outside `legal` — the returned action is always a member of that list.
|
||||
*/
|
||||
export const chooseAction = (legal: readonly Action[], equity: number, potOdds: number): Action => {
|
||||
const fold = legal.find((a) => a.type === 'fold')
|
||||
const passive = legal.find((a) => a.type === 'check' || a.type === 'call')
|
||||
const aggressiveActions = legal.filter((a) => a.type === 'bet' || a.type === 'raise')
|
||||
const minAggressive = aggressiveActions[0]
|
||||
const maxAggressive = aggressiveActions[aggressiveActions.length - 1]
|
||||
|
||||
// Strong equity: bet/raise big when possible, otherwise take whatever passive/fold action is offered.
|
||||
if (equity >= 0.7 && maxAggressive !== undefined) {
|
||||
return maxAggressive
|
||||
}
|
||||
// Moderate equity: open/raise a standard (minimum) size when possible.
|
||||
if (equity >= 0.55 && minAggressive !== undefined) {
|
||||
return minAggressive
|
||||
}
|
||||
// Equity clears the price being laid: call/check rather than fold.
|
||||
if (equity >= potOdds && passive !== undefined) {
|
||||
return passive
|
||||
}
|
||||
// Equity doesn't justify continuing: fold if there's a cost to continue, else check for free.
|
||||
if (fold !== undefined) {
|
||||
return fold
|
||||
}
|
||||
if (passive !== undefined) {
|
||||
return passive
|
||||
}
|
||||
const fallback = legal[0]
|
||||
if (fallback === undefined) {
|
||||
throw new Error('chooseAction: no legal actions to choose from')
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Computes pot odds: the price (as equity fraction) required to profitably call the amount owed. */
|
||||
export const potOddsToCall = (betting: BettingState): number => {
|
||||
const toCall = betting.betToCall - betting.seats[betting.toAct].committed
|
||||
if (toCall <= 0) {
|
||||
return 0
|
||||
}
|
||||
const potAfterCall = betting.pot + betting.seats.player.committed + betting.seats.opponent.committed + toCall
|
||||
return toCall / potAfterCall
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes estimateEquity + chooseAction into a full decision for the given betting state.
|
||||
* `ownHole` must be the deciding seat's own hole cards only.
|
||||
*/
|
||||
export const decideAction = (
|
||||
betting: BettingState,
|
||||
legal: readonly Action[],
|
||||
ownHole: readonly [Card, Card],
|
||||
board: readonly Card[],
|
||||
rng: Rng,
|
||||
iterations: number,
|
||||
): Action => {
|
||||
const equity = estimateEquity(ownHole, board, rng, iterations)
|
||||
const potOdds = potOddsToCall(betting)
|
||||
return chooseAction(legal, equity, potOdds)
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createRng, nextFloat, nextInt, pick } from './rng'
|
||||
|
||||
describe('rng', () => {
|
||||
it('is deterministic for a given seed', () => {
|
||||
const a = createRng(123)
|
||||
const b = createRng(123)
|
||||
expect([nextFloat(a), nextFloat(a), nextFloat(a)]).toEqual([
|
||||
nextFloat(b),
|
||||
nextFloat(b),
|
||||
nextFloat(b),
|
||||
])
|
||||
})
|
||||
|
||||
it('produces different sequences for different seeds', () => {
|
||||
expect(nextFloat(createRng(1))).not.toBe(nextFloat(createRng(2)))
|
||||
})
|
||||
|
||||
it('stays within [0, 1)', () => {
|
||||
const rng = createRng(77)
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
const v = nextFloat(rng)
|
||||
expect(v).toBeGreaterThanOrEqual(0)
|
||||
expect(v).toBeLessThan(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('wraps negative seeds into unsigned space', () => {
|
||||
expect(createRng(-1).s).toBe(0xffffffff)
|
||||
})
|
||||
|
||||
it('nextInt stays within [0, max)', () => {
|
||||
const rng = createRng(9)
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const v = nextInt(rng, 4)
|
||||
expect(v).toBeGreaterThanOrEqual(0)
|
||||
expect(v).toBeLessThan(4)
|
||||
}
|
||||
})
|
||||
|
||||
it('pick eventually returns every element of a tuple', () => {
|
||||
const rng = createRng(5)
|
||||
const seen = new Set<string>()
|
||||
for (let i = 0; i < 64; i += 1) {
|
||||
seen.add(pick(rng, ['a', 'b', 'c'] as const))
|
||||
}
|
||||
expect(seen).toEqual(new Set(['a', 'b', 'c']))
|
||||
})
|
||||
|
||||
it('pick is deterministic', () => {
|
||||
expect(pick(createRng(42), [10, 20, 30] as const)).toBe(
|
||||
pick(createRng(42), [10, 20, 30] as const),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -1,33 +0,0 @@
|
||||
/** Mutable state for a mulberry32 generator. */
|
||||
export interface Rng {
|
||||
s: number
|
||||
}
|
||||
|
||||
export const createRng = (seed: number): Rng => ({ s: seed >>> 0 })
|
||||
|
||||
/** Deterministic float in [0, 1). Advances the generator. */
|
||||
export const nextFloat = (rng: Rng): number => {
|
||||
rng.s = (rng.s + 0x6d2b79f5) >>> 0
|
||||
let t = rng.s
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
|
||||
/** Deterministic integer in [0, maxExclusive). */
|
||||
export const nextInt = (rng: Rng, maxExclusive: number): number =>
|
||||
Math.floor(nextFloat(rng) * maxExclusive)
|
||||
|
||||
/** Deterministic pick from a non-empty tuple. */
|
||||
export const pick = <T>(rng: Rng, items: readonly [T, ...T[]]): T => {
|
||||
const idx = nextInt(rng, items.length)
|
||||
let out: T = items[0]
|
||||
let i = 0
|
||||
for (const item of items) {
|
||||
if (i === idx) {
|
||||
out = item
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Action, BettingState } from './bettingRound'
|
||||
import { legalActions } from './bettingRound'
|
||||
import { createRng } from './rng'
|
||||
import { cashOutSession, deriveBlindSize, playSessionHand, startSession } from './session'
|
||||
import type { Seat } from './types'
|
||||
|
||||
const enforceLegal = (
|
||||
decide: (betting: BettingState, seat: Seat) => Action,
|
||||
): ((betting: BettingState, seat: Seat) => Action) => {
|
||||
return (betting, seat) => {
|
||||
const action = decide(betting, seat)
|
||||
expect(legalActions(betting)).toContainEqual(action)
|
||||
return action
|
||||
}
|
||||
}
|
||||
|
||||
const alwaysCheckOrCall = enforceLegal((betting) => {
|
||||
const toCall = betting.betToCall - betting.seats[betting.toAct].committed
|
||||
return toCall > 0 ? { type: 'call' } : { type: 'check' }
|
||||
})
|
||||
|
||||
/** Shoves all-in whenever a bet/raise is legal for the given seat, otherwise checks/calls. */
|
||||
const shoveForSeat = (targetSeat: Seat): ((betting: BettingState, seat: Seat) => Action) =>
|
||||
enforceLegal((betting, seat) => {
|
||||
if (seat !== targetSeat) {
|
||||
return alwaysCheckOrCall(betting, seat)
|
||||
}
|
||||
const actions = legalActions(betting)
|
||||
const shove = actions.find((a) => a.type === 'bet' || a.type === 'raise')
|
||||
return shove ?? alwaysCheckOrCall(betting, seat)
|
||||
})
|
||||
|
||||
describe('deriveBlindSize', () => {
|
||||
it('floors a tiny buy-in to the minimum viable blinds (BB=2, SB=1)', () => {
|
||||
expect(deriveBlindSize(20)).toEqual({ bigBlind: 2, smallBlind: 1 })
|
||||
})
|
||||
|
||||
it('scales blinds proportionally for a large buy-in', () => {
|
||||
expect(deriveBlindSize(1000)).toEqual({ bigBlind: 10, smallBlind: 5 })
|
||||
})
|
||||
|
||||
it('never produces a small blind equal to the big blind', () => {
|
||||
for (const buyIn of [1, 2, 20, 99, 100, 250, 10_000]) {
|
||||
const { bigBlind, smallBlind } = deriveBlindSize(buyIn)
|
||||
expect(smallBlind).toBeLessThan(bigBlind)
|
||||
expect(smallBlind).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSession', () => {
|
||||
it('starts both stacks equal to the buy-in with the player on the button', () => {
|
||||
const session = startSession({ buyInChips: 100 })
|
||||
expect(session.stacks).toEqual({ player: 100, opponent: 100 })
|
||||
expect(session.buttonSeat).toBe('player')
|
||||
expect(session.handsPlayed).toBe(0)
|
||||
expect(session.outcome).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playSessionHand', () => {
|
||||
it('updates stacks, increments handsPlayed, and rotates the button', () => {
|
||||
const session = startSession({ buyInChips: 200 })
|
||||
const { session: next } = playSessionHand(session, createRng(1), 200, { decide: alwaysCheckOrCall })
|
||||
expect(next.handsPlayed).toBe(1)
|
||||
expect(next.buttonSeat).toBe('opponent')
|
||||
expect(next.stacks.player + next.stacks.opponent).toBe(400)
|
||||
})
|
||||
|
||||
it('throws when called on an already-ended session', () => {
|
||||
const session = startSession({ buyInChips: 200 })
|
||||
const ended = cashOutSession(session)
|
||||
expect(() => playSessionHand(ended, createRng(1), 200, { decide: alwaysCheckOrCall })).toThrow(
|
||||
'session has already ended',
|
||||
)
|
||||
})
|
||||
|
||||
it('records a bust outcome when the player stack hits zero (deterministic seed)', () => {
|
||||
// Seed 1: player always shoves all-in, opponent always checks/calls — player loses and busts.
|
||||
const session = startSession({ buyInChips: 10 })
|
||||
const { session: after } = playSessionHand(session, createRng(1), 10, { decide: shoveForSeat('player') })
|
||||
expect(after.outcome).toEqual({ reason: 'bust', finalChips: 0 })
|
||||
expect(after.stacks.player).toBe(0)
|
||||
})
|
||||
|
||||
it('records a cash-out-equivalent outcome when the opponent stack hits zero (deterministic seed)', () => {
|
||||
// Seed 100: opponent always shoves all-in, player always checks/calls — opponent loses and busts.
|
||||
const session = startSession({ buyInChips: 10 })
|
||||
const { session: after } = playSessionHand(session, createRng(100), 10, { decide: shoveForSeat('opponent') })
|
||||
expect(after.stacks.opponent).toBe(0)
|
||||
expect(after.outcome).toEqual({ reason: 'cashOut', finalChips: after.stacks.player })
|
||||
expect(after.stacks.player).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('conserves total chips across many hands until the session ends', () => {
|
||||
let session = startSession({ buyInChips: 100 })
|
||||
let seed = 1
|
||||
while (session.outcome === null && session.handsPlayed < 30) {
|
||||
const result = playSessionHand(session, createRng(seed), 100, { decide: alwaysCheckOrCall })
|
||||
session = result.session
|
||||
seed += 1
|
||||
expect(session.stacks.player + session.stacks.opponent).toBe(200)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('cashOutSession', () => {
|
||||
it('records the current player stack as the outcome', () => {
|
||||
const session = startSession({ buyInChips: 100 })
|
||||
const { session: afterHand } = playSessionHand(session, createRng(1), 100, { decide: alwaysCheckOrCall })
|
||||
const cashedOut = cashOutSession(afterHand)
|
||||
expect(cashedOut.outcome).toEqual({ reason: 'cashOut', finalChips: afterHand.stacks.player })
|
||||
})
|
||||
|
||||
it('throws when called on an already-ended session', () => {
|
||||
const session = startSession({ buyInChips: 100 })
|
||||
const cashedOut = cashOutSession(session)
|
||||
expect(() => cashOutSession(cashedOut)).toThrow('session has already ended')
|
||||
})
|
||||
})
|
||||
@ -1,91 +0,0 @@
|
||||
import type { HandFlowCallbacks, HandResult, HeadsUpStacks } from './handFlow'
|
||||
import { playHand } from './handFlow'
|
||||
import type { Rng } from './rng'
|
||||
import type { Seat } from './types'
|
||||
|
||||
export interface SessionConfig {
|
||||
buyInChips: number
|
||||
}
|
||||
|
||||
export type SessionEndReason = 'cashOut' | 'bust'
|
||||
|
||||
export interface SessionOutcome {
|
||||
reason: SessionEndReason
|
||||
finalChips: number // the player's stack at the moment the session ended
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
stacks: HeadsUpStacks
|
||||
buttonSeat: Seat // rotates each hand
|
||||
handsPlayed: number
|
||||
outcome: SessionOutcome | null // null while the session is still live
|
||||
}
|
||||
|
||||
/** Big blind derived from the buy-in; small blind is always ≥1 and strictly less than BB. */
|
||||
export const deriveBlindSize = (buyInChips: number): { bigBlind: number; smallBlind: number } => {
|
||||
const bigBlind = Math.max(2, Math.floor(buyInChips / 100))
|
||||
const smallBlind = Math.floor(bigBlind / 2)
|
||||
return { bigBlind, smallBlind }
|
||||
}
|
||||
|
||||
export const startSession = (config: SessionConfig): SessionState => ({
|
||||
stacks: { player: config.buyInChips, opponent: config.buyInChips },
|
||||
buttonSeat: 'player',
|
||||
handsPlayed: 0,
|
||||
outcome: null,
|
||||
})
|
||||
|
||||
const other = (seat: Seat): Seat => (seat === 'player' ? 'opponent' : 'player')
|
||||
|
||||
/**
|
||||
* Folds a completed hand's result into the session: updates stacks, rotates the button, and
|
||||
* detects bust/cash-out. Pure — shared by `playSessionHand` (the PC-only synchronous path) and
|
||||
* any interactive driver that runs a hand via `playHandSteps` instead.
|
||||
*/
|
||||
export const applyHandResult = (session: SessionState, handResult: HandResult): SessionState => {
|
||||
const nextStacks = handResult.finalStacks
|
||||
// Player busting is a real debt event. Opponent busting means the player holds every chip in
|
||||
// play, which settles to owing nothing under the settlement formula — model it the same as a
|
||||
// cash-out (at the max chip count) rather than a special case, since no further hand is possible.
|
||||
let outcome: SessionOutcome | null = null
|
||||
if (nextStacks.player === 0) {
|
||||
outcome = { reason: 'bust', finalChips: 0 }
|
||||
} else if (nextStacks.opponent === 0) {
|
||||
outcome = { reason: 'cashOut', finalChips: nextStacks.player }
|
||||
}
|
||||
|
||||
return {
|
||||
stacks: nextStacks,
|
||||
buttonSeat: other(session.buttonSeat),
|
||||
handsPlayed: session.handsPlayed + 1,
|
||||
outcome,
|
||||
}
|
||||
}
|
||||
|
||||
/** Plays one hand, updates stacks, rotates the button, and detects bust. No-op if already ended. */
|
||||
export const playSessionHand = (
|
||||
session: SessionState,
|
||||
rng: Rng,
|
||||
buyInChips: number,
|
||||
callbacks: HandFlowCallbacks,
|
||||
): { session: SessionState; handResult: HandResult } => {
|
||||
if (session.outcome !== null) {
|
||||
throw new Error('playSessionHand: session has already ended')
|
||||
}
|
||||
const { bigBlind, smallBlind } = deriveBlindSize(buyInChips)
|
||||
const handResult = playHand(
|
||||
rng,
|
||||
{ buttonSeat: session.buttonSeat, bigBlind, smallBlind, stacks: session.stacks },
|
||||
callbacks,
|
||||
)
|
||||
|
||||
return { session: applyHandResult(session, handResult), handResult }
|
||||
}
|
||||
|
||||
/** Ends a live session by cash-out, recording the player's current chip count. */
|
||||
export const cashOutSession = (session: SessionState): SessionState => {
|
||||
if (session.outcome !== null) {
|
||||
throw new Error('cashOutSession: session has already ended')
|
||||
}
|
||||
return { ...session, outcome: { reason: 'cashOut', finalChips: session.stacks.player } }
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
/** Card ranks, stored high (Ace = 14). Wheel straights are handled explicitly in handEval. */
|
||||
export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14
|
||||
|
||||
export type Suit = 'clubs' | 'diamonds' | 'hearts' | 'spades'
|
||||
|
||||
export interface Card {
|
||||
rank: Rank
|
||||
suit: Suit
|
||||
}
|
||||
|
||||
export type Seat = 'player' | 'opponent'
|
||||
|
||||
export type Street = 'preflop' | 'flop' | 'turn' | 'river' | 'showdown'
|
||||
@ -1,27 +0,0 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('main', () => {
|
||||
it('refuses to boot without a #root element', async () => {
|
||||
await expect(import('./main')).rejects.toThrow('root element not found')
|
||||
})
|
||||
|
||||
it('mounts the app into #root', async () => {
|
||||
document.body.innerHTML = '<div id="root"></div>'
|
||||
await act(async () => {
|
||||
await import('./main')
|
||||
})
|
||||
// Asserts the mount happened, not the whole app's copy — App has its own tests for that.
|
||||
expect(document.querySelector('#root')?.textContent).toContain('Poker Stakes')
|
||||
})
|
||||
})
|
||||
@ -1,15 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
const rootElement = document.getElementById('root')
|
||||
if (rootElement === null) {
|
||||
throw new Error('root element not found')
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionOutcome } from '../engine/session'
|
||||
import { settleSession } from './settlement'
|
||||
import type { BuyIn } from './types'
|
||||
|
||||
const buyIn: BuyIn = { taskTypeId: 'pushups', chips: 100 }
|
||||
|
||||
describe('settleSession', () => {
|
||||
const cases: { name: string; outcome: SessionOutcome; owedUnits: number }[] = [
|
||||
{ name: 'doubled up', outcome: { reason: 'cashOut', finalChips: 200 }, owedUnits: 0 },
|
||||
{ name: 'partial win', outcome: { reason: 'cashOut', finalChips: 150 }, owedUnits: 50 },
|
||||
{ name: 'break-even', outcome: { reason: 'cashOut', finalChips: 100 }, owedUnits: 100 },
|
||||
{ name: 'partial loss', outcome: { reason: 'cashOut', finalChips: 50 }, owedUnits: 150 },
|
||||
{ name: 'bust to zero', outcome: { reason: 'bust', finalChips: 0 }, owedUnits: 200 },
|
||||
]
|
||||
|
||||
it.each(cases)('$name: owes $owedUnits units', ({ outcome, owedUnits }) => {
|
||||
const debt = settleSession(buyIn, outcome, 'debt-1', 12345)
|
||||
expect(debt.owedUnits).toBe(owedUnits)
|
||||
})
|
||||
|
||||
it('carries the task type, reason, id, and settledAt through unchanged', () => {
|
||||
const outcome: SessionOutcome = { reason: 'bust', finalChips: 0 }
|
||||
const debt = settleSession(buyIn, outcome, 'debt-42', 999)
|
||||
expect(debt).toEqual({
|
||||
id: 'debt-42',
|
||||
taskTypeId: 'pushups',
|
||||
owedUnits: 200,
|
||||
reason: 'bust',
|
||||
settledAt: 999,
|
||||
paidUnits: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,16 +0,0 @@
|
||||
import type { SessionOutcome } from '../engine/session'
|
||||
import type { BuyIn, SettledDebt } from './types'
|
||||
|
||||
/**
|
||||
* Converts a finished session's outcome into a debt record. `owedUnits` scales inversely with
|
||||
* how well the player did: `2 × buyIn − finalChips`, floored at 0. Bust (finalChips === 0) needs
|
||||
* no special case — it's just this formula's natural output (2 × buyIn, the cap) for that input.
|
||||
*/
|
||||
export const settleSession = (buyIn: BuyIn, outcome: SessionOutcome, id: string, settledAt: number): SettledDebt => ({
|
||||
id,
|
||||
taskTypeId: buyIn.taskTypeId,
|
||||
owedUnits: Math.max(0, 2 * buyIn.chips - outcome.finalChips),
|
||||
reason: outcome.reason,
|
||||
settledAt,
|
||||
paidUnits: 0,
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user