@trolleroof/tui 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +2 -2
  3. package/README.md +29 -7
  4. package/dist/index.js +284 -18
  5. package/overlay/dist/app.js +74 -0
  6. package/overlay/dist/index.html +1589 -0
  7. package/overlay-release.json +2 -2
  8. package/package.json +27 -4
  9. package/scripts/install-adapters.sh +6 -6
  10. package/scripts/install-adapters.test.sh +2 -2
  11. package/scripts/materialize-agents-plugin.sh +3 -3
  12. package/scripts/npm-pack.test.ts +51 -0
  13. package/scripts/overlay.ts +1 -1
  14. package/src/analytics/events.test.ts +77 -0
  15. package/src/analytics/events.ts +85 -0
  16. package/src/analytics/posthog.test.ts +85 -0
  17. package/src/analytics/posthog.ts +88 -0
  18. package/src/auth-pages.ts +108 -0
  19. package/src/auth.test.ts +255 -0
  20. package/src/auth.ts +511 -0
  21. package/src/core/game.ts +35 -0
  22. package/src/core/key.ts +12 -0
  23. package/src/core/rng.ts +41 -0
  24. package/src/data/bc-export.test.ts +153 -0
  25. package/src/data/bc-export.ts +125 -0
  26. package/src/data/trajectory.ts +103 -0
  27. package/src/games/connect4/connect4.ts +81 -0
  28. package/src/games/connect4/engine.test.ts +77 -0
  29. package/src/games/connect4/engine.ts +89 -0
  30. package/src/games/g2048/engine.test.ts +138 -0
  31. package/src/games/g2048/engine.ts +109 -0
  32. package/src/games/g2048/game2048.ts +104 -0
  33. package/src/games/minesweeper/engine.test.ts +83 -0
  34. package/src/games/minesweeper/engine.ts +112 -0
  35. package/src/games/minesweeper/minesweeper.ts +107 -0
  36. package/src/games/registry.test.ts +16 -0
  37. package/src/games/registry.ts +130 -0
  38. package/src/games/snake/engine.test.ts +89 -0
  39. package/src/games/snake/engine.ts +95 -0
  40. package/src/games/snake/snake.ts +77 -0
  41. package/src/games/sokoban/engine.test.ts +49 -0
  42. package/src/games/sokoban/engine.ts +152 -0
  43. package/src/games/sokoban/sokoban.ts +103 -0
  44. package/src/games/sudoku/engine.test.ts +88 -0
  45. package/src/games/sudoku/engine.ts +99 -0
  46. package/src/games/sudoku/sudoku.ts +100 -0
  47. package/src/games/tetris/engine.test.ts +378 -0
  48. package/src/games/tetris/engine.ts +236 -0
  49. package/src/games/tetris/tetris.ts +220 -0
  50. package/src/index.ts +82 -0
  51. package/src/recording/chunks.ts +93 -0
  52. package/src/recording/config.test.ts +31 -0
  53. package/src/recording/config.ts +37 -0
  54. package/src/recording/contracts.test.ts +71 -0
  55. package/src/recording/gameplay.test.ts +70 -0
  56. package/src/recording/gameplay.ts +18 -0
  57. package/src/recording/http-transport.test.ts +197 -0
  58. package/src/recording/identity.test.ts +26 -0
  59. package/src/recording/identity.ts +38 -0
  60. package/src/recording/ids.ts +27 -0
  61. package/src/recording/permissions.ts +10 -0
  62. package/src/recording/recorder.test.ts +388 -0
  63. package/src/recording/recorder.ts +464 -0
  64. package/src/recording/store.test.ts +231 -0
  65. package/src/recording/store.ts +305 -0
  66. package/src/recording/terminal-grid.test.ts +88 -0
  67. package/src/recording/terminal-grid.ts +248 -0
  68. package/src/recording/trace-recorder.test.ts +287 -0
  69. package/src/recording/trace-recorder.ts +560 -0
  70. package/src/recording/transport.ts +170 -0
  71. package/src/recording/types.test.ts +18 -0
  72. package/src/recording/types.ts +326 -0
  73. package/src/recording/uploader.test.ts +193 -0
  74. package/src/recording/uploader.ts +103 -0
  75. package/src/results/completion.ts +100 -0
  76. package/src/results/http-result-transport.test.ts +75 -0
  77. package/src/results/local-score.test.ts +32 -0
  78. package/src/results/local-score.ts +39 -0
  79. package/src/results/results.test.ts +383 -0
  80. package/src/results/store.ts +149 -0
  81. package/src/results/transport.ts +79 -0
  82. package/src/results/types.ts +20 -0
  83. package/src/results/uploader.ts +84 -0
  84. package/src/sync/config.test.ts +108 -0
  85. package/src/sync/config.ts +115 -0
  86. package/src/sync/coordinator.test.ts +423 -0
  87. package/src/sync/coordinator.ts +277 -0
  88. package/src/sync/game-start-queue.test.ts +93 -0
  89. package/src/sync/game-start-queue.ts +45 -0
  90. package/src/sync/identity-client.test.ts +34 -0
  91. package/src/sync/identity-client.ts +43 -0
