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:
Krzysztof Rudnicki 2026-08-23 17:05:41 +02:00
parent 3cd42a4451
commit ea281134b6
134 changed files with 93 additions and 21888 deletions

View File

@ -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

View File

@ -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

View File

@ -1,6 +0,0 @@
node_modules/
packages/*/node_modules/
packages/frontend/dist/
packages/backend/dist/
packages/backend/uploads/*
!packages/backend/uploads/.gitkeep

View File

@ -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"
]
}
}

View File

@ -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,
},
);

View File

@ -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"
}
}

View File

@ -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 {}

View File

@ -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);
}
}

View 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 {}

View File

@ -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(),
};
}
}

View File

@ -1,9 +0,0 @@
import { Controller, Get } from "@nestjs/common";
@Controller("health")
export class HealthController {
@Get()
check(): { status: string } {
return { status: "ok" };
}
}

View File

@ -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();

View File

@ -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"]
}

View File

@ -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?

View File

@ -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,
},
);

View File

@ -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>

View File

@ -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"
}
}

View File

@ -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();
});
});

View File

@ -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}
/>
);
}

View File

@ -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);
}

View File

@ -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);
});
});

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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();
});
});

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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);
});
});

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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();
});
});

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -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();
});
});

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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();
});
});

View File

@ -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>
);
}

View File

@ -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
);
}

View File

@ -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);
}

View File

@ -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));
});
});

View File

@ -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;
}

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -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(); });
});
});

View File

@ -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);
}

View File

@ -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");
});
});

View File

@ -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`;
}

View File

@ -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");
});
});

View File

@ -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;
});
}

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -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);
});
});

View File

@ -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);
}

View File

@ -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>,
);

View File

@ -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,
};

View File

@ -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;
};

View File

@ -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(); });
};

View File

@ -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",
);

View File

@ -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 };

View File

@ -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"]
}

View File

@ -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

View File

@ -1,2 +0,0 @@
packages:
- "packages/*"

View File

@ -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:

View File

@ -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."
}

View File

@ -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."
]
}

View File

@ -1,3 +0,0 @@
node_modules
dist
coverage

View File

@ -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],
},
)

View File

@ -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>

File diff suppressed because it is too large Load Diff

View File

@ -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"
}

View File

@ -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)
})
})

View File

@ -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>
)
}

View File

@ -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)
})
})

View File

@ -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,
}
}

View File

@ -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')
})
})

View File

@ -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 } }
}

View File

@ -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')
})
})

View File

@ -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)
})
})

View File

@ -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)
}

View File

@ -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))
})
})

View File

@ -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,
}
}

View File

@ -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,
})

View File

@ -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))
})
})

View File

@ -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))
},
}
}

View File

@ -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([])
})
})

View File

@ -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)
})
})

View File

@ -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)
}

View File

@ -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),
)
})
})

View File

@ -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
}

View File

@ -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')
})
})

View File

@ -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 } }
}

View File

@ -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'

View File

@ -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')
})
})

View File

@ -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>,
)

View File

@ -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,
})
})
})

View File

@ -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