← Memory Match history

test(examples): add failing behavioral tests before app implementation

Actual project-only Git commit. Email addresses are redacted; source bytes and recorded timestamps are preserved. This is a code record, not human acceptance.

commit 7748a960d0fa3b24afe609eb591f9c2eda29320e
Author:     0xLeif <redacted@invalid>
AuthorDate: Sun Nov 8 19:26:05 2026 -0700
Commit:     0xLeif <redacted@invalid>
CommitDate: Sun Nov 8 19:26:05 2026 -0700

    test(examples): add failing behavioral tests before app implementation

diff --git a/apps/memory-match/LICENSE b/apps/memory-match/LICENSE
new file mode 100644
index 0000000..812de8f
--- /dev/null
+++ b/apps/memory-match/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 CorvidLabs
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/apps/memory-match/package.json b/apps/memory-match/package.json
new file mode 100644
index 0000000..4d6b732
--- /dev/null
+++ b/apps/memory-match/package.json
@@ -0,0 +1,16 @@
+{
+  "name": "memory-match",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "license": "MIT",
+  "scripts": {
+    "test": "bun test",
+    "typecheck": "tsc --noEmit -p tsconfig.json",
+    "build": "bun build.ts",
+    "start": "bun serve.ts"
+  },
+  "devDependencies": {
+    "typescript": "5.9.3"
+  }
+}
diff --git a/apps/memory-match/src/model.ts b/apps/memory-match/src/model.ts
new file mode 100644
index 0000000..c2f07b7
--- /dev/null
+++ b/apps/memory-match/src/model.ts
@@ -0,0 +1,29 @@
+// Red-phase API stubs. Root commits these failure inputs before implementation.
+export const PAIR_COUNT = 6;
+export const MISMATCH_DELAY_MS = 1_000;
+export type RandomSource = () => number;
+export type CardFace = "down" | "up" | "matched";
+export type MemoryPhase = "playing" | "complete";
+export interface MemoryCard {
+    readonly id: number;
+    readonly pairId: number;
+    readonly face: CardFace;
+}
+export interface MemoryState {
+    readonly phase: MemoryPhase;
+    readonly cards: readonly MemoryCard[];
+    readonly firstCardId: number | null;
+    readonly mismatchUntilMs: number | null;
+}
+export function createGame(random: RandomSource): MemoryState {
+    throw new Error("Not implemented: createGame");
+}
+export function revealCard(state: MemoryState, cardId: number, nowMs: number): MemoryState {
+    throw new Error("Not implemented: revealCard");
+}
+export function advanceGame(state: MemoryState, nowMs: number): MemoryState {
+    throw new Error("Not implemented: advanceGame");
+}
+export function resetGame(random: RandomSource): MemoryState {
+    throw new Error("Not implemented: resetGame");
+}
diff --git a/apps/memory-match/tests/model.test.ts b/apps/memory-match/tests/model.test.ts
new file mode 100644
index 0000000..ba7fdd7
--- /dev/null
+++ b/apps/memory-match/tests/model.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, test } from "bun:test";
+import { PAIR_COUNT, MISMATCH_DELAY_MS, createGame, revealCard, advanceGame, resetGame, type MemoryState, type MemoryCard } from "../src/model";
+const random = (): number => 0.25;
+function card(state: MemoryState, id: number): MemoryCard {
+    const found = state.cards.find((item) => item.id === id);
+    if (!found) throw new Error(`Missing card ${id}`);
+    return found;
+}
+function frozen(state: MemoryState): MemoryState {
+    state.cards.forEach(Object.freeze);
+    Object.freeze(state.cards);
+    return Object.freeze(state);
+}
+function complete(): MemoryState {
+    let state = createGame(random);
+    for (let pair = 0; pair < 6; pair += 1) {
+        state = revealCard(state, pair * 2, pair * 10);
+        state = revealCard(state, pair * 2 + 1, pair * 10 + 1);
+    }
+    return state;
+}
+
+describe("REQ-memory-match-001", () => {
+    test("shuffle preserves exactly six pairs, unique identities and down faces", () => {
+        const state = createGame(random);
+        expect(PAIR_COUNT).toBe(6);
+        expect(MISMATCH_DELAY_MS).toBe(1_000);
+        expect(state.cards).toHaveLength(12);
+        expect(new Set(state.cards.map((item) => item.id)).size).toBe(12);
+        for (let pair = 0; pair < 6; pair += 1) expect(state.cards.filter((item) => item.pairId === pair)).toHaveLength(2);
+        expect(state.cards.every((item) => item.face === "down")).toBe(true);
+        expect(state.phase).toBe("playing");
+        expect(state.firstCardId).toBeNull();
+        expect(state.mismatchUntilMs).toBeNull();
+        expect(createGame(random)).toEqual(state);
+        const low = createGame(() => 0).cards.map((item) => item.id);
+        const high = createGame(() => 0.999999).cards.map((item) => item.id);
+        expect(low).not.toEqual(high);
+        expect([...low].sort((a, b) => a - b)).toEqual([...high].sort((a, b) => a - b));
+    });
+    test("same-card repeat and unknown IDs cannot create a match", () => {
+        const initial = frozen(createGame(random));
+        const first = frozen(revealCard(initial, 0, 0));
+        expect(first.firstCardId).toBe(0);
+        expect(first.cards.filter((item) => item.face === "up")).toHaveLength(1);
+        expect(revealCard(first, 0, 1)).toEqual(first);
+        expect(revealCard(first, 99, 1)).toEqual(first);
+        expect(first.phase).toBe("playing");
+        expect(initial.cards.every((item) => item.face === "down")).toBe(true);
+    });
+    test("distinct partners match and matched cards remain protected", () => {
+        const first = frozen(revealCard(createGame(random), 0, 0));
+        const matched = frozen(revealCard(first, 1, 1));
+        expect(card(matched, 0).face).toBe("matched");
+        expect(card(matched, 1).face).toBe("matched");
+        expect(matched.firstCardId).toBeNull();
+        expect(matched.mismatchUntilMs).toBeNull();
+        expect(revealCard(matched, 0, 2)).toEqual(matched);
+        expect(matched.cards.filter((item) => item.face === "down")).toHaveLength(10);
+        expect(card(first, 0).face).toBe("up");
+    });
+    test("mismatch locks third input until exact deadline without extending it", () => {
+        const first = revealCard(createGame(random), 0, 100);
+        const mismatch = frozen(revealCard(first, 2, 200));
+        expect(mismatch.mismatchUntilMs).toBe(1_200);
+        expect(mismatch.firstCardId).toBeNull();
+        expect(mismatch.cards.filter((item) => item.face === "up")).toHaveLength(2);
+        expect(revealCard(mismatch, 4, 1_199)).toEqual(mismatch);
+        expect(advanceGame(mismatch, 1_199)).toEqual(mismatch);
+        const settled = advanceGame(mismatch, 1_200);
+        expect(settled.cards.every((item) => item.face === "down")).toBe(true);
+        expect(settled.mismatchUntilMs).toBeNull();
+        const next = revealCard(mismatch, 4, 1_200);
+        expect(card(next, 4).face).toBe("up");
+        expect(card(next, 0).face).toBe("down");
+        expect(card(next, 2).face).toBe("down");
+        expect(next.firstCardId).toBe(4);
+        expect(mismatch.mismatchUntilMs).toBe(1_200);
+    });
+    test("settling a mismatch preserves earlier matched pairs", () => {
+        let state = revealCard(revealCard(createGame(random), 0, 0), 1, 1);
+        state = revealCard(revealCard(state, 2, 100), 4, 200);
+        const settled = advanceGame(frozen(state), 1_200);
+        expect(card(settled, 0).face).toBe("matched");
+        expect(card(settled, 1).face).toBe("matched");
+        expect(card(settled, 2).face).toBe("down");
+        expect(card(settled, 4).face).toBe("down");
+    });
+    test("only the sixth pair completes the game; completion is stable", () => {
+        let state = createGame(random);
+        for (let pair = 0; pair < 5; pair += 1) {
+            state = revealCard(revealCard(state, pair * 2, pair * 10), pair * 2 + 1, pair * 10 + 1);
+            expect(state.phase).toBe("playing");
+        }
+        expect(state.cards.filter((item) => item.face === "matched")).toHaveLength(10);
+        const done = frozen(revealCard(revealCard(state, 10, 100), 11, 101));
+        expect(done.phase).toBe("complete");
+        expect(done.cards.every((item) => item.face === "matched")).toBe(true);
+        expect(done.firstCardId).toBeNull();
+        expect(done.mismatchUntilMs).toBeNull();
+        expect(revealCard(done, 0, 200)).toEqual(done);
+        expect(advanceGame(done, 10_000)).toEqual(done);
+    });
+    test("reset clears pending and completed games; old expiry cannot affect fresh state", () => {
+        const pending = frozen(revealCard(revealCard(createGame(random), 0, 100), 2, 200));
+        const done = frozen(complete());
+        for (const previous of [pending, done]) {
+            const snapshot = structuredClone(previous);
+            const reset = frozen(resetGame(random));
+            expect(reset.phase).toBe("playing");
+            expect(reset.cards.every((item) => item.face === "down")).toBe(true);
+            expect(reset.firstCardId).toBeNull();
+            expect(reset.mismatchUntilMs).toBeNull();
+            expect(advanceGame(reset, 1_200)).toEqual(reset);
+            expect(previous).toEqual(snapshot);
+        }
+    });
+    test("invalid random values require RangeError, not any stub error", () => {
+        for (const value of [-1, 1, NaN, Infinity]) {
+            expect(() => createGame(() => value)).toThrow(RangeError);
+            expect(() => resetGame(() => value)).toThrow(RangeError);
+        }
+    });
+    test("invalid clock values require RangeError", () => {
+        const state: MemoryState = Object.freeze({ phase: "playing", cards: Object.freeze([]), firstCardId: null, mismatchUntilMs: null });
+        for (const value of [-1, NaN, Infinity]) {
+            expect(() => revealCard(state, 0, value)).toThrow(RangeError);
+            expect(() => advanceGame(state, value)).toThrow(RangeError);
+        }
+    });
+});
diff --git a/apps/memory-match/tsconfig.json b/apps/memory-match/tsconfig.json
new file mode 100644
index 0000000..3a66cf3
--- /dev/null
+++ b/apps/memory-match/tsconfig.json
@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "lib": [
+      "ES2022",
+      "DOM"
+    ],
+    "strict": true,
+    "noEmit": true,
+    "types": [],
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true
+  },
+  "include": [
+    "src/**/*.ts"
+  ]
+}