@@ -0,0 +1,153 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { TraceEvent, TraceEventChunk } from "../recording/types.js";
3
+ import { extractBehaviorCloningSamples } from "./bc-export.js";
4
+
5
+ function event(
6
+ sequence: number,
7
+ eventType: TraceEvent["event_type"],
8
+ payload: Record<string, unknown>,
9
+ ): TraceEvent {
10
+ return {
11
+ schema_version: 2,
12
+ user_id: "usr_test",
13
+ game_id: "game_test",
14
+ chunk_id: sequence <= 4 ? "chk_1" : "chk_2",
15
+ event_id: `evt_${sequence}`,
16
+ event_sequence: sequence,
17
+ event_version: 1,
18
+ event_type: eventType,
19
+ visibility: eventType === "engine_checkpoint" ? "privileged" : "policy",
20
+ client_time_ms: sequence,
21
+ monotonic_time_us: sequence * 1_000,
22
+ payload,
23
+ } as TraceEvent;
24
+ }
25
+
26
+ function chunk(sequence: number, events: TraceEvent[]): TraceEventChunk {
27
+ const chunkId = `chk_${sequence}`;
28
+ return {
29
+ schema_version: 2,
30
+ user_id: "usr_test",
31
+ game_id: "game_test",
32
+ game_type: "2048",
33
+ game_version: "1",
34
+ chunk_id: chunkId,
35
+ chunk_sequence: sequence,
36
+ first_event_sequence: events[0]!.event_sequence,
37
+ last_event_sequence: events.at(-1)!.event_sequence,
38
+ event_count: events.length,
39
+ created_at_ms: 10,
40
+ client: {
41
+ platform: "tui",
42
+ application_version: "test",
43
+ recording_sdk_version: "2",
44
+ },
45
+ events: events.map((item) => ({ ...item, chunk_id: chunkId })),
46
+ };
47
+ }
48
+
49
+ describe("behavior cloning export", () => {
50
+ test("joins observations, human controls, and outcomes across chunks", () => {
51
+ const chunks = [
52
+ chunk(2, [
53
+ event(5, "outcome_changed", {
54
+ score_before: 0,
55
+ score_after: 4,
56
+ score_delta: 4,
57
+ engine_reward: 4,
58
+ reward_spec_id: "engine_v1",
59
+ terminal: false,
60
+ caused_by_event_id: "evt_4",
61
+ progress: { moved: true, success: false },
62
+ }),
63
+ event(6, "control_changed", {
64
+ based_on_observation_id: "obs_2",
65
+ control: "left",
66
+ action: "left",
67
+ kind: "impulse",
68
+ actor: "environment",
69
+ }),
70
+ ]),
71
+ chunk(1, [
72
+ event(1, "episode_started", {
73
+ game_type: "2048",
74
+ game_version: "1",
75
+ ruleset_id: "classic",
76
+ telemetry_version: 2,
77
+ platform: "overlay",
78
+ viewport: { width: 520, height: 740 },
79
+ control_schema: ["left", "right"],
80
+ input_semantics: "engine_impulse",
81
+ }),
82
+ event(2, "observation", {
83
+ observation_id: "obs_1",
84
+ representation: "engine_state_v1",
85
+ state: { board: [0, 2] },
86
+ }),
87
+ event(3, "observation", {
88
+ observation_id: "obs_2",
89
+ representation: "engine_state_v1",
90
+ state: { board: [2, 0] },
91
+ }),
92
+ event(4, "control_changed", {
93
+ based_on_observation_id: "obs_2",
94
+ control: "right",
95
+ action: "right",
96
+ kind: "impulse",
97
+ actor: "human",
98
+ }),
99
+ ]),
100
+ ];
101
+
102
+ expect(extractBehaviorCloningSamples(chunks)).toEqual([
103
+ expect.objectContaining({
104
+ schema_version: 1,
105
+ game_id: "game_test",
106
+ game_type: "2048",
107
+ ruleset_id: "classic",
108
+ observation_id: "obs_2",
109
+ observation: {
110
+ representation: "engine_state_v1",
111
+ state: { board: [2, 0] },
112
+ },
113
+ action: "right",
114
+ reward: 4,
115
+ score_delta: 4,
116
+ terminal: false,
117
+ moved: true,
118
+ }),
119
+ ]);
120
+ });
121
+
122
+ test("drops no-op human controls by default and can retain them explicitly", () => {
123
+ const chunks = [
124
+ chunk(1, [
125
+ event(1, "observation", {
126
+ observation_id: "obs_1",
127
+ representation: "engine_state_v1",
128
+ state: { board: [2, 0] },
129
+ }),
130
+ event(2, "control_changed", {
131
+ based_on_observation_id: "obs_1",
132
+ control: "left",
133
+ action: "left",
134
+ kind: "impulse",
135
+ actor: "human",
136
+ }),
137
+ event(3, "outcome_changed", {
138
+ score_before: 0,
139
+ score_after: 0,
140
+ score_delta: 0,
141
+ engine_reward: 0,
142
+ reward_spec_id: "engine_v1",
143
+ terminal: false,
144
+ caused_by_event_id: "evt_2",
145
+ progress: { moved: false, success: false },
146
+ }),
147
+ ]),
148
+ ];
149
+
150
+ expect(extractBehaviorCloningSamples(chunks)).toEqual([]);
151
+ expect(extractBehaviorCloningSamples(chunks, { includeNoops: true })).toHaveLength(1);
152
+ });
153
+ });
@@ -0,0 +1,125 @@
1
+ import type {
2
+ ControlChangedTraceEvent,
3
+ EpisodeStartedTraceEvent,
4
+ ObservationTraceEvent,
5
+ OutcomeChangedTraceEvent,
6
+ PolicyObservation,
7
+ TraceActor,
8
+ TraceEvent,
9
+ TraceEventChunk,
10
+ } from "../recording/types.js";
11
+
12
+ export interface BehaviorCloningSample {
13
+ schema_version: 1;
14
+ user_id: string;
15
+ game_id: string;
16
+ game_type: string;
17
+ game_version: string;
18
+ ruleset_id: string | null;
19
+ event_sequence: number;
20
+ control_event_id: string;
21
+ observation_id: string;
22
+ observation: Omit<PolicyObservation, "observation_id" | "caused_by_event_id">;
23
+ action: string;
24
+ actor: TraceActor;
25
+ reward: number | null;
26
+ score_delta: number | null;
27
+ terminal: boolean | null;
28
+ moved: boolean | null;
29
+ }
30
+
31
+ export interface BehaviorCloningExportOptions {
32
+ actors?: readonly TraceActor[];
33
+ includeNoops?: boolean;
34
+ }
35
+
36
+ function policyObservation(
37
+ payload: ObservationTraceEvent["payload"],
38
+ ): BehaviorCloningSample["observation"] {
39
+ const { observation_id: _observationId, caused_by_event_id: _cause, ...observation } = payload;
40
+ return observation;
41
+ }
42
+
43
+ function orderedUniqueEvents(chunks: readonly TraceEventChunk[]): TraceEvent[] {
44
+ const events = new Map<string, TraceEvent>();
45
+ for (const chunk of chunks) {
46
+ if (chunk.schema_version !== 2) continue;
47
+ for (const event of chunk.events) events.set(event.event_id, event);
48
+ }
49
+ return [...events.values()].sort((left, right) =>
50
+ left.game_id.localeCompare(right.game_id) ||
51
+ left.event_sequence - right.event_sequence
52
+ );
53
+ }
54
+
55
+ export function extractBehaviorCloningSamples(
56
+ chunks: readonly TraceEventChunk[],
57
+ options: BehaviorCloningExportOptions = {},
58
+ ): BehaviorCloningSample[] {
59
+ const actors = new Set(options.actors ?? ["human"]);
60
+ const events = orderedUniqueEvents(chunks);
61
+ const observations = new Map<string, ObservationTraceEvent>();
62
+ const outcomes = new Map<string, OutcomeChangedTraceEvent>();
63
+ const rulesets = new Map<string, string>();
64
+ const chunksByGame = new Map(chunks.map((chunk) => [chunk.game_id, chunk]));
65
+
66
+ for (const event of events) {
67
+ if (event.event_type === "observation") {
68
+ observations.set(event.payload.observation_id, event);
69
+ } else if (event.event_type === "outcome_changed") {
70
+ outcomes.set(event.payload.caused_by_event_id, event);
71
+ } else if (event.event_type === "episode_started") {
72
+ rulesets.set(event.game_id, (event as EpisodeStartedTraceEvent).payload.ruleset_id);
73
+ }
74
+ }
75
+
76
+ const samples: BehaviorCloningSample[] = [];
77
+ for (const event of events) {
78
+ if (event.event_type !== "control_changed") continue;
79
+ const control = event as ControlChangedTraceEvent;
80
+ if (!actors.has(control.payload.actor) || !control.payload.based_on_observation_id) continue;
81
+ const observation = observations.get(control.payload.based_on_observation_id);
82
+ if (!observation || observation.game_id !== control.game_id) continue;
83
+ const outcome = outcomes.get(control.event_id);
84
+ const moved = typeof outcome?.payload.progress?.moved === "boolean"
85
+ ? outcome.payload.progress.moved
86
+ : null;
87
+ if (!options.includeNoops && moved === false) continue;
88
+ const chunk = chunksByGame.get(control.game_id);
89
+ if (!chunk) continue;
90
+
91
+ samples.push({
92
+ schema_version: 1,
93
+ user_id: control.user_id,
94
+ game_id: control.game_id,
95
+ game_type: chunk.game_type,
96
+ game_version: chunk.game_version,
97
+ ruleset_id: rulesets.get(control.game_id) ?? null,
98
+ event_sequence: control.event_sequence,
99
+ control_event_id: control.event_id,
100
+ observation_id: observation.payload.observation_id,
101
+ observation: policyObservation(observation.payload),
102
+ action: control.payload.action,
103
+ actor: control.payload.actor,
104
+ reward: outcome?.payload.engine_reward ?? null,
105
+ score_delta: outcome?.payload.score_delta ?? null,
106
+ terminal: outcome?.payload.terminal ?? null,
107
+ moved,
108
+ });
109
+ }
110
+ return samples;
111
+ }
112
+
113
+ export type DatasetSplit = "train" | "validation" | "test";
114
+
115
+ export function splitForGame(gameId: string): DatasetSplit {
116
+ let hash = 2_166_136_261;
117
+ for (const byte of new TextEncoder().encode(gameId)) {
118
+ hash ^= byte;
119
+ hash = Math.imul(hash, 16_777_619) >>> 0;
120
+ }
121
+ const bucket = hash % 100;
122
+ if (bucket < 90) return "train";
123
+ if (bucket < 95) return "validation";
124
+ return "test";
125
+ }
@@ -0,0 +1,103 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ /**
7
+ * JSONL trajectory logger — one file per episode, one JSON object per line.
8
+ * Line 1 is an episode header; each subsequent line is a (state, action,
9
+ * reward) transition; the final line summarizes the episode. This is the
10
+ * schema the S3 uploader and BC training pipeline will consume.
11
+ */
12
+
13
+ export interface EpisodeHeader {
14
+ kind: "header";
15
+ episodeId: string;
16
+ game: string;
17
+ seed: number;
18
+ variation: string;
19
+ goal: string;
20
+ startedAt: string;
21
+ schemaVersion: 2;
22
+ }
23
+
24
+ export interface TransitionLine {
25
+ kind: "step";
26
+ t: number;
27
+ /** Observable state BEFORE the action was taken. */
28
+ state: unknown;
29
+ action: string;
30
+ reward: number;
31
+ scoreAfter: number;
32
+ done: boolean;
33
+ }
34
+
35
+ export interface EpisodeSummary {
36
+ kind: "summary";
37
+ steps: number;
38
+ finalScore: number;
39
+ success: boolean;
40
+ endedAt: string;
41
+ /** "completed" = played to done, "abandoned" = user quit mid-episode. */
42
+ outcome: "completed" | "abandoned";
43
+ }
44
+
45
+ export function trajectoryDir(): string {
46
+ return join(homedir(), ".tui-gamepigeon", "trajectories");
47
+ }
48
+
49
+ export class TrajectoryLogger {
50
+ private file: string;
51
+ private t = 0;
52
+ private closed = false;
53
+
54
+ constructor(game: string, seed: number, variation: string, goal: string) {
55
+ const dir = trajectoryDir();
56
+ mkdirSync(dir, { recursive: true });
57
+ const episodeId = randomUUID();
58
+ this.file = join(dir, `${game}-${Date.now()}-${episodeId.slice(0, 8)}.jsonl`);
59
+ const header: EpisodeHeader = {
60
+ kind: "header",
61
+ episodeId,
62
+ game,
63
+ seed,
64
+ variation,
65
+ goal,
66
+ startedAt: new Date().toISOString(),
67
+ schemaVersion: 2,
68
+ };
69
+ this.write(header);
70
+ }
71
+
72
+ get path(): string {
73
+ return this.file;
74
+ }
75
+
76
+ logStep(state: unknown, action: string, reward: number, scoreAfter: number, done: boolean): void {
77
+ if (this.closed) return;
78
+ const line: TransitionLine = { kind: "step", t: this.t++, state, action, reward, scoreAfter, done };
79
+ this.write(line);
80
+ }
81
+
82
+ close(finalScore: number, success: boolean, outcome: EpisodeSummary["outcome"]): void {
83
+ if (this.closed) return;
84
+ this.closed = true;
85
+ const summary: EpisodeSummary = {
86
+ kind: "summary",
87
+ steps: this.t,
88
+ finalScore,
89
+ success,
90
+ endedAt: new Date().toISOString(),
91
+ outcome,
92
+ };
93
+ this.write(summary);
94
+ }
95
+
96
+ private write(obj: unknown): void {
97
+ try {
98
+ appendFileSync(this.file, JSON.stringify(obj) + "\n");
99
+ } catch {
100
+ // Logging must never crash gameplay; drop the line on disk errors.
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,81 @@
1
+ import type { Game, StepResult } from "../../core/game.js";
2
+ import { Rng } from "../../core/rng.js";
3
+ import { COLS, botMove, drop, emptyBoard, isFull, winner, type Board } from "./engine.js";
4
+
5
+ export type Connect4Action = "left" | "right" | "drop" | "bot_move";
6
+
7
+ export interface Connect4State {
8
+ board: Board;
9
+ cursor: number;
10
+ /** 0 in progress, 1 player won, 2 bot won, 3 draw. */
11
+ result: number;
12
+ }
13
+
14
+
15
+ export class Connect4Game implements Game<Connect4State, Connect4Action> {
16
+ readonly name = "connect4";
17
+ readonly actions = ["left", "right", "drop", "bot_move"] as const;
18
+ readonly keymap = { space: "drop", enter: "drop", down: "drop" } as const;
19
+
20
+ private rng = new Rng(0);
21
+ private board: Board = emptyBoard();
22
+ private cursor = 3;
23
+ private result = 0;
24
+
25
+ reset(seed: number): void {
26
+ this.rng = new Rng(seed);
27
+ this.board = emptyBoard();
28
+ this.cursor = 3;
29
+ this.result = 0;
30
+ }
31
+
32
+ step(action: Connect4Action): StepResult {
33
+ if (this.result !== 0) return { reward: 0, moved: false, done: true, success: this.result === 1 };
34
+ if (action === "left" || action === "right") {
35
+ const nc = this.cursor + (action === "left" ? -1 : 1);
36
+ if (nc < 0 || nc >= COLS) return { reward: 0, moved: false, done: false, success: false };
37
+ this.cursor = nc;
38
+ return { reward: 0, moved: true, done: false, success: false };
39
+ }
40
+ if (action === "bot_move") {
41
+ const botColumn = botMove(this.board, this.rng);
42
+ drop(this.board, botColumn, 2);
43
+ const randomEffects = { kind: "bot_move", column: botColumn };
44
+ if (winner(this.board) === 2) {
45
+ this.result = 2;
46
+ return { reward: 0, moved: true, done: true, success: false, randomEffects };
47
+ }
48
+ if (isFull(this.board)) {
49
+ this.result = 3;
50
+ return { reward: 50, moved: true, done: true, success: false, randomEffects };
51
+ }
52
+ return { reward: 0, moved: true, done: false, success: false, randomEffects };
53
+ }
54
+ if (drop(this.board, this.cursor, 1) < 0) return { reward: 0, moved: false, done: false, success: false };
55
+ if (winner(this.board) === 1) {
56
+ this.result = 1;
57
+ return { reward: 100, moved: true, done: true, success: true };
58
+ }
59
+ if (isFull(this.board)) {
60
+ this.result = 3;
61
+ return { reward: 50, moved: true, done: true, success: false };
62
+ }
63
+ return { reward: 1, moved: true, done: false, success: false };
64
+ }
65
+
66
+ state(): Connect4State {
67
+ return { board: this.board.map((r) => [...r]), cursor: this.cursor, result: this.result };
68
+ }
69
+
70
+ score(): number {
71
+ return this.result === 1 ? 100 : this.result === 3 ? 50 : 0;
72
+ }
73
+
74
+ isOver(): boolean {
75
+ return this.result !== 0;
76
+ }
77
+
78
+ hasWon(): boolean {
79
+ return this.result === 1;
80
+ }
81
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { Rng } from "../../core/rng.js";
3
+ import { COLS, ROWS, botMove, drop, emptyBoard, legalColumns, winner, type Board } from "./engine.js";
4
+ import { Connect4Game } from "./connect4.js";
5
+
6
+ describe("connect4 engine", () => {
7
+ it("pieces stack from the bottom", () => {
8
+ const b = emptyBoard();
9
+ expect(drop(b, 3, 1)).toBe(ROWS - 1);
10
+ expect(drop(b, 3, 2)).toBe(ROWS - 2);
11
+ });
12
+
13
+ it("full columns reject drops and leave legalColumns", () => {
14
+ const b = emptyBoard();
15
+ for (let i = 0; i < ROWS; i++) drop(b, 0, 1);
16
+ expect(drop(b, 0, 1)).toBe(-1);
17
+ expect(legalColumns(b)).not.toContain(0);
18
+ expect(legalColumns(b)).toHaveLength(COLS - 1);
19
+ });
20
+
21
+ it("detects horizontal, vertical, and diagonal wins", () => {
22
+ const h = emptyBoard();
23
+ for (let c = 0; c < 4; c++) drop(h, c, 1);
24
+ expect(winner(h)).toBe(1);
25
+
26
+ const v = emptyBoard();
27
+ for (let i = 0; i < 4; i++) drop(v, 2, 2);
28
+ expect(winner(v)).toBe(2);
29
+
30
+ const d = emptyBoard();
31
+ // Build a / diagonal for player 1.
32
+ const cols = [1, 2, 2, 3, 3, 3];
33
+ for (const c of cols) drop(d, c, 2);
34
+ d[ROWS - 1]![0] = 1;
35
+ d[ROWS - 2]![1] = 1;
36
+ d[ROWS - 3]![2] = 1;
37
+ d[ROWS - 4]![3] = 1;
38
+ expect(winner(d)).toBe(1);
39
+ });
40
+
41
+ it("bot takes an immediate win", () => {
42
+ const b = emptyBoard();
43
+ for (let i = 0; i < 3; i++) drop(b, 5, 2);
44
+ for (let i = 0; i < 2; i++) drop(b, 0, 1);
45
+ expect(botMove(b, new Rng(1))).toBe(5);
46
+ });
47
+
48
+ it("bot blocks the player's immediate win", () => {
49
+ const b = emptyBoard();
50
+ for (let i = 0; i < 3; i++) drop(b, 1, 1);
51
+ expect(botMove(b, new Rng(1))).toBe(1);
52
+ });
53
+ });
54
+
55
+ describe("Connect4Game", () => {
56
+ it("reports the bot column selected after a bot_move action", () => {
57
+ const g = new Connect4Game();
58
+ g.reset(7);
59
+ g.step("drop");
60
+ expect(g.step("bot_move").randomEffects).toEqual({
61
+ kind: "bot_move",
62
+ column: expect.any(Number),
63
+ });
64
+ });
65
+
66
+ it("plays a full deterministic game per seed", () => {
67
+ const g = new Connect4Game();
68
+ g.reset(1234);
69
+ let guard = 0;
70
+ while (!g.isOver() && guard++ < 100) {
71
+ const res = g.step("drop");
72
+ if (!res.moved) g.step("left"); // column filled up; aim elsewhere
73
+ }
74
+ expect(g.isOver()).toBe(true);
75
+ expect([0, 50, 100]).toContain(g.score());
76
+ });
77
+ });
@@ -0,0 +1,89 @@
1
+ import type { Rng } from "../../core/rng.js";
2
+
3
+ /**
4
+ * Pure Connect Four rules on the standard 7×6 board, plus a small
5
+ * deterministic bot: win if possible, block if necessary, otherwise prefer
6
+ * central columns with a seeded tie-break.
7
+ */
8
+
9
+ export const COLS = 7;
10
+ export const ROWS = 6;
11
+
12
+ /** 0 empty, 1 player, 2 bot. */
13
+ export type Cell = 0 | 1 | 2;
14
+ export type Board = Cell[][];
15
+
16
+ export function emptyBoard(): Board {
17
+ return Array.from({ length: ROWS }, () => Array<Cell>(COLS).fill(0));
18
+ }
19
+
20
+ export function legalColumns(board: Board): number[] {
21
+ const cols: number[] = [];
22
+ for (let c = 0; c < COLS; c++) if (board[0]![c] === 0) cols.push(c);
23
+ return cols;
24
+ }
25
+
26
+ /** Drop into `col`; returns the landing row or -1 if the column is full. */
27
+ export function drop(board: Board, col: number, piece: Cell): number {
28
+ for (let r = ROWS - 1; r >= 0; r--) {
29
+ if (board[r]![col] === 0) {
30
+ board[r]![col] = piece;
31
+ return r;
32
+ }
33
+ }
34
+ return -1;
35
+ }
36
+
37
+ const DIRS: Array<[number, number]> = [
38
+ [1, 0],
39
+ [0, 1],
40
+ [1, 1],
41
+ [1, -1],
42
+ ];
43
+
44
+ /** 0 if nobody has four in a row yet. */
45
+ export function winner(board: Board): Cell {
46
+ for (let r = 0; r < ROWS; r++) {
47
+ for (let c = 0; c < COLS; c++) {
48
+ const p = board[r]![c]!;
49
+ if (p === 0) continue;
50
+ for (const [dc, dr] of DIRS) {
51
+ let n = 1;
52
+ while (n < 4 && board[r + dr * n]?.[c + dc * n] === p) n++;
53
+ if (n === 4) return p;
54
+ }
55
+ }
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ export function isFull(board: Board): boolean {
61
+ return legalColumns(board).length === 0;
62
+ }
63
+
64
+ function winsAt(board: Board, col: number, piece: Cell): boolean {
65
+ const row = drop(board, col, piece);
66
+ if (row < 0) return false;
67
+ const won = winner(board) === piece;
68
+ board[row]![col] = 0;
69
+ return won;
70
+ }
71
+
72
+ /** Bot policy: take a win, block the player's win, else center-biased random. */
73
+ export function botMove(board: Board, rng: Rng): number {
74
+ const legal = legalColumns(board);
75
+ for (const c of legal) if (winsAt(board, c, 2)) return c;
76
+ for (const c of legal) if (winsAt(board, c, 1)) return c;
77
+ // Avoid handing the player an immediate win on top of our piece.
78
+ const safe = legal.filter((c) => {
79
+ const row = drop(board, c, 2);
80
+ const losing = legalColumns(board).some((pc) => winsAt(board, pc, 1));
81
+ board[row]![c] = 0;
82
+ return !losing;
83
+ });
84
+ const pool = safe.length > 0 ? safe : legal;
85
+ const weight = (c: number): number => 4 - Math.abs(3 - c);
86
+ const best = Math.max(...pool.map(weight));
87
+ const top = pool.filter((c) => weight(c) === best);
88
+ return top[rng.int(top.length)]!;
89
+ }