k-l-lambda Claude commited on
Commit
6a4b3c0
·
1 Parent(s): 9563838

Pre-build backend to skip esbuild step in Docker

Browse files

- Include pre-built backend/dist/backend/src/server.js
- Remove esbuild build step from Dockerfile
- Simplify to only npm install for runtime deps

Co-Authored-By: Claude <noreply@anthropic.com>

.dockerignore CHANGED
@@ -12,9 +12,10 @@ README.md
12
  node_modules
13
  **/node_modules
14
 
15
- # Build outputs (will be generated during build) - keep app/dist for pre-built frontend
16
  **/dist
17
  !trigo-web/app/dist
 
18
 
19
  # Test files
20
  tests
 
12
  node_modules
13
  **/node_modules
14
 
15
+ # Build outputs - keep pre-built dist folders
16
  **/dist
17
  !trigo-web/app/dist
18
+ !trigo-web/backend/dist
19
 
20
  # Test files
21
  tests
.gitignore CHANGED
@@ -1,4 +1,4 @@
1
  node_modules/
2
- # Don't ignore trigo-web/app/dist - we commit pre-built frontend
3
- dist/
4
- !trigo-web/app/dist/
 
1
  node_modules/
2
+ # Don't ignore pre-built outputs
3
+ # trigo-web/app/dist - pre-built frontend
4
+ # trigo-web/backend/dist - pre-built backend
Dockerfile CHANGED
@@ -3,7 +3,7 @@ FROM node:20-slim
3
  # Set noninteractive installation
4
  ENV DEBIAN_FRONTEND=noninteractive
5
 
6
- # Build timestamp to force cache invalidation: 2026-01-12T20:00
7
 
8
  # Install build dependencies
9
  RUN apt-get update && apt-get install -y \
@@ -14,27 +14,24 @@ RUN apt-get update && apt-get install -y \
14
  # Create app directory
15
  WORKDIR /app
16
 
17
- # Copy trigo-web project (includes pre-built app/dist with onnx files)
18
  COPY trigo-web/ ./
19
 
20
- # Install build tools globally
21
- RUN npm install -g tsx jison typescript esbuild
22
 
23
  # Install dependencies
24
  # Root: production only (skip onnxruntime-node which requires native compilation)
25
- # App & Backend: all deps needed for runtime
26
  RUN npm install --omit=dev && \
27
  cd app && npm install --omit=dev && \
28
- cd ../backend && npm install && \
29
  cd ..
30
 
31
- # Skip jison parser build - pre-built tgnParser.cjs is already in public/lib/
32
- # Skip vite build - pre-built dist is already included (with onnx files)
33
-
34
- # Build backend with esbuild (handles ESM imports without .js extensions)
35
- # Output to dist/backend/src/ to match backend/package.json main field
36
- RUN mkdir -p backend/dist/backend/src && \
37
- esbuild backend/src/server.ts --bundle --platform=node --target=node20 --format=esm --outfile=backend/dist/backend/src/server.js --external:express --external:socket.io --external:cors --external:dotenv --external:uuid
38
 
39
  # Set environment variables for Hugging Face Spaces
40
  ENV PORT=7860
 
3
  # Set noninteractive installation
4
  ENV DEBIAN_FRONTEND=noninteractive
5
 
6
+ # Build timestamp to force cache invalidation: 2026-01-12T20:10
7
 
8
  # Install build dependencies
9
  RUN apt-get update && apt-get install -y \
 
14
  # Create app directory
15
  WORKDIR /app
16
 
17
+ # Copy trigo-web project (includes pre-built app/dist and backend/dist)
18
  COPY trigo-web/ ./
19
 
20
+ # Install build tools globally (still needed for some scripts)
21
+ RUN npm install -g tsx jison typescript
22
 
23
  # Install dependencies
24
  # Root: production only (skip onnxruntime-node which requires native compilation)
25
+ # App & Backend: runtime deps only
26
  RUN npm install --omit=dev && \
27
  cd app && npm install --omit=dev && \
28
+ cd ../backend && npm install --omit=dev && \
29
  cd ..
30
 
31
+ # Skip all build steps - everything is pre-built:
32
+ # - Frontend: app/dist/ (includes onnx files)
33
+ # - Backend: backend/dist/backend/src/server.js
34
+ # - Parser: public/lib/tgnParser.cjs
 
 
 
35
 
36
  # Set environment variables for Hugging Face Spaces
37
  ENV PORT=7860
trigo-web/backend/dist/backend/src/server.js ADDED
@@ -0,0 +1,2104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // backend/src/server.ts
2
+ import express from "express";
3
+ import { createServer } from "http";
4
+ import { Server } from "socket.io";
5
+ import cors from "cors";
6
+ import dotenv from "dotenv";
7
+ import path from "path";
8
+ import fs from "fs";
9
+ import { fileURLToPath } from "url";
10
+
11
+ // backend/src/services/gameManager.ts
12
+ import { v4 as uuidv4 } from "uuid";
13
+
14
+ // inc/trigo/gameUtils.ts
15
+ var StoneType = {
16
+ EMPTY: 0,
17
+ BLACK: 1,
18
+ WHITE: 2
19
+ };
20
+ function getEnemyColor(color) {
21
+ if (color === StoneType.BLACK) return StoneType.WHITE;
22
+ if (color === StoneType.WHITE) return StoneType.BLACK;
23
+ return StoneType.EMPTY;
24
+ }
25
+ function isInBounds(pos, shape) {
26
+ return pos.x >= 0 && pos.x < shape.x && pos.y >= 0 && pos.y < shape.y && pos.z >= 0 && pos.z < shape.z;
27
+ }
28
+ function getNeighbors(pos, shape) {
29
+ const neighbors = [];
30
+ const directions = [
31
+ { x: 1, y: 0, z: 0 },
32
+ { x: -1, y: 0, z: 0 },
33
+ { x: 0, y: 1, z: 0 },
34
+ { x: 0, y: -1, z: 0 },
35
+ { x: 0, y: 0, z: 1 },
36
+ { x: 0, y: 0, z: -1 }
37
+ ];
38
+ for (const dir of directions) {
39
+ const neighbor = {
40
+ x: pos.x + dir.x,
41
+ y: pos.y + dir.y,
42
+ z: pos.z + dir.z
43
+ };
44
+ if (isInBounds(neighbor, shape)) {
45
+ neighbors.push(neighbor);
46
+ }
47
+ }
48
+ return neighbors;
49
+ }
50
+ function positionsEqual(p1, p2) {
51
+ return p1.x === p2.x && p1.y === p2.y && p1.z === p2.z;
52
+ }
53
+ var CoordSet = class {
54
+ constructor() {
55
+ this.positions = [];
56
+ }
57
+ has(pos) {
58
+ return this.positions.some((p) => positionsEqual(p, pos));
59
+ }
60
+ insert(pos) {
61
+ if (!this.has(pos)) {
62
+ this.positions.push(pos);
63
+ return true;
64
+ }
65
+ return false;
66
+ }
67
+ remove(pos) {
68
+ this.positions = this.positions.filter((p) => !positionsEqual(p, pos));
69
+ }
70
+ size() {
71
+ return this.positions.length;
72
+ }
73
+ empty() {
74
+ return this.positions.length === 0;
75
+ }
76
+ forEach(callback) {
77
+ this.positions.forEach(callback);
78
+ }
79
+ toArray() {
80
+ return [...this.positions];
81
+ }
82
+ clear() {
83
+ this.positions = [];
84
+ }
85
+ };
86
+ var Patch = class {
87
+ constructor(color = StoneType.EMPTY) {
88
+ this.positions = new CoordSet();
89
+ this.color = StoneType.EMPTY;
90
+ this.color = color;
91
+ }
92
+ addStone(pos) {
93
+ this.positions.insert(pos);
94
+ }
95
+ size() {
96
+ return this.positions.size();
97
+ }
98
+ /**
99
+ * Get all liberties (empty adjacent positions) for this group
100
+ *
101
+ * Equivalent to StoneArray.patchAir() in prototype
102
+ * Returns a CoordSet of empty positions adjacent to this patch
103
+ */
104
+ getLiberties(board, shape) {
105
+ const liberties = new CoordSet();
106
+ this.positions.forEach((stonePos) => {
107
+ const neighbors = getNeighbors(stonePos, shape);
108
+ for (const neighbor of neighbors) {
109
+ if (board[neighbor.x][neighbor.y][neighbor.z] === StoneType.EMPTY) {
110
+ liberties.insert(neighbor);
111
+ }
112
+ }
113
+ });
114
+ return liberties;
115
+ }
116
+ };
117
+ function findGroup(pos, board, shape) {
118
+ const color = board[pos.x][pos.y][pos.z];
119
+ const group = new Patch(color);
120
+ if (color === StoneType.EMPTY) {
121
+ return group;
122
+ }
123
+ const visited = new CoordSet();
124
+ const stack = [pos];
125
+ while (stack.length > 0) {
126
+ const current = stack.pop();
127
+ if (visited.has(current)) {
128
+ continue;
129
+ }
130
+ visited.insert(current);
131
+ if (board[current.x][current.y][current.z] === color) {
132
+ group.addStone(current);
133
+ const neighbors = getNeighbors(current, shape);
134
+ for (const neighbor of neighbors) {
135
+ if (!visited.has(neighbor)) {
136
+ stack.push(neighbor);
137
+ }
138
+ }
139
+ }
140
+ }
141
+ return group;
142
+ }
143
+ function getNeighborGroups(pos, board, shape, excludeEmpty = false) {
144
+ const neighbors = getNeighbors(pos, shape);
145
+ const groups = [];
146
+ const processedPositions = new CoordSet();
147
+ for (const neighbor of neighbors) {
148
+ if (processedPositions.has(neighbor)) {
149
+ continue;
150
+ }
151
+ const stone = board[neighbor.x][neighbor.y][neighbor.z];
152
+ if (excludeEmpty && stone === StoneType.EMPTY) {
153
+ continue;
154
+ }
155
+ if (stone !== StoneType.EMPTY) {
156
+ const group = findGroup(neighbor, board, shape);
157
+ group.positions.forEach((p) => processedPositions.insert(p));
158
+ groups.push(group);
159
+ }
160
+ }
161
+ return groups;
162
+ }
163
+ function isGroupCaptured(group, board, shape) {
164
+ const liberties = group.getLiberties(board, shape);
165
+ return liberties.size() === 0;
166
+ }
167
+ function findCapturedGroups(pos, playerColor, board, shape) {
168
+ const enemyColor = getEnemyColor(playerColor);
169
+ const captured = [];
170
+ const tempBoard = board.map((plane) => plane.map((row) => [...row]));
171
+ tempBoard[pos.x][pos.y][pos.z] = playerColor;
172
+ const neighborGroups = getNeighborGroups(pos, tempBoard, shape, true);
173
+ for (const group of neighborGroups) {
174
+ if (group.color === enemyColor) {
175
+ if (isGroupCaptured(group, tempBoard, shape)) {
176
+ captured.push(group);
177
+ }
178
+ }
179
+ }
180
+ return captured;
181
+ }
182
+ function isSuicideMove(pos, playerColor, board, shape) {
183
+ const tempBoard = board.map((plane) => plane.map((row) => [...row]));
184
+ tempBoard[pos.x][pos.y][pos.z] = playerColor;
185
+ const capturedGroups = findCapturedGroups(pos, playerColor, board, shape);
186
+ if (capturedGroups.length > 0) {
187
+ return false;
188
+ }
189
+ const placedGroup = findGroup(pos, tempBoard, shape);
190
+ const liberties = placedGroup.getLiberties(tempBoard, shape);
191
+ return liberties.size() === 0;
192
+ }
193
+ function isKoViolation(pos, playerColor, board, shape, lastCapturedPositions) {
194
+ if (!lastCapturedPositions || lastCapturedPositions.length !== 1) {
195
+ return false;
196
+ }
197
+ const capturedGroups = findCapturedGroups(pos, playerColor, board, shape);
198
+ if (capturedGroups.length !== 1 || capturedGroups[0].size() !== 1) {
199
+ return false;
200
+ }
201
+ const previouslyCaptured = lastCapturedPositions[0];
202
+ if (positionsEqual(pos, previouslyCaptured)) {
203
+ return true;
204
+ }
205
+ return false;
206
+ }
207
+ function executeCaptures(capturedGroups, board) {
208
+ const capturedPositions = [];
209
+ for (const group of capturedGroups) {
210
+ group.positions.forEach((pos) => {
211
+ board[pos.x][pos.y][pos.z] = StoneType.EMPTY;
212
+ capturedPositions.push(pos);
213
+ });
214
+ }
215
+ return capturedPositions;
216
+ }
217
+ function findEmptyRegion(startPos, board, shape, visited) {
218
+ const region = new CoordSet();
219
+ const stack = [startPos];
220
+ while (stack.length > 0) {
221
+ const pos = stack.pop();
222
+ if (visited.has(pos)) {
223
+ continue;
224
+ }
225
+ visited.insert(pos);
226
+ if (board[pos.x][pos.y][pos.z] === StoneType.EMPTY) {
227
+ region.insert(pos);
228
+ const neighbors = getNeighbors(pos, shape);
229
+ for (const neighbor of neighbors) {
230
+ if (!visited.has(neighbor)) {
231
+ stack.push(neighbor);
232
+ }
233
+ }
234
+ }
235
+ }
236
+ return region;
237
+ }
238
+ function determineRegionOwner(region, board, shape) {
239
+ let owner = StoneType.EMPTY;
240
+ let solved = false;
241
+ region.forEach((pos) => {
242
+ if (solved) return;
243
+ const neighbors = getNeighbors(pos, shape);
244
+ for (const neighbor of neighbors) {
245
+ if (solved) break;
246
+ const stone = board[neighbor.x][neighbor.y][neighbor.z];
247
+ if (stone !== StoneType.EMPTY) {
248
+ if (owner === StoneType.EMPTY) {
249
+ owner = stone;
250
+ } else if (owner !== stone) {
251
+ owner = StoneType.EMPTY;
252
+ solved = true;
253
+ }
254
+ }
255
+ }
256
+ });
257
+ return owner;
258
+ }
259
+ function calculateTerritory(board, shape) {
260
+ const result = {
261
+ black: 0,
262
+ white: 0,
263
+ neutral: 0,
264
+ blackTerritory: [],
265
+ whiteTerritory: [],
266
+ neutralTerritory: []
267
+ };
268
+ const visited = new CoordSet();
269
+ const emptyRegions = [];
270
+ for (let x = 0; x < shape.x; x++) {
271
+ for (let y = 0; y < shape.y; y++) {
272
+ for (let z = 0; z < shape.z; z++) {
273
+ const pos = { x, y, z };
274
+ const stone = board[x][y][z];
275
+ if (stone === StoneType.BLACK) {
276
+ result.black++;
277
+ result.blackTerritory.push(pos);
278
+ } else if (stone === StoneType.WHITE) {
279
+ result.white++;
280
+ result.whiteTerritory.push(pos);
281
+ } else if (!visited.has(pos)) {
282
+ const region = findEmptyRegion(pos, board, shape, visited);
283
+ emptyRegions.push(region);
284
+ }
285
+ }
286
+ }
287
+ }
288
+ for (const region of emptyRegions) {
289
+ const owner = determineRegionOwner(region, board, shape);
290
+ const regionArray = region.toArray();
291
+ if (owner === StoneType.BLACK) {
292
+ result.black += region.size();
293
+ result.blackTerritory.push(...regionArray);
294
+ } else if (owner === StoneType.WHITE) {
295
+ result.white += region.size();
296
+ result.whiteTerritory.push(...regionArray);
297
+ } else {
298
+ result.neutral += region.size();
299
+ result.neutralTerritory.push(...regionArray);
300
+ }
301
+ }
302
+ return result;
303
+ }
304
+ function validateMove(pos, playerColor, board, shape, lastCapturedPositions = null) {
305
+ if (!isInBounds(pos, shape)) {
306
+ return { valid: false, reason: "Position out of bounds" };
307
+ }
308
+ if (board[pos.x][pos.y][pos.z] !== StoneType.EMPTY) {
309
+ return { valid: false, reason: "Position already occupied" };
310
+ }
311
+ if (isKoViolation(pos, playerColor, board, shape, lastCapturedPositions)) {
312
+ return { valid: false, reason: "Ko rule violation" };
313
+ }
314
+ if (isSuicideMove(pos, playerColor, board, shape)) {
315
+ return { valid: false, reason: "suicide move not allowed" };
316
+ }
317
+ return { valid: true };
318
+ }
319
+
320
+ // inc/trigo/ab0yz.ts
321
+ var compactShape = (shape) => shape[shape.length - 1] === 1 ? compactShape(shape.slice(0, shape.length - 1)) : shape;
322
+ var encodeAb0yz = (pos, boardShape) => {
323
+ const compactedShape = compactShape(boardShape);
324
+ const result = [];
325
+ for (let i = 0; i < compactedShape.length; i++) {
326
+ const size = compactedShape[i];
327
+ const center = (size - 1) / 2;
328
+ const index = pos[i];
329
+ if (index === center) {
330
+ result.push("0");
331
+ } else if (index < center) {
332
+ result.push(String.fromCharCode(97 + index));
333
+ } else {
334
+ const offset = size - 1 - index;
335
+ result.push(String.fromCharCode(122 - offset));
336
+ }
337
+ }
338
+ return result.join("");
339
+ };
340
+ var decodeAb0yz = (code, boardShape) => {
341
+ const compactedShape = compactShape(boardShape);
342
+ if (code.length !== compactedShape.length) {
343
+ throw new Error(
344
+ `Invalid TGN coordinate: "${code}" (must be ${compactedShape.length} characters for board shape ${boardShape.join("x")})`
345
+ );
346
+ }
347
+ const result = [];
348
+ for (let i = 0; i < compactedShape.length; i++) {
349
+ const char = code[i];
350
+ const size = compactedShape[i];
351
+ const center = (size - 1) / 2;
352
+ if (char === "0") {
353
+ console.assert(Number.isInteger(center));
354
+ result.push(center);
355
+ } else {
356
+ const charCode = char.charCodeAt(0);
357
+ if (charCode >= 97 && charCode <= 122) {
358
+ const distFromA = charCode - 97;
359
+ const distFromZ = 122 - charCode;
360
+ if (distFromA < distFromZ) {
361
+ const index = distFromA;
362
+ if (index >= center) {
363
+ throw new Error(
364
+ `Invalid TGN coordinate: "${code}" (position ${index} >= center ${center} on axis ${i})`
365
+ );
366
+ }
367
+ result.push(index);
368
+ } else {
369
+ const index = size - 1 - distFromZ;
370
+ if (index <= center) {
371
+ throw new Error(
372
+ `Invalid TGN coordinate: "${code}" (position ${index} <= center ${center} on axis ${i})`
373
+ );
374
+ }
375
+ result.push(index);
376
+ }
377
+ } else {
378
+ throw new Error(
379
+ `Invalid TGN coordinate: "${code}" (character '${char}' at position ${i} must be '0' or a-z)`
380
+ );
381
+ }
382
+ }
383
+ }
384
+ while (result.length < boardShape.length) {
385
+ result.push(0);
386
+ }
387
+ return result;
388
+ };
389
+
390
+ // inc/tgn/tgnParser.ts
391
+ var TGNParseError = class extends Error {
392
+ constructor(message, line, column, hash) {
393
+ super(message);
394
+ this.line = line;
395
+ this.column = column;
396
+ this.hash = hash;
397
+ this.name = "TGNParseError";
398
+ }
399
+ };
400
+ var parserModule = null;
401
+ function getParser() {
402
+ if (!parserModule) {
403
+ throw new Error(
404
+ "TGN parser not loaded. Please ensure the parser has been built.\nRun: npm run build:parsers"
405
+ );
406
+ }
407
+ return parserModule;
408
+ }
409
+ function parseTGN(tgnString) {
410
+ const parser = getParser();
411
+ if (!parser.parse) {
412
+ throw new Error("TGN parser parse method not available");
413
+ }
414
+ try {
415
+ const result = parser.parse(tgnString);
416
+ return result;
417
+ } catch (error) {
418
+ throw new TGNParseError(
419
+ error.message || "Unknown parse error",
420
+ error.hash?.line,
421
+ error.hash?.loc?.first_column,
422
+ error.hash
423
+ );
424
+ }
425
+ }
426
+
427
+ // inc/trigo/game.ts
428
+ var TrigoGame = class _TrigoGame {
429
+ /**
430
+ * Constructor
431
+ * Equivalent to trigo.Game constructor (lines 75-85)
432
+ */
433
+ constructor(shape = { x: 5, y: 5, z: 5 }, callbacks = {}) {
434
+ // Last captured stones for Ko rule detection
435
+ this.lastCapturedPositions = null;
436
+ // Static analysis cache (territory, capturing moves)
437
+ // Invalidated on any board state change
438
+ this.cachedTerritory = null;
439
+ this.cachedCapturingMove = /* @__PURE__ */ new Map();
440
+ this.shape = shape;
441
+ this.callbacks = callbacks;
442
+ this.board = this.createEmptyBoard();
443
+ this.currentPlayer = StoneType.BLACK;
444
+ this.stepHistory = [];
445
+ this.currentStepIndex = 0;
446
+ this.gameStatus = "idle";
447
+ this.gameResult = void 0;
448
+ this.passCount = 0;
449
+ }
450
+ /**
451
+ * Create an empty board
452
+ */
453
+ createEmptyBoard() {
454
+ const board = [];
455
+ for (let x = 0; x < this.shape.x; x++) {
456
+ board[x] = [];
457
+ for (let y = 0; y < this.shape.y; y++) {
458
+ board[x][y] = [];
459
+ for (let z = 0; z < this.shape.z; z++) {
460
+ board[x][y][z] = StoneType.EMPTY;
461
+ }
462
+ }
463
+ }
464
+ return board;
465
+ }
466
+ /**
467
+ * Reset the game to initial state
468
+ * Equivalent to Game.reset() (lines 153-163)
469
+ */
470
+ reset() {
471
+ this.board = this.createEmptyBoard();
472
+ this.currentPlayer = StoneType.BLACK;
473
+ this.stepHistory = [];
474
+ this.currentStepIndex = 0;
475
+ this.lastCapturedPositions = null;
476
+ this.invalidateAnalysisCache();
477
+ this.gameStatus = "idle";
478
+ this.gameResult = void 0;
479
+ this.passCount = 0;
480
+ }
481
+ /**
482
+ * Invalidate all static analysis caches
483
+ * Called when board state changes
484
+ */
485
+ invalidateAnalysisCache() {
486
+ this.cachedTerritory = null;
487
+ this.cachedCapturingMove.clear();
488
+ }
489
+ /**
490
+ * Clone the game state (deep copy)
491
+ * Creates an independent copy with all state preserved
492
+ */
493
+ clone() {
494
+ const cloned = new _TrigoGame(this.shape, {});
495
+ cloned.board = this.board.map((plane) => plane.map((row) => [...row]));
496
+ cloned.currentPlayer = this.currentPlayer;
497
+ cloned.currentStepIndex = this.currentStepIndex;
498
+ cloned.gameStatus = this.gameStatus;
499
+ cloned.passCount = this.passCount;
500
+ cloned.stepHistory = this.stepHistory.map((step) => ({
501
+ ...step,
502
+ position: step.position ? { ...step.position } : void 0,
503
+ capturedPositions: step.capturedPositions ? step.capturedPositions.map((pos) => ({ ...pos })) : []
504
+ }));
505
+ cloned.lastCapturedPositions = this.lastCapturedPositions ? this.lastCapturedPositions.map((pos) => ({ ...pos })) : null;
506
+ if (this.gameResult) {
507
+ cloned.gameResult = {
508
+ ...this.gameResult,
509
+ score: this.gameResult.score ? { ...this.gameResult.score } : void 0
510
+ };
511
+ }
512
+ cloned.invalidateAnalysisCache();
513
+ return cloned;
514
+ }
515
+ /**
516
+ * Get current board state (read-only)
517
+ */
518
+ getBoard() {
519
+ return this.board.map((plane) => plane.map((row) => [...row]));
520
+ }
521
+ /**
522
+ * Get stone at specific position
523
+ * Equivalent to Game.stone() (lines 95-97)
524
+ */
525
+ getStone(pos) {
526
+ return this.board[pos.x][pos.y][pos.z];
527
+ }
528
+ /**
529
+ * Get current player
530
+ */
531
+ getCurrentPlayer() {
532
+ return this.currentPlayer;
533
+ }
534
+ /**
535
+ * Get current step number
536
+ * Equivalent to Game.currentStep() (lines 99-101)
537
+ */
538
+ getCurrentStep() {
539
+ return this.currentStepIndex;
540
+ }
541
+ /**
542
+ * Get move history
543
+ * Equivalent to Game.routine() (lines 103-105)
544
+ */
545
+ getHistory() {
546
+ return [...this.stepHistory];
547
+ }
548
+ /**
549
+ * Get last move
550
+ * Equivalent to Game.lastStep() (lines 107-110)
551
+ */
552
+ getLastStep() {
553
+ if (this.currentStepIndex > 0) {
554
+ return this.stepHistory[this.currentStepIndex - 1];
555
+ }
556
+ return null;
557
+ }
558
+ /**
559
+ * Get board shape
560
+ * Equivalent to Game.shape() (lines 87-89)
561
+ */
562
+ getShape() {
563
+ return { ...this.shape };
564
+ }
565
+ /**
566
+ * Get game status
567
+ */
568
+ getGameStatus() {
569
+ return this.gameStatus;
570
+ }
571
+ /**
572
+ * Set game status
573
+ */
574
+ setGameStatus(status) {
575
+ this.gameStatus = status;
576
+ }
577
+ /**
578
+ * Get game result
579
+ */
580
+ getGameResult() {
581
+ return this.gameResult;
582
+ }
583
+ /**
584
+ * Get consecutive pass count
585
+ */
586
+ getPassCount() {
587
+ return this.passCount;
588
+ }
589
+ /**
590
+ * Recalculate consecutive pass count based on current history
591
+ * Counts consecutive PASS steps from the end of current history
592
+ */
593
+ recalculatePassCount() {
594
+ this.passCount = 0;
595
+ for (let i = this.currentStepIndex - 1; i >= 0; i--) {
596
+ if (this.stepHistory[i].type === 1 /* PASS */) {
597
+ this.passCount++;
598
+ } else {
599
+ break;
600
+ }
601
+ }
602
+ }
603
+ /**
604
+ * Start the game
605
+ */
606
+ startGame() {
607
+ if (this.gameStatus === "idle") {
608
+ this.gameStatus = "playing";
609
+ }
610
+ }
611
+ /**
612
+ * Check if game is active
613
+ */
614
+ isGameActive() {
615
+ return this.gameStatus === "playing";
616
+ }
617
+ /**
618
+ * Check if a move is valid
619
+ * Equivalent to Game.isDropable() and Game.isValidStep() (lines 112-151)
620
+ */
621
+ isValidMove(pos, player) {
622
+ const playerColor = player || this.currentPlayer;
623
+ return validateMove(pos, playerColor, this.board, this.shape, this.lastCapturedPositions);
624
+ }
625
+ /**
626
+ * Get all valid move positions for current player (efficient batch query)
627
+ *
628
+ * This method is optimized to avoid repeated validation checks by:
629
+ * 1. Only checking empty positions
630
+ * 2. Skipping bounds checking (iterator is already within bounds)
631
+ * 3. Using low-level validation functions directly
632
+ * 4. Batching board state access
633
+ *
634
+ * @param player - Optional player color (defaults to current player)
635
+ * @returns Array of all valid move positions
636
+ */
637
+ validMovePositions(player) {
638
+ const playerColor = player || this.currentPlayer;
639
+ const validPositions = [];
640
+ for (let x = 0; x < this.shape.x; x++) {
641
+ for (let y = 0; y < this.shape.y; y++) {
642
+ for (let z = 0; z < this.shape.z; z++) {
643
+ if (this.board[x][y][z] !== StoneType.EMPTY) {
644
+ continue;
645
+ }
646
+ const pos = { x, y, z };
647
+ if (isKoViolation(
648
+ pos,
649
+ playerColor,
650
+ this.board,
651
+ this.shape,
652
+ this.lastCapturedPositions
653
+ )) {
654
+ continue;
655
+ }
656
+ if (isSuicideMove(pos, playerColor, this.board, this.shape)) {
657
+ continue;
658
+ }
659
+ validPositions.push(pos);
660
+ }
661
+ }
662
+ }
663
+ return validPositions;
664
+ }
665
+ /**
666
+ * Check if any valid move can capture enemy stones
667
+ * Used by MCTS to determine if a position is truly terminal
668
+ *
669
+ * Results are cached and invalidated when board state changes.
670
+ *
671
+ * @param player - Optional player color (defaults to current player)
672
+ * @returns true if at least one valid move would capture stones
673
+ */
674
+ hasCapturingMove(player) {
675
+ const playerColor = player ?? this.currentPlayer;
676
+ if (this.cachedCapturingMove.has(playerColor)) {
677
+ return this.cachedCapturingMove.get(playerColor);
678
+ }
679
+ const result = this.computeHasCapturingMove(playerColor);
680
+ this.cachedCapturingMove.set(playerColor, result);
681
+ return result;
682
+ }
683
+ /**
684
+ * Internal: Compute whether a player has any capturing move
685
+ */
686
+ computeHasCapturingMove(playerColor) {
687
+ for (let x = 0; x < this.shape.x; x++) {
688
+ for (let y = 0; y < this.shape.y; y++) {
689
+ for (let z = 0; z < this.shape.z; z++) {
690
+ if (this.board[x][y][z] !== StoneType.EMPTY) {
691
+ continue;
692
+ }
693
+ const pos = { x, y, z };
694
+ if (isKoViolation(
695
+ pos,
696
+ playerColor,
697
+ this.board,
698
+ this.shape,
699
+ this.lastCapturedPositions
700
+ )) {
701
+ continue;
702
+ }
703
+ if (isSuicideMove(pos, playerColor, this.board, this.shape)) {
704
+ continue;
705
+ }
706
+ const capturedGroups = findCapturedGroups(pos, playerColor, this.board, this.shape);
707
+ if (capturedGroups.length > 0) {
708
+ return true;
709
+ }
710
+ }
711
+ }
712
+ }
713
+ return false;
714
+ }
715
+ /**
716
+ * Check if the game has reached a natural terminal state
717
+ *
718
+ * A game is naturally terminal when:
719
+ * - All territory is claimed (neutral === 0)
720
+ * - AND neither player has any capturing moves available
721
+ *
722
+ * This is different from the "finished" status which requires double pass.
723
+ * Natural termination means the game state is completely settled and
724
+ * no further moves can meaningfully change the outcome.
725
+ *
726
+ * NOTE: This method is expensive due to territory calculation and capture move checking.
727
+ * Use coverage ratio check as a pre-filter when calling frequently.
728
+ *
729
+ * Used by:
730
+ * - MCTS agent (terminal detection for tree search)
731
+ * - Model battle (early stopping when settled)
732
+ * - Self-play games (early stopping when settled)
733
+ *
734
+ * @returns true if the game is naturally terminal, false otherwise
735
+ */
736
+ isNaturallyTerminal() {
737
+ const territory = this.getTerritory();
738
+ if (territory.neutral !== 0) {
739
+ return false;
740
+ }
741
+ const blackCanCapture = this.hasCapturingMove(StoneType.BLACK);
742
+ const whiteCanCapture = this.hasCapturingMove(StoneType.WHITE);
743
+ return !blackCanCapture && !whiteCanCapture;
744
+ }
745
+ /**
746
+ * Reset pass count (called when a stone is placed)
747
+ */
748
+ resetPassCount() {
749
+ this.passCount = 0;
750
+ }
751
+ /**
752
+ * Place a stone (drop move)
753
+ * Equivalent to Game.drop() and Game.appendStone() (lines 181-273)
754
+ *
755
+ * @returns true if move was successful, false otherwise
756
+ */
757
+ drop(pos) {
758
+ const validation = this.isValidMove(pos);
759
+ if (!validation.valid) {
760
+ console.warn(`Invalid move at (${pos.x}, ${pos.y}, ${pos.z}): ${validation.reason}`);
761
+ return false;
762
+ }
763
+ const capturedGroups = findCapturedGroups(pos, this.currentPlayer, this.board, this.shape);
764
+ this.board[pos.x][pos.y][pos.z] = this.currentPlayer;
765
+ const capturedPositions = executeCaptures(capturedGroups, this.board);
766
+ this.lastCapturedPositions = capturedPositions.length > 0 ? capturedPositions : null;
767
+ this.invalidateAnalysisCache();
768
+ this.resetPassCount();
769
+ const step = {
770
+ type: 0 /* DROP */,
771
+ position: pos,
772
+ player: this.currentPlayer,
773
+ capturedPositions: capturedPositions.length > 0 ? capturedPositions : void 0,
774
+ timestamp: Date.now()
775
+ };
776
+ this.advanceStep(step);
777
+ if (capturedPositions.length > 0 && this.callbacks.onCapture) {
778
+ this.callbacks.onCapture(capturedPositions);
779
+ }
780
+ if (this.callbacks.onTerritoryChange) {
781
+ this.callbacks.onTerritoryChange(this.getTerritory());
782
+ }
783
+ return true;
784
+ }
785
+ /**
786
+ * Pass turn
787
+ * Equivalent to PASS step type in prototype
788
+ */
789
+ pass() {
790
+ const step = {
791
+ type: 1 /* PASS */,
792
+ player: this.currentPlayer,
793
+ timestamp: Date.now()
794
+ };
795
+ this.lastCapturedPositions = null;
796
+ this.passCount++;
797
+ this.advanceStep(step);
798
+ if (this.passCount >= 2) {
799
+ const territory = this.getTerritory();
800
+ const capturedCounts = this.getCapturedCounts();
801
+ const blackTotal = territory.black + capturedCounts.white;
802
+ const whiteTotal = territory.white + capturedCounts.black;
803
+ let winner;
804
+ if (blackTotal > whiteTotal) {
805
+ winner = "black";
806
+ } else if (whiteTotal > blackTotal) {
807
+ winner = "white";
808
+ } else {
809
+ winner = "draw";
810
+ }
811
+ this.gameResult = {
812
+ winner,
813
+ reason: "double-pass",
814
+ score: territory
815
+ };
816
+ this.gameStatus = "finished";
817
+ if (this.callbacks.onWin) {
818
+ const winnerStone = winner === "black" ? StoneType.BLACK : winner === "white" ? StoneType.WHITE : StoneType.EMPTY;
819
+ this.callbacks.onWin(winnerStone);
820
+ }
821
+ }
822
+ return true;
823
+ }
824
+ /**
825
+ * Surrender/resign
826
+ * Equivalent to Game.step() with SURRENDER type (lines 176-178)
827
+ */
828
+ surrender() {
829
+ const surrenderingPlayer = this.currentPlayer;
830
+ const step = {
831
+ type: 2 /* SURRENDER */,
832
+ player: this.currentPlayer,
833
+ timestamp: Date.now()
834
+ };
835
+ this.advanceStep(step);
836
+ const winner = surrenderingPlayer === StoneType.BLACK ? "white" : "black";
837
+ this.gameResult = {
838
+ winner,
839
+ reason: "resignation"
840
+ };
841
+ this.gameStatus = "finished";
842
+ const winnerStone = getEnemyColor(surrenderingPlayer);
843
+ if (this.callbacks.onWin) {
844
+ this.callbacks.onWin(winnerStone);
845
+ }
846
+ return true;
847
+ }
848
+ /**
849
+ * Undo last move
850
+ * Equivalent to Game.repent() (lines 197-230)
851
+ *
852
+ * @returns true if undo was successful, false if no moves to undo
853
+ */
854
+ undo() {
855
+ if (this.currentStepIndex === 0 || this.stepHistory.length === 0) {
856
+ return false;
857
+ }
858
+ const lastStep = this.stepHistory[this.currentStepIndex - 1];
859
+ if (lastStep.type === 0 /* DROP */ && lastStep.position) {
860
+ this.board[lastStep.position.x][lastStep.position.y][lastStep.position.z] = StoneType.EMPTY;
861
+ if (lastStep.capturedPositions) {
862
+ const enemyColor = getEnemyColor(lastStep.player);
863
+ for (const pos of lastStep.capturedPositions) {
864
+ this.board[pos.x][pos.y][pos.z] = enemyColor;
865
+ }
866
+ }
867
+ }
868
+ this.currentStepIndex--;
869
+ this.currentPlayer = lastStep.player;
870
+ this.recalculatePassCount();
871
+ if (this.currentStepIndex > 0) {
872
+ const previousStep = this.stepHistory[this.currentStepIndex - 1];
873
+ this.lastCapturedPositions = previousStep.capturedPositions || null;
874
+ } else {
875
+ this.lastCapturedPositions = null;
876
+ }
877
+ this.invalidateAnalysisCache();
878
+ if (this.callbacks.onStepBack) {
879
+ this.callbacks.onStepBack(lastStep, this.stepHistory.slice(0, this.currentStepIndex));
880
+ }
881
+ return true;
882
+ }
883
+ /**
884
+ * Redo next move (after undo)
885
+ *
886
+ * @returns true if redo was successful, false if no moves to redo
887
+ */
888
+ redo() {
889
+ if (this.currentStepIndex >= this.stepHistory.length) {
890
+ return false;
891
+ }
892
+ const nextStep = this.stepHistory[this.currentStepIndex];
893
+ if (nextStep.type === 0 /* DROP */ && nextStep.position) {
894
+ this.board[nextStep.position.x][nextStep.position.y][nextStep.position.z] = nextStep.player;
895
+ if (nextStep.capturedPositions) {
896
+ for (const pos of nextStep.capturedPositions) {
897
+ this.board[pos.x][pos.y][pos.z] = StoneType.EMPTY;
898
+ }
899
+ }
900
+ this.lastCapturedPositions = nextStep.capturedPositions || null;
901
+ } else if (nextStep.type === 1 /* PASS */) {
902
+ this.lastCapturedPositions = null;
903
+ }
904
+ this.currentStepIndex++;
905
+ this.currentPlayer = getEnemyColor(nextStep.player);
906
+ this.invalidateAnalysisCache();
907
+ if (this.callbacks.onStepAdvance) {
908
+ this.callbacks.onStepAdvance(
909
+ nextStep,
910
+ this.stepHistory.slice(0, this.currentStepIndex)
911
+ );
912
+ }
913
+ return true;
914
+ }
915
+ /**
916
+ * Check if redo is available
917
+ */
918
+ canRedo() {
919
+ return this.currentStepIndex < this.stepHistory.length;
920
+ }
921
+ /**
922
+ * Jump to specific step in history
923
+ * Rebuilds board state after applying the first 'index' moves
924
+ *
925
+ * @param index Number of moves to apply from history (0 for initial state, 1 for after first move, etc.)
926
+ * @returns true if jump was successful
927
+ */
928
+ jumpToStep(index) {
929
+ if (index < 0 || index > this.stepHistory.length) {
930
+ return false;
931
+ }
932
+ if (index === this.currentStepIndex) {
933
+ return false;
934
+ }
935
+ this.board = this.createEmptyBoard();
936
+ this.lastCapturedPositions = null;
937
+ for (let i = 0; i < index; i++) {
938
+ const step = this.stepHistory[i];
939
+ if (step.type === 0 /* DROP */ && step.position) {
940
+ const pos = step.position;
941
+ this.board[pos.x][pos.y][pos.z] = step.player;
942
+ if (step.capturedPositions) {
943
+ for (const capturedPos of step.capturedPositions) {
944
+ this.board[capturedPos.x][capturedPos.y][capturedPos.z] = StoneType.EMPTY;
945
+ }
946
+ }
947
+ }
948
+ }
949
+ if (index > 0) {
950
+ const lastAppliedStep = this.stepHistory[index - 1];
951
+ if (lastAppliedStep.type === 0 /* DROP */) {
952
+ this.lastCapturedPositions = lastAppliedStep.capturedPositions || null;
953
+ } else if (lastAppliedStep.type === 1 /* PASS */) {
954
+ this.lastCapturedPositions = null;
955
+ }
956
+ } else {
957
+ this.lastCapturedPositions = null;
958
+ }
959
+ const oldStepIndex = this.currentStepIndex;
960
+ this.currentStepIndex = index;
961
+ const movesPlayed = index;
962
+ this.currentPlayer = movesPlayed % 2 === 0 ? StoneType.BLACK : StoneType.WHITE;
963
+ this.recalculatePassCount();
964
+ this.invalidateAnalysisCache();
965
+ if (index < oldStepIndex && this.callbacks.onStepBack) {
966
+ const currentStep = this.stepHistory[index];
967
+ this.callbacks.onStepBack(currentStep, this.stepHistory.slice(0, index + 1));
968
+ } else if (index > oldStepIndex && this.callbacks.onStepAdvance) {
969
+ const currentStep = this.stepHistory[index];
970
+ this.callbacks.onStepAdvance(currentStep, this.stepHistory.slice(0, index + 1));
971
+ }
972
+ return true;
973
+ }
974
+ /**
975
+ * Advance to next step
976
+ * Equivalent to Game.stepAdvance() (lines 279-287)
977
+ */
978
+ advanceStep(step) {
979
+ if (this.currentStepIndex < this.stepHistory.length) {
980
+ this.stepHistory = this.stepHistory.slice(0, this.currentStepIndex);
981
+ }
982
+ this.stepHistory.push(step);
983
+ this.currentStepIndex++;
984
+ this.currentPlayer = getEnemyColor(this.currentPlayer);
985
+ if (this.callbacks.onStepAdvance) {
986
+ this.callbacks.onStepAdvance(step, this.stepHistory);
987
+ }
988
+ }
989
+ /**
990
+ * Get territory calculation
991
+ * Equivalent to Game.blackDomain() and Game.whiteDomain() (lines 232-244)
992
+ *
993
+ * Returns cached result if territory hasn't changed
994
+ */
995
+ getTerritory() {
996
+ if (!this.cachedTerritory)
997
+ this.cachedTerritory = calculateTerritory(this.board, this.shape);
998
+ return this.cachedTerritory;
999
+ }
1000
+ /**
1001
+ * Get captured stone counts up to current position in history
1002
+ * Only counts captures that have been played (up to currentStepIndex)
1003
+ */
1004
+ getCapturedCounts() {
1005
+ const counts = { black: 0, white: 0 };
1006
+ for (let i = 0; i < this.currentStepIndex; i++) {
1007
+ const step = this.stepHistory[i];
1008
+ if (step.capturedPositions && step.capturedPositions.length > 0) {
1009
+ const enemyColor = getEnemyColor(step.player);
1010
+ if (enemyColor === StoneType.BLACK) {
1011
+ counts.black += step.capturedPositions.length;
1012
+ } else if (enemyColor === StoneType.WHITE) {
1013
+ counts.white += step.capturedPositions.length;
1014
+ }
1015
+ }
1016
+ }
1017
+ return counts;
1018
+ }
1019
+ /**
1020
+ * Serialize game state to JSON
1021
+ * Equivalent to Game.serialize() (lines 250-252)
1022
+ */
1023
+ toJSON() {
1024
+ return {
1025
+ shape: this.shape,
1026
+ currentPlayer: this.currentPlayer,
1027
+ currentStepIndex: this.currentStepIndex,
1028
+ history: this.stepHistory,
1029
+ board: this.board,
1030
+ gameStatus: this.gameStatus,
1031
+ gameResult: this.gameResult,
1032
+ passCount: this.passCount
1033
+ };
1034
+ }
1035
+ /**
1036
+ * Load game state from JSON
1037
+ */
1038
+ fromJSON(data) {
1039
+ try {
1040
+ if (!data || typeof data !== "object") {
1041
+ return false;
1042
+ }
1043
+ if (!data.shape || !data.board || !Array.isArray(data.history)) {
1044
+ return false;
1045
+ }
1046
+ this.shape = data.shape;
1047
+ this.currentPlayer = data.currentPlayer;
1048
+ this.currentStepIndex = data.currentStepIndex;
1049
+ this.stepHistory = data.history || [];
1050
+ this.board = data.board;
1051
+ this.gameStatus = data.gameStatus || "idle";
1052
+ this.gameResult = data.gameResult;
1053
+ this.passCount = data.passCount || 0;
1054
+ if (this.currentStepIndex > 0) {
1055
+ const lastStep = this.stepHistory[this.currentStepIndex - 1];
1056
+ this.lastCapturedPositions = lastStep.capturedPositions || null;
1057
+ } else {
1058
+ this.lastCapturedPositions = null;
1059
+ }
1060
+ this.invalidateAnalysisCache();
1061
+ return true;
1062
+ } catch (error) {
1063
+ console.error("Failed to load game state:", error);
1064
+ return false;
1065
+ }
1066
+ }
1067
+ /**
1068
+ * Get game statistics
1069
+ */
1070
+ getStats() {
1071
+ const captured = this.getCapturedCounts();
1072
+ const territory = this.getTerritory();
1073
+ let blackMoves = 0;
1074
+ let whiteMoves = 0;
1075
+ for (const step of this.stepHistory.slice(0, this.currentStepIndex)) {
1076
+ if (step.type === 0 /* DROP */) {
1077
+ if (step.player === StoneType.BLACK) {
1078
+ blackMoves++;
1079
+ } else if (step.player === StoneType.WHITE) {
1080
+ whiteMoves++;
1081
+ }
1082
+ }
1083
+ }
1084
+ return {
1085
+ totalMoves: this.currentStepIndex,
1086
+ blackMoves,
1087
+ whiteMoves,
1088
+ capturedByBlack: captured.white,
1089
+ // Black captures white stones
1090
+ capturedByWhite: captured.black,
1091
+ // White captures black stones
1092
+ territory
1093
+ };
1094
+ }
1095
+ /**
1096
+ * Save game state to sessionStorage
1097
+ *
1098
+ * @param key Storage key (default: "trigoGameState")
1099
+ * @returns true if save was successful
1100
+ */
1101
+ saveToSessionStorage(key = "trigoGameState") {
1102
+ if (typeof globalThis !== "undefined" && globalThis.sessionStorage) {
1103
+ try {
1104
+ const gameState = this.toJSON();
1105
+ globalThis.sessionStorage.setItem(key, JSON.stringify(gameState));
1106
+ return true;
1107
+ } catch (error) {
1108
+ console.error("Failed to save game state to sessionStorage:", error);
1109
+ return false;
1110
+ }
1111
+ }
1112
+ console.warn("sessionStorage is not available");
1113
+ return false;
1114
+ }
1115
+ /**
1116
+ * Load game state from sessionStorage
1117
+ *
1118
+ * @param key Storage key (default: "trigoGameState")
1119
+ * @returns true if load was successful
1120
+ */
1121
+ loadFromSessionStorage(key = "trigoGameState") {
1122
+ if (typeof globalThis !== "undefined" && globalThis.sessionStorage) {
1123
+ try {
1124
+ const savedState = globalThis.sessionStorage.getItem(key);
1125
+ if (!savedState) {
1126
+ console.log("No saved game state found");
1127
+ return false;
1128
+ }
1129
+ const data = JSON.parse(savedState);
1130
+ return this.fromJSON(data);
1131
+ } catch (error) {
1132
+ console.error("Failed to load game state from sessionStorage:", error);
1133
+ return false;
1134
+ }
1135
+ }
1136
+ console.warn("sessionStorage is not available");
1137
+ return false;
1138
+ }
1139
+ /**
1140
+ * Clear saved game state from sessionStorage
1141
+ *
1142
+ * @param key Storage key (default: "trigoGameState")
1143
+ */
1144
+ clearSessionStorage(key = "trigoGameState") {
1145
+ if (typeof globalThis !== "undefined" && globalThis.sessionStorage) {
1146
+ try {
1147
+ globalThis.sessionStorage.removeItem(key);
1148
+ } catch (error) {
1149
+ console.error("Failed to clear sessionStorage:", error);
1150
+ }
1151
+ } else {
1152
+ console.warn("sessionStorage is not available");
1153
+ }
1154
+ }
1155
+ /**
1156
+ * Export game to TGN (Trigo Game Notation) format
1157
+ *
1158
+ * TGN format is similar to PGN (Portable Game Notation) for chess.
1159
+ * It includes metadata tags and move sequence using ab0yz coordinate notation.
1160
+ *
1161
+ * @param metadata Optional metadata for the game (Event, Site, Date, Players, etc.)
1162
+ * @returns TGN-formatted string
1163
+ *
1164
+ * @example
1165
+ * const tgn = game.toTGN({
1166
+ * event: "World Championship",
1167
+ * site: "Tokyo",
1168
+ * date: "2025.10.31",
1169
+ * black: "Alice",
1170
+ * white: "Bob"
1171
+ * });
1172
+ */
1173
+ toTGN(metadata, { markResult } = {}) {
1174
+ const lines = [];
1175
+ if (metadata) {
1176
+ if (metadata.event) lines.push(`[Event "${metadata.event}"]`);
1177
+ if (metadata.site) lines.push(`[Site "${metadata.site}"]`);
1178
+ if (metadata.date) lines.push(`[Date "${metadata.date}"]`);
1179
+ if (metadata.round) lines.push(`[Round "${metadata.round}"]`);
1180
+ if (metadata.black) lines.push(`[Black "${metadata.black}"]`);
1181
+ if (metadata.white) lines.push(`[White "${metadata.white}"]`);
1182
+ }
1183
+ if (this.gameStatus === "finished" && this.gameResult) {
1184
+ let resultStr = "";
1185
+ if (this.gameResult.winner === "black") {
1186
+ resultStr = "B+";
1187
+ } else if (this.gameResult.winner === "white") {
1188
+ resultStr = "W+";
1189
+ } else {
1190
+ resultStr = "=";
1191
+ }
1192
+ if (this.gameResult.score) {
1193
+ const { black, white } = this.gameResult.score;
1194
+ const diff = Math.abs(black - white);
1195
+ resultStr += `${diff}points`;
1196
+ } else if (this.gameResult.reason === "resignation") {
1197
+ resultStr += "Resign";
1198
+ }
1199
+ }
1200
+ const boardStr = this.shape.z === 1 ? `${this.shape.x}x${this.shape.y}` : `${this.shape.x}x${this.shape.y}x${this.shape.z}`;
1201
+ lines.push(`[Board ${boardStr}]`);
1202
+ if (metadata) {
1203
+ if (metadata.rules) lines.push(`[Rules "${metadata.rules}"]`);
1204
+ if (metadata.timeControl) lines.push(`[TimeControl "${metadata.timeControl}"]`);
1205
+ if (metadata.application) lines.push(`[Application "${metadata.application}"]`);
1206
+ }
1207
+ lines.push("");
1208
+ const moves = [];
1209
+ let moveNumber = 1;
1210
+ for (let i = 0; i < this.stepHistory.length; i++) {
1211
+ const step = this.stepHistory[i];
1212
+ let moveStr = "";
1213
+ if (step.player === StoneType.BLACK) {
1214
+ moveStr = `${moveNumber}. `;
1215
+ }
1216
+ if (step.type === 0 /* DROP */ && step.position) {
1217
+ const pos = [step.position.x, step.position.y, step.position.z];
1218
+ const boardShape = [this.shape.x, this.shape.y, this.shape.z];
1219
+ const coord = encodeAb0yz(pos, boardShape);
1220
+ moveStr += coord;
1221
+ } else if (step.type === 1 /* PASS */) {
1222
+ moveStr += "Pass";
1223
+ } else if (step.type === 2 /* SURRENDER */) {
1224
+ moveStr += "Resign";
1225
+ }
1226
+ moves.push(moveStr);
1227
+ if (step.player === StoneType.WHITE) {
1228
+ moveNumber++;
1229
+ }
1230
+ }
1231
+ let currentLine = "";
1232
+ for (let i = 0; i < moves.length; i++) {
1233
+ const move = moves[i];
1234
+ if (move.match(/^\d+\./)) {
1235
+ if (currentLine) {
1236
+ lines.push(currentLine);
1237
+ }
1238
+ currentLine = move;
1239
+ } else {
1240
+ currentLine += " " + move;
1241
+ }
1242
+ }
1243
+ if (currentLine) {
1244
+ lines.push(currentLine);
1245
+ }
1246
+ if (markResult) {
1247
+ const territory = this.getTerritory();
1248
+ const scoreDiff = territory.black - territory.white;
1249
+ lines.push(`; ${scoreDiff > 0 ? "-" : scoreDiff < 0 ? "+" : ""}${Math.abs(scoreDiff)}`);
1250
+ }
1251
+ lines.push("");
1252
+ return lines.join("\n");
1253
+ }
1254
+ /**
1255
+ * Import game from TGN (Trigo Game Notation) format
1256
+ *
1257
+ * Static factory method that parses a TGN string and creates a new TrigoGame instance
1258
+ * with the board configuration and moves from the TGN file.
1259
+ *
1260
+ * Synchronous operation - requires parser to be loaded via setParserModule()
1261
+ *
1262
+ * @param tgnString TGN-formatted game notation string
1263
+ * @param callbacks Optional game callbacks
1264
+ * @returns New TrigoGame instance with the imported game state
1265
+ * @throws TGNParseError if the TGN string is invalid
1266
+ *
1267
+ * @example
1268
+ * const tgnString = `
1269
+ * [Event "World Championship"]
1270
+ * [Board "5x5x5"]
1271
+ * [Black "Alice"]
1272
+ * [White "Bob"]
1273
+ *
1274
+ * 1. 000 y00
1275
+ * 2. 0y0 pass
1276
+ * `;
1277
+ * const game = TrigoGame.fromTGN(tgnString);
1278
+ */
1279
+ static fromTGN(tgnString, callbacks) {
1280
+ const parsed = parseTGN(tgnString);
1281
+ let boardShape;
1282
+ if (parsed.tags.Board && Array.isArray(parsed.tags.Board)) {
1283
+ const shape = parsed.tags.Board;
1284
+ boardShape = {
1285
+ x: shape[0] || 5,
1286
+ y: shape[1] || 5,
1287
+ z: shape[2] || 1
1288
+ };
1289
+ } else {
1290
+ boardShape = { x: 5, y: 5, z: 5 };
1291
+ }
1292
+ const game = new this(boardShape, callbacks);
1293
+ game.startGame();
1294
+ if (parsed.moves && parsed.moves.length > 0) {
1295
+ for (const round of parsed.moves) {
1296
+ if (round.action_black) {
1297
+ game._applyParsedMove(round.action_black, boardShape);
1298
+ }
1299
+ if (round.action_white) {
1300
+ game._applyParsedMove(round.action_white, boardShape);
1301
+ }
1302
+ }
1303
+ }
1304
+ return game;
1305
+ }
1306
+ /**
1307
+ * Apply a parsed move action to the game
1308
+ * Private helper method for fromTGN
1309
+ *
1310
+ * @param action Parsed move action from TGN parser
1311
+ * @param boardShape Board dimensions for coordinate decoding
1312
+ */
1313
+ _applyParsedMove(action, boardShape) {
1314
+ if (action.type === "pass") {
1315
+ this.pass();
1316
+ } else if (action.type === "resign") {
1317
+ this.surrender();
1318
+ } else if (action.type === "move" && action.position) {
1319
+ const coords = decodeAb0yz(action.position, [boardShape.x, boardShape.y, boardShape.z]);
1320
+ const position = {
1321
+ x: coords[0],
1322
+ y: coords[1],
1323
+ z: coords[2]
1324
+ };
1325
+ this.drop(position);
1326
+ }
1327
+ }
1328
+ };
1329
+
1330
+ // backend/src/services/gameManager.ts
1331
+ var GameManager = class {
1332
+ // Default 5x5x5 board
1333
+ constructor() {
1334
+ this.rooms = /* @__PURE__ */ new Map();
1335
+ this.playerRoomMap = /* @__PURE__ */ new Map();
1336
+ this.defaultBoardShape = { x: 5, y: 5, z: 5 };
1337
+ console.log("GameManager initialized");
1338
+ }
1339
+ createRoom(playerId, nickname, boardShape, preferredColor) {
1340
+ const roomId = this.generateRoomId();
1341
+ const shape = boardShape || this.defaultBoardShape;
1342
+ const playerColor = preferredColor || "black";
1343
+ const room = {
1344
+ id: roomId,
1345
+ adminId: playerId,
1346
+ // Room creator is admin
1347
+ players: {
1348
+ [playerId]: {
1349
+ id: playerId,
1350
+ nickname,
1351
+ color: playerColor,
1352
+ connected: true
1353
+ }
1354
+ },
1355
+ game: new TrigoGame(shape, {
1356
+ onStepAdvance: (_step, history) => {
1357
+ console.log(`Step ${history.length}: Player made move`);
1358
+ },
1359
+ onCapture: (captured) => {
1360
+ console.log(`Captured ${captured.length} stones`);
1361
+ },
1362
+ onWin: (winner) => {
1363
+ console.log(`Game won by ${winner}`);
1364
+ }
1365
+ }),
1366
+ gameState: {
1367
+ gameStatus: "waiting",
1368
+ winner: null
1369
+ },
1370
+ createdAt: /* @__PURE__ */ new Date(),
1371
+ startedAt: null
1372
+ };
1373
+ this.rooms.set(roomId, room);
1374
+ this.playerRoomMap.set(playerId, roomId);
1375
+ console.log(`Room ${roomId} created by ${playerId}`);
1376
+ return room;
1377
+ }
1378
+ joinRoom(roomId, playerId, nickname, preferredColor) {
1379
+ const room = this.rooms.get(roomId);
1380
+ if (!room) {
1381
+ return null;
1382
+ }
1383
+ const playerCount = Object.keys(room.players).length;
1384
+ if (playerCount >= 2) {
1385
+ return null;
1386
+ }
1387
+ const firstPlayer = Object.values(room.players)[0];
1388
+ let assignedColor;
1389
+ if (preferredColor && preferredColor !== firstPlayer.color) {
1390
+ assignedColor = preferredColor;
1391
+ } else {
1392
+ assignedColor = firstPlayer.color === "black" ? "white" : "black";
1393
+ }
1394
+ room.players[playerId] = {
1395
+ id: playerId,
1396
+ nickname,
1397
+ color: assignedColor,
1398
+ connected: true
1399
+ };
1400
+ this.playerRoomMap.set(playerId, roomId);
1401
+ if (playerCount === 1) {
1402
+ room.gameState.gameStatus = "playing";
1403
+ room.startedAt = /* @__PURE__ */ new Date();
1404
+ }
1405
+ return room;
1406
+ }
1407
+ leaveRoom(roomId, playerId) {
1408
+ const room = this.rooms.get(roomId);
1409
+ if (!room) return;
1410
+ if (room.players[playerId]) {
1411
+ room.players[playerId].connected = false;
1412
+ }
1413
+ this.playerRoomMap.delete(playerId);
1414
+ const connectedPlayers = Object.values(room.players).filter((p) => p.connected);
1415
+ if (connectedPlayers.length === 0) {
1416
+ this.rooms.delete(roomId);
1417
+ console.log(`Room ${roomId} deleted - no players remaining`);
1418
+ }
1419
+ }
1420
+ makeMove(roomId, playerId, move) {
1421
+ const room = this.rooms.get(roomId);
1422
+ if (!room) return false;
1423
+ const player = room.players[playerId];
1424
+ if (!player) return false;
1425
+ if (room.gameState.gameStatus !== "playing") {
1426
+ return false;
1427
+ }
1428
+ const expectedPlayer = player.color === "black" ? StoneType.BLACK : StoneType.WHITE;
1429
+ const currentPlayer = room.game.getCurrentPlayer();
1430
+ if (currentPlayer !== expectedPlayer) {
1431
+ return false;
1432
+ }
1433
+ const position = { x: move.x, y: move.y, z: move.z };
1434
+ const success = room.game.drop(position);
1435
+ return success;
1436
+ }
1437
+ passTurn(roomId, playerId) {
1438
+ const room = this.rooms.get(roomId);
1439
+ if (!room) return false;
1440
+ const player = room.players[playerId];
1441
+ if (!player) return false;
1442
+ if (room.gameState.gameStatus !== "playing") {
1443
+ return false;
1444
+ }
1445
+ const expectedPlayer = player.color === "black" ? StoneType.BLACK : StoneType.WHITE;
1446
+ const currentPlayer = room.game.getCurrentPlayer();
1447
+ if (currentPlayer !== expectedPlayer) {
1448
+ return false;
1449
+ }
1450
+ return room.game.pass();
1451
+ }
1452
+ resign(roomId, playerId) {
1453
+ const room = this.rooms.get(roomId);
1454
+ if (!room) return false;
1455
+ const player = room.players[playerId];
1456
+ if (!player) return false;
1457
+ room.game.surrender();
1458
+ room.gameState.gameStatus = "finished";
1459
+ room.gameState.winner = player.color === "black" ? "white" : "black";
1460
+ return true;
1461
+ }
1462
+ /**
1463
+ * Undo the last move (悔棋)
1464
+ */
1465
+ undoMove(roomId, playerId) {
1466
+ const room = this.rooms.get(roomId);
1467
+ if (!room) return false;
1468
+ const player = room.players[playerId];
1469
+ if (!player) return false;
1470
+ if (room.gameState.gameStatus !== "playing") {
1471
+ return false;
1472
+ }
1473
+ return room.game.undo();
1474
+ }
1475
+ /**
1476
+ * Redo the last undone move (forward in history)
1477
+ */
1478
+ redoMove(roomId, playerId) {
1479
+ const room = this.rooms.get(roomId);
1480
+ if (!room) return false;
1481
+ const player = room.players[playerId];
1482
+ if (!player) return false;
1483
+ if (room.gameState.gameStatus !== "playing") {
1484
+ return false;
1485
+ }
1486
+ return room.game.redo();
1487
+ }
1488
+ /**
1489
+ * Reset the game to initial state (new game in same room)
1490
+ * Only admin can reset the game
1491
+ */
1492
+ resetGame(roomId, adminId, options) {
1493
+ const room = this.rooms.get(roomId);
1494
+ if (!room) return { success: false, error: "Room not found" };
1495
+ if (room.adminId !== adminId) {
1496
+ return { success: false, error: "Only room admin can reset the game" };
1497
+ }
1498
+ const boardShape = options?.boardShape;
1499
+ const playerColors = options?.playerColors;
1500
+ if (playerColors) {
1501
+ const playerIds = Object.keys(room.players);
1502
+ for (const playerId of playerIds) {
1503
+ if (playerColors[playerId]) {
1504
+ room.players[playerId].color = playerColors[playerId];
1505
+ }
1506
+ }
1507
+ console.log(`Player colors assigned:`, playerColors);
1508
+ }
1509
+ if (boardShape) {
1510
+ const currentShape = room.game.getShape();
1511
+ if (boardShape.x !== currentShape.x || boardShape.y !== currentShape.y || boardShape.z !== currentShape.z) {
1512
+ room.game = new TrigoGame(boardShape, {
1513
+ onStepAdvance: (_step, history) => {
1514
+ console.log(`Step ${history.length}: Player made move`);
1515
+ },
1516
+ onCapture: (captured) => {
1517
+ console.log(`Captured ${captured.length} stones`);
1518
+ },
1519
+ onWin: (winner) => {
1520
+ console.log(`Game won by ${winner}`);
1521
+ }
1522
+ });
1523
+ console.log(`Game ${roomId} reset with new board shape: ${boardShape.x}x${boardShape.y}x${boardShape.z}`);
1524
+ } else {
1525
+ room.game.reset();
1526
+ console.log(`Game ${roomId} reset to initial state`);
1527
+ }
1528
+ } else {
1529
+ room.game.reset();
1530
+ console.log(`Game ${roomId} reset to initial state`);
1531
+ }
1532
+ room.gameState.gameStatus = "playing";
1533
+ room.gameState.winner = null;
1534
+ room.startedAt = /* @__PURE__ */ new Date();
1535
+ return { success: true };
1536
+ }
1537
+ /**
1538
+ * Get game board state for a room
1539
+ */
1540
+ getGameBoard(roomId) {
1541
+ const room = this.rooms.get(roomId);
1542
+ if (!room) return null;
1543
+ return room.game.getBoard();
1544
+ }
1545
+ /**
1546
+ * Get game statistics for a room
1547
+ */
1548
+ getGameStats(roomId) {
1549
+ const room = this.rooms.get(roomId);
1550
+ if (!room) return null;
1551
+ return room.game.getStats();
1552
+ }
1553
+ /**
1554
+ * Get current player for a room
1555
+ */
1556
+ getCurrentPlayer(roomId) {
1557
+ const room = this.rooms.get(roomId);
1558
+ if (!room) return null;
1559
+ const currentStone = room.game.getCurrentPlayer();
1560
+ return currentStone === StoneType.BLACK ? "black" : "white";
1561
+ }
1562
+ /**
1563
+ * Calculate and get territory for a room
1564
+ */
1565
+ getTerritory(roomId) {
1566
+ const room = this.rooms.get(roomId);
1567
+ if (!room) return null;
1568
+ return room.game.getTerritory();
1569
+ }
1570
+ /**
1571
+ * End the game and determine winner based on territory
1572
+ */
1573
+ endGameByTerritory(roomId) {
1574
+ const room = this.rooms.get(roomId);
1575
+ if (!room) return false;
1576
+ if (room.gameState.gameStatus !== "playing") {
1577
+ return false;
1578
+ }
1579
+ const territory = room.game.getTerritory();
1580
+ if (territory.black > territory.white) {
1581
+ room.gameState.winner = "black";
1582
+ } else if (territory.white > territory.black) {
1583
+ room.gameState.winner = "white";
1584
+ } else {
1585
+ room.gameState.winner = null;
1586
+ }
1587
+ room.gameState.gameStatus = "finished";
1588
+ console.log(
1589
+ `Game ${roomId} ended. Black: ${territory.black}, White: ${territory.white}, Winner: ${room.gameState.winner}`
1590
+ );
1591
+ return true;
1592
+ }
1593
+ /**
1594
+ * Check if both players passed consecutively (game should end)
1595
+ * Returns true if game was ended
1596
+ */
1597
+ checkConsecutivePasses(roomId) {
1598
+ const room = this.rooms.get(roomId);
1599
+ if (!room) return false;
1600
+ const history = room.game.getHistory();
1601
+ if (history.length < 2) return false;
1602
+ const lastMove = history[history.length - 1];
1603
+ const secondLastMove = history[history.length - 2];
1604
+ if (lastMove.type === 1 /* PASS */ && secondLastMove.type === 1 /* PASS */) {
1605
+ this.endGameByTerritory(roomId);
1606
+ return true;
1607
+ }
1608
+ return false;
1609
+ }
1610
+ getRoom(roomId) {
1611
+ return this.rooms.get(roomId);
1612
+ }
1613
+ getPlayerRoom(playerId) {
1614
+ const roomId = this.playerRoomMap.get(playerId);
1615
+ if (!roomId) return void 0;
1616
+ return this.rooms.get(roomId);
1617
+ }
1618
+ getActiveRooms() {
1619
+ return Array.from(this.rooms.values()).filter(
1620
+ (room) => room.gameState.gameStatus !== "finished"
1621
+ );
1622
+ }
1623
+ generateRoomId() {
1624
+ return uuidv4().substring(0, 8).toUpperCase();
1625
+ }
1626
+ };
1627
+
1628
+ // backend/src/sockets/gameSocket.ts
1629
+ function getRoomSummary(room) {
1630
+ const connectedPlayers = Object.values(room.players).filter((p) => p.connected);
1631
+ return {
1632
+ id: room.id,
1633
+ playerCount: connectedPlayers.length,
1634
+ maxPlayers: 2,
1635
+ status: room.gameState.gameStatus,
1636
+ isFull: connectedPlayers.length >= 2,
1637
+ createdAt: room.createdAt.toISOString()
1638
+ };
1639
+ }
1640
+ function setupSocketHandlers(io2, socket, gameManager2) {
1641
+ console.log(`Setting up socket handlers for ${socket.id}`);
1642
+ socket.on("listRooms", (callback) => {
1643
+ const rooms = gameManager2.getActiveRooms();
1644
+ const roomList = rooms.map((room) => getRoomSummary(room));
1645
+ if (callback) {
1646
+ callback({ success: true, rooms: roomList });
1647
+ }
1648
+ });
1649
+ socket.on(
1650
+ "joinRoom",
1651
+ (data, callback) => {
1652
+ console.log("[gameSocket] joinRoom event received:", {
1653
+ roomId: data.roomId,
1654
+ nickname: data.nickname,
1655
+ preferredColor: data.preferredColor,
1656
+ hasCallback: !!callback,
1657
+ socketId: socket.id
1658
+ });
1659
+ const { roomId, nickname, preferredColor } = data;
1660
+ try {
1661
+ let room;
1662
+ if (roomId) {
1663
+ const existingRoom = gameManager2.getRoom(roomId);
1664
+ if (!existingRoom) {
1665
+ if (callback) {
1666
+ callback({
1667
+ success: false,
1668
+ error: "Room not found",
1669
+ errorCode: "ROOM_NOT_FOUND"
1670
+ });
1671
+ }
1672
+ return;
1673
+ }
1674
+ const playerCount = Object.keys(existingRoom.players).length;
1675
+ if (playerCount >= 2) {
1676
+ if (callback) {
1677
+ callback({
1678
+ success: false,
1679
+ error: "Room is full",
1680
+ errorCode: "ROOM_FULL"
1681
+ });
1682
+ }
1683
+ return;
1684
+ }
1685
+ room = gameManager2.joinRoom(roomId, socket.id, nickname, preferredColor);
1686
+ } else {
1687
+ room = gameManager2.createRoom(socket.id, nickname, void 0, preferredColor);
1688
+ }
1689
+ if (room) {
1690
+ socket.join(room.id);
1691
+ const roomSockets = io2.sockets.adapter.rooms.get(room.id);
1692
+ console.log(`[gameSocket] Socket ${socket.id} joined room ${room.id}`);
1693
+ console.log(`[gameSocket] Room ${room.id} now has sockets:`, roomSockets ? Array.from(roomSockets) : []);
1694
+ const currentPlayer = gameManager2.getCurrentPlayer(room.id);
1695
+ const stats = gameManager2.getGameStats(room.id);
1696
+ const tgn = room.game.toTGN();
1697
+ const players = {};
1698
+ for (const [pid, player] of Object.entries(room.players)) {
1699
+ players[pid] = {
1700
+ nickname: player.nickname,
1701
+ color: player.color
1702
+ };
1703
+ }
1704
+ const response = {
1705
+ success: true,
1706
+ roomId: room.id,
1707
+ playerId: socket.id,
1708
+ playerColor: room.players[socket.id]?.color,
1709
+ isAdmin: room.adminId === socket.id,
1710
+ adminId: room.adminId,
1711
+ players,
1712
+ // Include all players in room
1713
+ gameState: {
1714
+ boardShape: room.game.getShape(),
1715
+ currentPlayer,
1716
+ currentMoveIndex: room.game.getCurrentStep(),
1717
+ capturedStones: {
1718
+ black: stats?.capturedByBlack || 0,
1719
+ white: stats?.capturedByWhite || 0
1720
+ },
1721
+ gameStatus: room.gameState.gameStatus,
1722
+ winner: room.gameState.winner,
1723
+ tgn
1724
+ }
1725
+ };
1726
+ if (callback) {
1727
+ console.log("[gameSocket] Sending response via callback:", {
1728
+ roomId: response.roomId,
1729
+ playerColor: response.playerColor
1730
+ });
1731
+ callback(response);
1732
+ } else {
1733
+ console.log("[gameSocket] No callback, using roomJoined emit");
1734
+ socket.emit("roomJoined", response);
1735
+ }
1736
+ console.log(`[gameSocket] Broadcasting playerJoined to room ${room.id} (excluding ${socket.id})`);
1737
+ socket.to(room.id).emit("playerJoined", {
1738
+ playerId: socket.id,
1739
+ nickname
1740
+ });
1741
+ console.log(`[gameSocket] playerJoined broadcast sent`);
1742
+ const roomSummary = getRoomSummary(room);
1743
+ if (roomId) {
1744
+ io2.emit("roomUpdated", roomSummary);
1745
+ } else {
1746
+ io2.emit("roomCreated", roomSummary);
1747
+ }
1748
+ console.log(
1749
+ `Player ${socket.id} ${roomId ? "joined" : "created"} room ${room.id}`
1750
+ );
1751
+ } else {
1752
+ if (callback) {
1753
+ callback({
1754
+ success: false,
1755
+ error: "Failed to join or create room",
1756
+ errorCode: "UNKNOWN_ERROR"
1757
+ });
1758
+ }
1759
+ }
1760
+ } catch (error) {
1761
+ console.error(`Error in joinRoom handler:`, error);
1762
+ if (callback) {
1763
+ callback({
1764
+ success: false,
1765
+ error: "Server error",
1766
+ errorCode: "SERVER_ERROR"
1767
+ });
1768
+ }
1769
+ }
1770
+ }
1771
+ );
1772
+ socket.on("leaveRoom", () => {
1773
+ const room = gameManager2.getPlayerRoom(socket.id);
1774
+ if (room) {
1775
+ const roomId = room.id;
1776
+ socket.leave(room.id);
1777
+ gameManager2.leaveRoom(room.id, socket.id);
1778
+ socket.to(roomId).emit("playerLeft", {
1779
+ playerId: socket.id
1780
+ });
1781
+ const updatedRoom = gameManager2.getRoom(roomId);
1782
+ if (updatedRoom) {
1783
+ io2.emit("roomUpdated", getRoomSummary(updatedRoom));
1784
+ } else {
1785
+ io2.emit("roomDeleted", { roomId });
1786
+ }
1787
+ }
1788
+ });
1789
+ socket.on("makeMove", (data) => {
1790
+ const room = gameManager2.getPlayerRoom(socket.id);
1791
+ if (room && gameManager2.makeMove(room.id, socket.id, data)) {
1792
+ const currentPlayer = gameManager2.getCurrentPlayer(room.id);
1793
+ const stats = gameManager2.getGameStats(room.id);
1794
+ const lastStep = room.game.getLastStep();
1795
+ const tgn = room.game.toTGN();
1796
+ io2.to(room.id).emit("gameUpdate", {
1797
+ currentPlayer,
1798
+ action: "move",
1799
+ lastMove: data,
1800
+ capturedStones: {
1801
+ black: stats?.capturedByBlack || 0,
1802
+ white: stats?.capturedByWhite || 0
1803
+ },
1804
+ capturedPositions: lastStep?.capturedPositions,
1805
+ currentMoveIndex: room.game.getCurrentStep(),
1806
+ tgn
1807
+ });
1808
+ } else {
1809
+ socket.emit("error", { message: "Invalid move" });
1810
+ }
1811
+ });
1812
+ socket.on("pass", () => {
1813
+ const room = gameManager2.getPlayerRoom(socket.id);
1814
+ if (room && gameManager2.passTurn(room.id, socket.id)) {
1815
+ const currentPlayer = gameManager2.getCurrentPlayer(room.id);
1816
+ const tgn = room.game.toTGN();
1817
+ io2.to(room.id).emit("gameUpdate", {
1818
+ currentPlayer,
1819
+ action: "pass",
1820
+ currentMoveIndex: room.game.getCurrentStep(),
1821
+ tgn
1822
+ });
1823
+ if (gameManager2.checkConsecutivePasses(room.id)) {
1824
+ const territory = gameManager2.getTerritory(room.id);
1825
+ io2.to(room.id).emit("gameEnded", {
1826
+ winner: room.gameState.winner,
1827
+ reason: "double-pass",
1828
+ territory
1829
+ });
1830
+ }
1831
+ }
1832
+ });
1833
+ socket.on("resign", () => {
1834
+ const room = gameManager2.getPlayerRoom(socket.id);
1835
+ if (room && gameManager2.resign(room.id, socket.id)) {
1836
+ io2.to(room.id).emit("gameEnded", {
1837
+ winner: room.gameState.winner,
1838
+ reason: "resignation"
1839
+ });
1840
+ }
1841
+ });
1842
+ socket.on("undoMove", (callback) => {
1843
+ const room = gameManager2.getPlayerRoom(socket.id);
1844
+ if (!room) {
1845
+ if (callback) callback({ success: false, error: "Not in a room", errorCode: "NOT_IN_ROOM" });
1846
+ return;
1847
+ }
1848
+ if (room.gameState.gameStatus !== "playing") {
1849
+ if (callback) callback({ success: false, error: "Game not active", errorCode: "GAME_NOT_ACTIVE" });
1850
+ return;
1851
+ }
1852
+ const success = gameManager2.undoMove(room.id, socket.id);
1853
+ if (success) {
1854
+ const currentPlayer = gameManager2.getCurrentPlayer(room.id);
1855
+ const stats = gameManager2.getGameStats(room.id);
1856
+ const tgn = room.game.toTGN();
1857
+ io2.to(room.id).emit("gameUpdate", {
1858
+ currentPlayer,
1859
+ action: "undo",
1860
+ currentMoveIndex: room.game.getCurrentStep(),
1861
+ capturedStones: {
1862
+ black: stats?.capturedByBlack || 0,
1863
+ white: stats?.capturedByWhite || 0
1864
+ },
1865
+ tgn
1866
+ });
1867
+ if (callback) callback({ success: true });
1868
+ } else {
1869
+ if (callback) callback({ success: false, error: "Cannot undo", errorCode: "UNDO_FAILED" });
1870
+ }
1871
+ });
1872
+ socket.on("redoMove", (callback) => {
1873
+ const room = gameManager2.getPlayerRoom(socket.id);
1874
+ if (!room) {
1875
+ if (callback) callback({ success: false, error: "Not in a room", errorCode: "NOT_IN_ROOM" });
1876
+ return;
1877
+ }
1878
+ if (room.gameState.gameStatus !== "playing") {
1879
+ if (callback) callback({ success: false, error: "Game not active", errorCode: "GAME_NOT_ACTIVE" });
1880
+ return;
1881
+ }
1882
+ if (!room.game.canRedo()) {
1883
+ if (callback) callback({ success: false, error: "Nothing to redo", errorCode: "NOTHING_TO_REDO" });
1884
+ return;
1885
+ }
1886
+ const success = gameManager2.redoMove(room.id, socket.id);
1887
+ if (success) {
1888
+ const currentPlayer = gameManager2.getCurrentPlayer(room.id);
1889
+ const stats = gameManager2.getGameStats(room.id);
1890
+ const lastStep = room.game.getLastStep();
1891
+ const tgn = room.game.toTGN();
1892
+ io2.to(room.id).emit("gameUpdate", {
1893
+ currentPlayer,
1894
+ action: "redo",
1895
+ lastMove: lastStep?.position,
1896
+ capturedStones: {
1897
+ black: stats?.capturedByBlack || 0,
1898
+ white: stats?.capturedByWhite || 0
1899
+ },
1900
+ capturedPositions: lastStep?.capturedPositions,
1901
+ currentMoveIndex: room.game.getCurrentStep(),
1902
+ tgn
1903
+ });
1904
+ if (callback) callback({ success: true });
1905
+ } else {
1906
+ if (callback) callback({ success: false, error: "Redo failed", errorCode: "REDO_FAILED" });
1907
+ }
1908
+ });
1909
+ socket.on("resetGame", (data, callback) => {
1910
+ const room = gameManager2.getPlayerRoom(socket.id);
1911
+ if (!room) {
1912
+ const cb = typeof data === "function" ? data : callback;
1913
+ if (cb) cb({ success: false, error: "Not in a room", errorCode: "NOT_IN_ROOM" });
1914
+ return;
1915
+ }
1916
+ const options = typeof data === "object" && data !== null ? {
1917
+ boardShape: data.boardShape,
1918
+ playerColors: data.playerColors
1919
+ } : void 0;
1920
+ const responseCb = typeof data === "function" ? data : callback;
1921
+ const result = gameManager2.resetGame(room.id, socket.id, options);
1922
+ if (result.success) {
1923
+ const currentPlayer = gameManager2.getCurrentPlayer(room.id);
1924
+ const tgn = room.game.toTGN();
1925
+ const players = {};
1926
+ for (const [pid, player] of Object.entries(room.players)) {
1927
+ players[pid] = {
1928
+ nickname: player.nickname,
1929
+ color: player.color
1930
+ };
1931
+ }
1932
+ io2.to(room.id).emit("gameReset", {
1933
+ currentPlayer,
1934
+ boardShape: room.game.getShape(),
1935
+ currentMoveIndex: 0,
1936
+ capturedStones: { black: 0, white: 0 },
1937
+ players,
1938
+ tgn
1939
+ });
1940
+ if (responseCb) responseCb({ success: true });
1941
+ } else {
1942
+ if (responseCb) responseCb({
1943
+ success: false,
1944
+ error: result.error || "Reset failed",
1945
+ errorCode: result.error === "Only room admin can reset the game" ? "NOT_ADMIN" : "RESET_FAILED"
1946
+ });
1947
+ }
1948
+ });
1949
+ socket.on("chatMessage", (data) => {
1950
+ const room = gameManager2.getPlayerRoom(socket.id);
1951
+ if (room) {
1952
+ const player = room.players[socket.id];
1953
+ io2.to(room.id).emit("chatMessage", {
1954
+ author: player?.nickname || "Unknown",
1955
+ content: data.content,
1956
+ playerId: socket.id
1957
+ });
1958
+ }
1959
+ });
1960
+ socket.on(
1961
+ "changeNickname",
1962
+ (data, callback) => {
1963
+ const room = gameManager2.getPlayerRoom(socket.id);
1964
+ if (!room) {
1965
+ const error = { success: false, error: "Not in a room" };
1966
+ if (callback) callback(error);
1967
+ return;
1968
+ }
1969
+ const validation = validateNickname(data.nickname);
1970
+ if (!validation.valid) {
1971
+ const error = { success: false, error: validation.error };
1972
+ if (callback) callback(error);
1973
+ return;
1974
+ }
1975
+ const player = room.players[socket.id];
1976
+ if (player) {
1977
+ const oldNickname = player.nickname;
1978
+ player.nickname = data.nickname;
1979
+ io2.to(room.id).emit("nicknameChanged", {
1980
+ playerId: socket.id,
1981
+ nickname: data.nickname,
1982
+ oldNickname
1983
+ });
1984
+ console.log(`Player ${socket.id} changed nickname: ${oldNickname} -> ${data.nickname}`);
1985
+ if (callback) {
1986
+ callback({ success: true, nickname: data.nickname });
1987
+ }
1988
+ }
1989
+ }
1990
+ );
1991
+ socket.on("disconnect", () => {
1992
+ console.log(`Client disconnected: ${socket.id}`);
1993
+ const room = gameManager2.getPlayerRoom(socket.id);
1994
+ if (room) {
1995
+ const roomId = room.id;
1996
+ gameManager2.leaveRoom(room.id, socket.id);
1997
+ socket.to(room.id).emit("playerDisconnected", {
1998
+ playerId: socket.id
1999
+ });
2000
+ const updatedRoom = gameManager2.getRoom(roomId);
2001
+ if (updatedRoom) {
2002
+ io2.emit("roomUpdated", getRoomSummary(updatedRoom));
2003
+ } else {
2004
+ io2.emit("roomDeleted", { roomId });
2005
+ }
2006
+ }
2007
+ });
2008
+ }
2009
+ function validateNickname(nickname) {
2010
+ if (!nickname || typeof nickname !== "string") {
2011
+ return { valid: false, error: "Invalid nickname" };
2012
+ }
2013
+ const trimmed = nickname.trim();
2014
+ if (trimmed.length < 3) {
2015
+ return { valid: false, error: "Nickname must be at least 3 characters" };
2016
+ }
2017
+ if (trimmed.length > 20) {
2018
+ return { valid: false, error: "Nickname must be 20 characters or less" };
2019
+ }
2020
+ if (!/^[a-zA-Z0-9 ]+$/.test(trimmed)) {
2021
+ return { valid: false, error: "Only letters, numbers, and spaces allowed" };
2022
+ }
2023
+ if (trimmed !== nickname) {
2024
+ return { valid: false, error: "No leading or trailing spaces allowed" };
2025
+ }
2026
+ return { valid: true };
2027
+ }
2028
+
2029
+ // backend/src/server.ts
2030
+ var __filename = fileURLToPath(import.meta.url);
2031
+ var __dirname = path.dirname(__filename);
2032
+ var isDev = __dirname.includes("/src") && !__dirname.includes("/dist");
2033
+ var levelsUp = isDev ? "../" : "../../../";
2034
+ var envPath = path.join(__dirname, levelsUp, ".env");
2035
+ var envLocalPath = path.join(__dirname, levelsUp, ".env.local");
2036
+ if (fs.existsSync(envPath)) {
2037
+ dotenv.config({ path: envPath });
2038
+ console.log("[Config] Loaded .env");
2039
+ } else {
2040
+ console.log(`[Config] .env not found at: ${envPath}`);
2041
+ }
2042
+ if (fs.existsSync(envLocalPath)) {
2043
+ dotenv.config({ path: envLocalPath, override: true });
2044
+ console.log("[Config] Loaded .env.local (overriding .env)");
2045
+ } else {
2046
+ console.log(`[Config] .env.local not found at: ${envLocalPath}`);
2047
+ }
2048
+ var app = express();
2049
+ var httpServer = createServer(app);
2050
+ var io = new Server(httpServer, {
2051
+ cors: {
2052
+ origin: process.env.NODE_ENV === "production" ? process.env.CLIENT_URL || "http://localhost:5173" : true,
2053
+ // Allow all origins in development
2054
+ methods: ["GET", "POST"],
2055
+ credentials: true
2056
+ }
2057
+ });
2058
+ var gameManager = new GameManager();
2059
+ var PORT = parseInt(process.env.PORT || "3000", 10);
2060
+ var HOST = process.env.HOST || "0.0.0.0";
2061
+ console.log(`[Config] Server Configuration:`);
2062
+ console.log(`[Config] PORT: ${PORT}`);
2063
+ console.log(`[Config] HOST: ${HOST}`);
2064
+ console.log(`[Config] NODE_ENV: ${process.env.NODE_ENV || "development"}`);
2065
+ console.log(`[Config] CLIENT_URL: ${process.env.CLIENT_URL || "not set"}`);
2066
+ app.use(cors());
2067
+ app.use(express.json());
2068
+ if (process.env.NODE_ENV === "production") {
2069
+ const frontendPath = path.join(__dirname, "../../../../app/dist");
2070
+ app.use(express.static(frontendPath));
2071
+ app.get("*", (req, res, next) => {
2072
+ if (req.path.startsWith("/health") || req.path.startsWith("/socket.io")) {
2073
+ return next();
2074
+ }
2075
+ res.sendFile(path.join(frontendPath, "index.html"));
2076
+ });
2077
+ }
2078
+ app.get("/health", (_req, res) => {
2079
+ res.json({ status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() });
2080
+ });
2081
+ io.on("connection", (socket) => {
2082
+ console.log(`New client connected: ${socket.id}`);
2083
+ setupSocketHandlers(io, socket, gameManager);
2084
+ socket.on("echo", (data, callback) => {
2085
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2086
+ const responseMessage = `Hello from server! Received: "${data.message}" at ${timestamp}`;
2087
+ console.log(`[Echo] Client ${socket.id}: ${data.message}`);
2088
+ if (callback && typeof callback === "function") {
2089
+ callback({
2090
+ message: responseMessage,
2091
+ serverTime: timestamp,
2092
+ clientTime: data.timestamp
2093
+ });
2094
+ }
2095
+ });
2096
+ socket.on("disconnect", () => {
2097
+ console.log(`Client disconnected: ${socket.id}`);
2098
+ });
2099
+ });
2100
+ httpServer.listen(PORT, HOST, () => {
2101
+ console.log(`Server running on ${HOST}:${PORT}`);
2102
+ console.log(`Health check: http://${HOST}:${PORT}/health`);
2103
+ console.log(`Environment: ${process.env.NODE_ENV || "development"}`);
2104
+ });
trigo-web/backend/dist/server.js ADDED
@@ -0,0 +1,2104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // backend/src/server.ts
2
+ import express from "express";
3
+ import { createServer } from "http";
4
+ import { Server } from "socket.io";
5
+ import cors from "cors";
6
+ import dotenv from "dotenv";
7
+ import path from "path";
8
+ import fs from "fs";
9
+ import { fileURLToPath } from "url";
10
+
11
+ // backend/src/services/gameManager.ts
12
+ import { v4 as uuidv4 } from "uuid";
13
+
14
+ // inc/trigo/gameUtils.ts
15
+ var StoneType = {
16
+ EMPTY: 0,
17
+ BLACK: 1,
18
+ WHITE: 2
19
+ };
20
+ function getEnemyColor(color) {
21
+ if (color === StoneType.BLACK) return StoneType.WHITE;
22
+ if (color === StoneType.WHITE) return StoneType.BLACK;
23
+ return StoneType.EMPTY;
24
+ }
25
+ function isInBounds(pos, shape) {
26
+ return pos.x >= 0 && pos.x < shape.x && pos.y >= 0 && pos.y < shape.y && pos.z >= 0 && pos.z < shape.z;
27
+ }
28
+ function getNeighbors(pos, shape) {
29
+ const neighbors = [];
30
+ const directions = [
31
+ { x: 1, y: 0, z: 0 },
32
+ { x: -1, y: 0, z: 0 },
33
+ { x: 0, y: 1, z: 0 },
34
+ { x: 0, y: -1, z: 0 },
35
+ { x: 0, y: 0, z: 1 },
36
+ { x: 0, y: 0, z: -1 }
37
+ ];
38
+ for (const dir of directions) {
39
+ const neighbor = {
40
+ x: pos.x + dir.x,
41
+ y: pos.y + dir.y,
42
+ z: pos.z + dir.z
43
+ };
44
+ if (isInBounds(neighbor, shape)) {
45
+ neighbors.push(neighbor);
46
+ }
47
+ }
48
+ return neighbors;
49
+ }
50
+ function positionsEqual(p1, p2) {
51
+ return p1.x === p2.x && p1.y === p2.y && p1.z === p2.z;
52
+ }
53
+ var CoordSet = class {
54
+ constructor() {
55
+ this.positions = [];
56
+ }
57
+ has(pos) {
58
+ return this.positions.some((p) => positionsEqual(p, pos));
59
+ }
60
+ insert(pos) {
61
+ if (!this.has(pos)) {
62
+ this.positions.push(pos);
63
+ return true;
64
+ }
65
+ return false;
66
+ }
67
+ remove(pos) {
68
+ this.positions = this.positions.filter((p) => !positionsEqual(p, pos));
69
+ }
70
+ size() {
71
+ return this.positions.length;
72
+ }
73
+ empty() {
74
+ return this.positions.length === 0;
75
+ }
76
+ forEach(callback) {
77
+ this.positions.forEach(callback);
78
+ }
79
+ toArray() {
80
+ return [...this.positions];
81
+ }
82
+ clear() {
83
+ this.positions = [];
84
+ }
85
+ };
86
+ var Patch = class {
87
+ constructor(color = StoneType.EMPTY) {
88
+ this.positions = new CoordSet();
89
+ this.color = StoneType.EMPTY;
90
+ this.color = color;
91
+ }
92
+ addStone(pos) {
93
+ this.positions.insert(pos);
94
+ }
95
+ size() {
96
+ return this.positions.size();
97
+ }
98
+ /**
99
+ * Get all liberties (empty adjacent positions) for this group
100
+ *
101
+ * Equivalent to StoneArray.patchAir() in prototype
102
+ * Returns a CoordSet of empty positions adjacent to this patch
103
+ */
104
+ getLiberties(board, shape) {
105
+ const liberties = new CoordSet();
106
+ this.positions.forEach((stonePos) => {
107
+ const neighbors = getNeighbors(stonePos, shape);
108
+ for (const neighbor of neighbors) {
109
+ if (board[neighbor.x][neighbor.y][neighbor.z] === StoneType.EMPTY) {
110
+ liberties.insert(neighbor);
111
+ }
112
+ }
113
+ });
114
+ return liberties;
115
+ }
116
+ };
117
+ function findGroup(pos, board, shape) {
118
+ const color = board[pos.x][pos.y][pos.z];
119
+ const group = new Patch(color);
120
+ if (color === StoneType.EMPTY) {
121
+ return group;
122
+ }
123
+ const visited = new CoordSet();
124
+ const stack = [pos];
125
+ while (stack.length > 0) {
126
+ const current = stack.pop();
127
+ if (visited.has(current)) {
128
+ continue;
129
+ }
130
+ visited.insert(current);
131
+ if (board[current.x][current.y][current.z] === color) {
132
+ group.addStone(current);
133
+ const neighbors = getNeighbors(current, shape);
134
+ for (const neighbor of neighbors) {
135
+ if (!visited.has(neighbor)) {
136
+ stack.push(neighbor);
137
+ }
138
+ }
139
+ }
140
+ }
141
+ return group;
142
+ }
143
+ function getNeighborGroups(pos, board, shape, excludeEmpty = false) {
144
+ const neighbors = getNeighbors(pos, shape);
145
+ const groups = [];
146
+ const processedPositions = new CoordSet();
147
+ for (const neighbor of neighbors) {
148
+ if (processedPositions.has(neighbor)) {
149
+ continue;
150
+ }
151
+ const stone = board[neighbor.x][neighbor.y][neighbor.z];
152
+ if (excludeEmpty && stone === StoneType.EMPTY) {
153
+ continue;
154
+ }
155
+ if (stone !== StoneType.EMPTY) {
156
+ const group = findGroup(neighbor, board, shape);
157
+ group.positions.forEach((p) => processedPositions.insert(p));
158
+ groups.push(group);
159
+ }
160
+ }
161
+ return groups;
162
+ }
163
+ function isGroupCaptured(group, board, shape) {
164
+ const liberties = group.getLiberties(board, shape);
165
+ return liberties.size() === 0;
166
+ }
167
+ function findCapturedGroups(pos, playerColor, board, shape) {
168
+ const enemyColor = getEnemyColor(playerColor);
169
+ const captured = [];
170
+ const tempBoard = board.map((plane) => plane.map((row) => [...row]));
171
+ tempBoard[pos.x][pos.y][pos.z] = playerColor;
172
+ const neighborGroups = getNeighborGroups(pos, tempBoard, shape, true);
173
+ for (const group of neighborGroups) {
174
+ if (group.color === enemyColor) {
175
+ if (isGroupCaptured(group, tempBoard, shape)) {
176
+ captured.push(group);
177
+ }
178
+ }
179
+ }
180
+ return captured;
181
+ }
182
+ function isSuicideMove(pos, playerColor, board, shape) {
183
+ const tempBoard = board.map((plane) => plane.map((row) => [...row]));
184
+ tempBoard[pos.x][pos.y][pos.z] = playerColor;
185
+ const capturedGroups = findCapturedGroups(pos, playerColor, board, shape);
186
+ if (capturedGroups.length > 0) {
187
+ return false;
188
+ }
189
+ const placedGroup = findGroup(pos, tempBoard, shape);
190
+ const liberties = placedGroup.getLiberties(tempBoard, shape);
191
+ return liberties.size() === 0;
192
+ }
193
+ function isKoViolation(pos, playerColor, board, shape, lastCapturedPositions) {
194
+ if (!lastCapturedPositions || lastCapturedPositions.length !== 1) {
195
+ return false;
196
+ }
197
+ const capturedGroups = findCapturedGroups(pos, playerColor, board, shape);
198
+ if (capturedGroups.length !== 1 || capturedGroups[0].size() !== 1) {
199
+ return false;
200
+ }
201
+ const previouslyCaptured = lastCapturedPositions[0];
202
+ if (positionsEqual(pos, previouslyCaptured)) {
203
+ return true;
204
+ }
205
+ return false;
206
+ }
207
+ function executeCaptures(capturedGroups, board) {
208
+ const capturedPositions = [];
209
+ for (const group of capturedGroups) {
210
+ group.positions.forEach((pos) => {
211
+ board[pos.x][pos.y][pos.z] = StoneType.EMPTY;
212
+ capturedPositions.push(pos);
213
+ });
214
+ }
215
+ return capturedPositions;
216
+ }
217
+ function findEmptyRegion(startPos, board, shape, visited) {
218
+ const region = new CoordSet();
219
+ const stack = [startPos];
220
+ while (stack.length > 0) {
221
+ const pos = stack.pop();
222
+ if (visited.has(pos)) {
223
+ continue;
224
+ }
225
+ visited.insert(pos);
226
+ if (board[pos.x][pos.y][pos.z] === StoneType.EMPTY) {
227
+ region.insert(pos);
228
+ const neighbors = getNeighbors(pos, shape);
229
+ for (const neighbor of neighbors) {
230
+ if (!visited.has(neighbor)) {
231
+ stack.push(neighbor);
232
+ }
233
+ }
234
+ }
235
+ }
236
+ return region;
237
+ }
238
+ function determineRegionOwner(region, board, shape) {
239
+ let owner = StoneType.EMPTY;
240
+ let solved = false;
241
+ region.forEach((pos) => {
242
+ if (solved) return;
243
+ const neighbors = getNeighbors(pos, shape);
244
+ for (const neighbor of neighbors) {
245
+ if (solved) break;
246
+ const stone = board[neighbor.x][neighbor.y][neighbor.z];
247
+ if (stone !== StoneType.EMPTY) {
248
+ if (owner === StoneType.EMPTY) {
249
+ owner = stone;
250
+ } else if (owner !== stone) {
251
+ owner = StoneType.EMPTY;
252
+ solved = true;
253
+ }
254
+ }
255
+ }
256
+ });
257
+ return owner;
258
+ }
259
+ function calculateTerritory(board, shape) {
260
+ const result = {
261
+ black: 0,
262
+ white: 0,
263
+ neutral: 0,
264
+ blackTerritory: [],
265
+ whiteTerritory: [],
266
+ neutralTerritory: []
267
+ };
268
+ const visited = new CoordSet();
269
+ const emptyRegions = [];
270
+ for (let x = 0; x < shape.x; x++) {
271
+ for (let y = 0; y < shape.y; y++) {
272
+ for (let z = 0; z < shape.z; z++) {
273
+ const pos = { x, y, z };
274
+ const stone = board[x][y][z];
275
+ if (stone === StoneType.BLACK) {
276
+ result.black++;
277
+ result.blackTerritory.push(pos);
278
+ } else if (stone === StoneType.WHITE) {
279
+ result.white++;
280
+ result.whiteTerritory.push(pos);
281
+ } else if (!visited.has(pos)) {
282
+ const region = findEmptyRegion(pos, board, shape, visited);
283
+ emptyRegions.push(region);
284
+ }
285
+ }
286
+ }
287
+ }
288
+ for (const region of emptyRegions) {
289
+ const owner = determineRegionOwner(region, board, shape);
290
+ const regionArray = region.toArray();
291
+ if (owner === StoneType.BLACK) {
292
+ result.black += region.size();
293
+ result.blackTerritory.push(...regionArray);
294
+ } else if (owner === StoneType.WHITE) {
295
+ result.white += region.size();
296
+ result.whiteTerritory.push(...regionArray);
297
+ } else {
298
+ result.neutral += region.size();
299
+ result.neutralTerritory.push(...regionArray);
300
+ }
301
+ }
302
+ return result;
303
+ }
304
+ function validateMove(pos, playerColor, board, shape, lastCapturedPositions = null) {
305
+ if (!isInBounds(pos, shape)) {
306
+ return { valid: false, reason: "Position out of bounds" };
307
+ }
308
+ if (board[pos.x][pos.y][pos.z] !== StoneType.EMPTY) {
309
+ return { valid: false, reason: "Position already occupied" };
310
+ }
311
+ if (isKoViolation(pos, playerColor, board, shape, lastCapturedPositions)) {
312
+ return { valid: false, reason: "Ko rule violation" };
313
+ }
314
+ if (isSuicideMove(pos, playerColor, board, shape)) {
315
+ return { valid: false, reason: "suicide move not allowed" };
316
+ }
317
+ return { valid: true };
318
+ }
319
+
320
+ // inc/trigo/ab0yz.ts
321
+ var compactShape = (shape) => shape[shape.length - 1] === 1 ? compactShape(shape.slice(0, shape.length - 1)) : shape;
322
+ var encodeAb0yz = (pos, boardShape) => {
323
+ const compactedShape = compactShape(boardShape);
324
+ const result = [];
325
+ for (let i = 0; i < compactedShape.length; i++) {
326
+ const size = compactedShape[i];
327
+ const center = (size - 1) / 2;
328
+ const index = pos[i];
329
+ if (index === center) {
330
+ result.push("0");
331
+ } else if (index < center) {
332
+ result.push(String.fromCharCode(97 + index));
333
+ } else {
334
+ const offset = size - 1 - index;
335
+ result.push(String.fromCharCode(122 - offset));
336
+ }
337
+ }
338
+ return result.join("");
339
+ };
340
+ var decodeAb0yz = (code, boardShape) => {
341
+ const compactedShape = compactShape(boardShape);
342
+ if (code.length !== compactedShape.length) {
343
+ throw new Error(
344
+ `Invalid TGN coordinate: "${code}" (must be ${compactedShape.length} characters for board shape ${boardShape.join("x")})`
345
+ );
346
+ }
347
+ const result = [];
348
+ for (let i = 0; i < compactedShape.length; i++) {
349
+ const char = code[i];
350
+ const size = compactedShape[i];
351
+ const center = (size - 1) / 2;
352
+ if (char === "0") {
353
+ console.assert(Number.isInteger(center));
354
+ result.push(center);
355
+ } else {
356
+ const charCode = char.charCodeAt(0);
357
+ if (charCode >= 97 && charCode <= 122) {
358
+ const distFromA = charCode - 97;
359
+ const distFromZ = 122 - charCode;
360
+ if (distFromA < distFromZ) {
361
+ const index = distFromA;
362
+ if (index >= center) {
363
+ throw new Error(
364
+ `Invalid TGN coordinate: "${code}" (position ${index} >= center ${center} on axis ${i})`
365
+ );
366
+ }
367
+ result.push(index);
368
+ } else {
369
+ const index = size - 1 - distFromZ;
370
+ if (index <= center) {
371
+ throw new Error(
372
+ `Invalid TGN coordinate: "${code}" (position ${index} <= center ${center} on axis ${i})`
373
+ );
374
+ }
375
+ result.push(index);
376
+ }
377
+ } else {
378
+ throw new Error(
379
+ `Invalid TGN coordinate: "${code}" (character '${char}' at position ${i} must be '0' or a-z)`
380
+ );
381
+ }
382
+ }
383
+ }
384
+ while (result.length < boardShape.length) {
385
+ result.push(0);
386
+ }
387
+ return result;
388
+ };
389
+
390
+ // inc/tgn/tgnParser.ts
391
+ var TGNParseError = class extends Error {
392
+ constructor(message, line, column, hash) {
393
+ super(message);
394
+ this.line = line;
395
+ this.column = column;
396
+ this.hash = hash;
397
+ this.name = "TGNParseError";
398
+ }
399
+ };
400
+ var parserModule = null;
401
+ function getParser() {
402
+ if (!parserModule) {
403
+ throw new Error(
404
+ "TGN parser not loaded. Please ensure the parser has been built.\nRun: npm run build:parsers"
405
+ );
406
+ }
407
+ return parserModule;
408
+ }
409
+ function parseTGN(tgnString) {
410
+ const parser = getParser();
411
+ if (!parser.parse) {
412
+ throw new Error("TGN parser parse method not available");
413
+ }
414
+ try {
415
+ const result = parser.parse(tgnString);
416
+ return result;
417
+ } catch (error) {
418
+ throw new TGNParseError(
419
+ error.message || "Unknown parse error",
420
+ error.hash?.line,
421
+ error.hash?.loc?.first_column,
422
+ error.hash
423
+ );
424
+ }
425
+ }
426
+
427
+ // inc/trigo/game.ts
428
+ var TrigoGame = class _TrigoGame {
429
+ /**
430
+ * Constructor
431
+ * Equivalent to trigo.Game constructor (lines 75-85)
432
+ */
433
+ constructor(shape = { x: 5, y: 5, z: 5 }, callbacks = {}) {
434
+ // Last captured stones for Ko rule detection
435
+ this.lastCapturedPositions = null;
436
+ // Static analysis cache (territory, capturing moves)
437
+ // Invalidated on any board state change
438
+ this.cachedTerritory = null;
439
+ this.cachedCapturingMove = /* @__PURE__ */ new Map();
440
+ this.shape = shape;
441
+ this.callbacks = callbacks;
442
+ this.board = this.createEmptyBoard();
443
+ this.currentPlayer = StoneType.BLACK;
444
+ this.stepHistory = [];
445
+ this.currentStepIndex = 0;
446
+ this.gameStatus = "idle";
447
+ this.gameResult = void 0;
448
+ this.passCount = 0;
449
+ }
450
+ /**
451
+ * Create an empty board
452
+ */
453
+ createEmptyBoard() {
454
+ const board = [];
455
+ for (let x = 0; x < this.shape.x; x++) {
456
+ board[x] = [];
457
+ for (let y = 0; y < this.shape.y; y++) {
458
+ board[x][y] = [];
459
+ for (let z = 0; z < this.shape.z; z++) {
460
+ board[x][y][z] = StoneType.EMPTY;
461
+ }
462
+ }
463
+ }
464
+ return board;
465
+ }
466
+ /**
467
+ * Reset the game to initial state
468
+ * Equivalent to Game.reset() (lines 153-163)
469
+ */
470
+ reset() {
471
+ this.board = this.createEmptyBoard();
472
+ this.currentPlayer = StoneType.BLACK;
473
+ this.stepHistory = [];
474
+ this.currentStepIndex = 0;
475
+ this.lastCapturedPositions = null;
476
+ this.invalidateAnalysisCache();
477
+ this.gameStatus = "idle";
478
+ this.gameResult = void 0;
479
+ this.passCount = 0;
480
+ }
481
+ /**
482
+ * Invalidate all static analysis caches
483
+ * Called when board state changes
484
+ */
485
+ invalidateAnalysisCache() {
486
+ this.cachedTerritory = null;
487
+ this.cachedCapturingMove.clear();
488
+ }
489
+ /**
490
+ * Clone the game state (deep copy)
491
+ * Creates an independent copy with all state preserved
492
+ */
493
+ clone() {
494
+ const cloned = new _TrigoGame(this.shape, {});
495
+ cloned.board = this.board.map((plane) => plane.map((row) => [...row]));
496
+ cloned.currentPlayer = this.currentPlayer;
497
+ cloned.currentStepIndex = this.currentStepIndex;
498
+ cloned.gameStatus = this.gameStatus;
499
+ cloned.passCount = this.passCount;
500
+ cloned.stepHistory = this.stepHistory.map((step) => ({
501
+ ...step,
502
+ position: step.position ? { ...step.position } : void 0,
503
+ capturedPositions: step.capturedPositions ? step.capturedPositions.map((pos) => ({ ...pos })) : []
504
+ }));
505
+ cloned.lastCapturedPositions = this.lastCapturedPositions ? this.lastCapturedPositions.map((pos) => ({ ...pos })) : null;
506
+ if (this.gameResult) {
507
+ cloned.gameResult = {
508
+ ...this.gameResult,
509
+ score: this.gameResult.score ? { ...this.gameResult.score } : void 0
510
+ };
511
+ }
512
+ cloned.invalidateAnalysisCache();
513
+ return cloned;
514
+ }
515
+ /**
516
+ * Get current board state (read-only)
517
+ */
518
+ getBoard() {
519
+ return this.board.map((plane) => plane.map((row) => [...row]));
520
+ }
521
+ /**
522
+ * Get stone at specific position
523
+ * Equivalent to Game.stone() (lines 95-97)
524
+ */
525
+ getStone(pos) {
526
+ return this.board[pos.x][pos.y][pos.z];
527
+ }
528
+ /**
529
+ * Get current player
530
+ */
531
+ getCurrentPlayer() {
532
+ return this.currentPlayer;
533
+ }
534
+ /**
535
+ * Get current step number
536
+ * Equivalent to Game.currentStep() (lines 99-101)
537
+ */
538
+ getCurrentStep() {
539
+ return this.currentStepIndex;
540
+ }
541
+ /**
542
+ * Get move history
543
+ * Equivalent to Game.routine() (lines 103-105)
544
+ */
545
+ getHistory() {
546
+ return [...this.stepHistory];
547
+ }
548
+ /**
549
+ * Get last move
550
+ * Equivalent to Game.lastStep() (lines 107-110)
551
+ */
552
+ getLastStep() {
553
+ if (this.currentStepIndex > 0) {
554
+ return this.stepHistory[this.currentStepIndex - 1];
555
+ }
556
+ return null;
557
+ }
558
+ /**
559
+ * Get board shape
560
+ * Equivalent to Game.shape() (lines 87-89)
561
+ */
562
+ getShape() {
563
+ return { ...this.shape };
564
+ }
565
+ /**
566
+ * Get game status
567
+ */
568
+ getGameStatus() {
569
+ return this.gameStatus;
570
+ }
571
+ /**
572
+ * Set game status
573
+ */
574
+ setGameStatus(status) {
575
+ this.gameStatus = status;
576
+ }
577
+ /**
578
+ * Get game result
579
+ */
580
+ getGameResult() {
581
+ return this.gameResult;
582
+ }
583
+ /**
584
+ * Get consecutive pass count
585
+ */
586
+ getPassCount() {
587
+ return this.passCount;
588
+ }
589
+ /**
590
+ * Recalculate consecutive pass count based on current history
591
+ * Counts consecutive PASS steps from the end of current history
592
+ */
593
+ recalculatePassCount() {
594
+ this.passCount = 0;
595
+ for (let i = this.currentStepIndex - 1; i >= 0; i--) {
596
+ if (this.stepHistory[i].type === 1 /* PASS */) {
597
+ this.passCount++;
598
+ } else {
599
+ break;
600
+ }
601
+ }
602
+ }
603
+ /**
604
+ * Start the game
605
+ */
606
+ startGame() {
607
+ if (this.gameStatus === "idle") {
608
+ this.gameStatus = "playing";
609
+ }
610
+ }
611
+ /**
612
+ * Check if game is active
613
+ */
614
+ isGameActive() {
615
+ return this.gameStatus === "playing";
616
+ }
617
+ /**
618
+ * Check if a move is valid
619
+ * Equivalent to Game.isDropable() and Game.isValidStep() (lines 112-151)
620
+ */
621
+ isValidMove(pos, player) {
622
+ const playerColor = player || this.currentPlayer;
623
+ return validateMove(pos, playerColor, this.board, this.shape, this.lastCapturedPositions);
624
+ }
625
+ /**
626
+ * Get all valid move positions for current player (efficient batch query)
627
+ *
628
+ * This method is optimized to avoid repeated validation checks by:
629
+ * 1. Only checking empty positions
630
+ * 2. Skipping bounds checking (iterator is already within bounds)
631
+ * 3. Using low-level validation functions directly
632
+ * 4. Batching board state access
633
+ *
634
+ * @param player - Optional player color (defaults to current player)
635
+ * @returns Array of all valid move positions
636
+ */
637
+ validMovePositions(player) {
638
+ const playerColor = player || this.currentPlayer;
639
+ const validPositions = [];
640
+ for (let x = 0; x < this.shape.x; x++) {
641
+ for (let y = 0; y < this.shape.y; y++) {
642
+ for (let z = 0; z < this.shape.z; z++) {
643
+ if (this.board[x][y][z] !== StoneType.EMPTY) {
644
+ continue;
645
+ }
646
+ const pos = { x, y, z };
647
+ if (isKoViolation(
648
+ pos,
649
+ playerColor,
650
+ this.board,
651
+ this.shape,
652
+ this.lastCapturedPositions
653
+ )) {
654
+ continue;
655
+ }
656
+ if (isSuicideMove(pos, playerColor, this.board, this.shape)) {
657
+ continue;
658
+ }
659
+ validPositions.push(pos);
660
+ }
661
+ }
662
+ }
663
+ return validPositions;
664
+ }
665
+ /**
666
+ * Check if any valid move can capture enemy stones
667
+ * Used by MCTS to determine if a position is truly terminal
668
+ *
669
+ * Results are cached and invalidated when board state changes.
670
+ *
671
+ * @param player - Optional player color (defaults to current player)
672
+ * @returns true if at least one valid move would capture stones
673
+ */
674
+ hasCapturingMove(player) {
675
+ const playerColor = player ?? this.currentPlayer;
676
+ if (this.cachedCapturingMove.has(playerColor)) {
677
+ return this.cachedCapturingMove.get(playerColor);
678
+ }
679
+ const result = this.computeHasCapturingMove(playerColor);
680
+ this.cachedCapturingMove.set(playerColor, result);
681
+ return result;
682
+ }
683
+ /**
684
+ * Internal: Compute whether a player has any capturing move
685
+ */
686
+ computeHasCapturingMove(playerColor) {
687
+ for (let x = 0; x < this.shape.x; x++) {
688
+ for (let y = 0; y < this.shape.y; y++) {
689
+ for (let z = 0; z < this.shape.z; z++) {
690
+ if (this.board[x][y][z] !== StoneType.EMPTY) {
691
+ continue;
692
+ }
693
+ const pos = { x, y, z };
694
+ if (isKoViolation(
695
+ pos,
696
+ playerColor,
697
+ this.board,
698
+ this.shape,
699
+ this.lastCapturedPositions
700
+ )) {
701
+ continue;
702
+ }
703
+ if (isSuicideMove(pos, playerColor, this.board, this.shape)) {
704
+ continue;
705
+ }
706
+ const capturedGroups = findCapturedGroups(pos, playerColor, this.board, this.shape);
707
+ if (capturedGroups.length > 0) {
708
+ return true;
709
+ }
710
+ }
711
+ }
712
+ }
713
+ return false;
714
+ }
715
+ /**
716
+ * Check if the game has reached a natural terminal state
717
+ *
718
+ * A game is naturally terminal when:
719
+ * - All territory is claimed (neutral === 0)
720
+ * - AND neither player has any capturing moves available
721
+ *
722
+ * This is different from the "finished" status which requires double pass.
723
+ * Natural termination means the game state is completely settled and
724
+ * no further moves can meaningfully change the outcome.
725
+ *
726
+ * NOTE: This method is expensive due to territory calculation and capture move checking.
727
+ * Use coverage ratio check as a pre-filter when calling frequently.
728
+ *
729
+ * Used by:
730
+ * - MCTS agent (terminal detection for tree search)
731
+ * - Model battle (early stopping when settled)
732
+ * - Self-play games (early stopping when settled)
733
+ *
734
+ * @returns true if the game is naturally terminal, false otherwise
735
+ */
736
+ isNaturallyTerminal() {
737
+ const territory = this.getTerritory();
738
+ if (territory.neutral !== 0) {
739
+ return false;
740
+ }
741
+ const blackCanCapture = this.hasCapturingMove(StoneType.BLACK);
742
+ const whiteCanCapture = this.hasCapturingMove(StoneType.WHITE);
743
+ return !blackCanCapture && !whiteCanCapture;
744
+ }
745
+ /**
746
+ * Reset pass count (called when a stone is placed)
747
+ */
748
+ resetPassCount() {
749
+ this.passCount = 0;
750
+ }
751
+ /**
752
+ * Place a stone (drop move)
753
+ * Equivalent to Game.drop() and Game.appendStone() (lines 181-273)
754
+ *
755
+ * @returns true if move was successful, false otherwise
756
+ */
757
+ drop(pos) {
758
+ const validation = this.isValidMove(pos);
759
+ if (!validation.valid) {
760
+ console.warn(`Invalid move at (${pos.x}, ${pos.y}, ${pos.z}): ${validation.reason}`);
761
+ return false;
762
+ }
763
+ const capturedGroups = findCapturedGroups(pos, this.currentPlayer, this.board, this.shape);
764
+ this.board[pos.x][pos.y][pos.z] = this.currentPlayer;
765
+ const capturedPositions = executeCaptures(capturedGroups, this.board);
766
+ this.lastCapturedPositions = capturedPositions.length > 0 ? capturedPositions : null;
767
+ this.invalidateAnalysisCache();
768
+ this.resetPassCount();
769
+ const step = {
770
+ type: 0 /* DROP */,
771
+ position: pos,
772
+ player: this.currentPlayer,
773
+ capturedPositions: capturedPositions.length > 0 ? capturedPositions : void 0,
774
+ timestamp: Date.now()
775
+ };
776
+ this.advanceStep(step);
777
+ if (capturedPositions.length > 0 && this.callbacks.onCapture) {
778
+ this.callbacks.onCapture(capturedPositions);
779
+ }
780
+ if (this.callbacks.onTerritoryChange) {
781
+ this.callbacks.onTerritoryChange(this.getTerritory());
782
+ }
783
+ return true;
784
+ }
785
+ /**
786
+ * Pass turn
787
+ * Equivalent to PASS step type in prototype
788
+ */
789
+ pass() {
790
+ const step = {
791
+ type: 1 /* PASS */,
792
+ player: this.currentPlayer,
793
+ timestamp: Date.now()
794
+ };
795
+ this.lastCapturedPositions = null;
796
+ this.passCount++;
797
+ this.advanceStep(step);
798
+ if (this.passCount >= 2) {
799
+ const territory = this.getTerritory();
800
+ const capturedCounts = this.getCapturedCounts();
801
+ const blackTotal = territory.black + capturedCounts.white;
802
+ const whiteTotal = territory.white + capturedCounts.black;
803
+ let winner;
804
+ if (blackTotal > whiteTotal) {
805
+ winner = "black";
806
+ } else if (whiteTotal > blackTotal) {
807
+ winner = "white";
808
+ } else {
809
+ winner = "draw";
810
+ }
811
+ this.gameResult = {
812
+ winner,
813
+ reason: "double-pass",
814
+ score: territory
815
+ };
816
+ this.gameStatus = "finished";
817
+ if (this.callbacks.onWin) {
818
+ const winnerStone = winner === "black" ? StoneType.BLACK : winner === "white" ? StoneType.WHITE : StoneType.EMPTY;
819
+ this.callbacks.onWin(winnerStone);
820
+ }
821
+ }
822
+ return true;
823
+ }
824
+ /**
825
+ * Surrender/resign
826
+ * Equivalent to Game.step() with SURRENDER type (lines 176-178)
827
+ */
828
+ surrender() {
829
+ const surrenderingPlayer = this.currentPlayer;
830
+ const step = {
831
+ type: 2 /* SURRENDER */,
832
+ player: this.currentPlayer,
833
+ timestamp: Date.now()
834
+ };
835
+ this.advanceStep(step);
836
+ const winner = surrenderingPlayer === StoneType.BLACK ? "white" : "black";
837
+ this.gameResult = {
838
+ winner,
839
+ reason: "resignation"
840
+ };
841
+ this.gameStatus = "finished";
842
+ const winnerStone = getEnemyColor(surrenderingPlayer);
843
+ if (this.callbacks.onWin) {
844
+ this.callbacks.onWin(winnerStone);
845
+ }
846
+ return true;
847
+ }
848
+ /**
849
+ * Undo last move
850
+ * Equivalent to Game.repent() (lines 197-230)
851
+ *
852
+ * @returns true if undo was successful, false if no moves to undo
853
+ */
854
+ undo() {
855
+ if (this.currentStepIndex === 0 || this.stepHistory.length === 0) {
856
+ return false;
857
+ }
858
+ const lastStep = this.stepHistory[this.currentStepIndex - 1];
859
+ if (lastStep.type === 0 /* DROP */ && lastStep.position) {
860
+ this.board[lastStep.position.x][lastStep.position.y][lastStep.position.z] = StoneType.EMPTY;
861
+ if (lastStep.capturedPositions) {
862
+ const enemyColor = getEnemyColor(lastStep.player);
863
+ for (const pos of lastStep.capturedPositions) {
864
+ this.board[pos.x][pos.y][pos.z] = enemyColor;
865
+ }
866
+ }
867
+ }
868
+ this.currentStepIndex--;
869
+ this.currentPlayer = lastStep.player;
870
+ this.recalculatePassCount();
871
+ if (this.currentStepIndex > 0) {
872
+ const previousStep = this.stepHistory[this.currentStepIndex - 1];
873
+ this.lastCapturedPositions = previousStep.capturedPositions || null;
874
+ } else {
875
+ this.lastCapturedPositions = null;
876
+ }
877
+ this.invalidateAnalysisCache();
878
+ if (this.callbacks.onStepBack) {
879
+ this.callbacks.onStepBack(lastStep, this.stepHistory.slice(0, this.currentStepIndex));
880
+ }
881
+ return true;
882
+ }
883
+ /**
884
+ * Redo next move (after undo)
885
+ *
886
+ * @returns true if redo was successful, false if no moves to redo
887
+ */
888
+ redo() {
889
+ if (this.currentStepIndex >= this.stepHistory.length) {
890
+ return false;
891
+ }
892
+ const nextStep = this.stepHistory[this.currentStepIndex];
893
+ if (nextStep.type === 0 /* DROP */ && nextStep.position) {
894
+ this.board[nextStep.position.x][nextStep.position.y][nextStep.position.z] = nextStep.player;
895
+ if (nextStep.capturedPositions) {
896
+ for (const pos of nextStep.capturedPositions) {
897
+ this.board[pos.x][pos.y][pos.z] = StoneType.EMPTY;
898
+ }
899
+ }
900
+ this.lastCapturedPositions = nextStep.capturedPositions || null;
901
+ } else if (nextStep.type === 1 /* PASS */) {
902
+ this.lastCapturedPositions = null;
903
+ }
904
+ this.currentStepIndex++;
905
+ this.currentPlayer = getEnemyColor(nextStep.player);
906
+ this.invalidateAnalysisCache();
907
+ if (this.callbacks.onStepAdvance) {
908
+ this.callbacks.onStepAdvance(
909
+ nextStep,
910
+ this.stepHistory.slice(0, this.currentStepIndex)
911
+ );
912
+ }
913
+ return true;
914
+ }
915
+ /**
916
+ * Check if redo is available
917
+ */
918
+ canRedo() {
919
+ return this.currentStepIndex < this.stepHistory.length;
920
+ }
921
+ /**
922
+ * Jump to specific step in history
923
+ * Rebuilds board state after applying the first 'index' moves
924
+ *
925
+ * @param index Number of moves to apply from history (0 for initial state, 1 for after first move, etc.)
926
+ * @returns true if jump was successful
927
+ */
928
+ jumpToStep(index) {
929
+ if (index < 0 || index > this.stepHistory.length) {
930
+ return false;
931
+ }
932
+ if (index === this.currentStepIndex) {
933
+ return false;
934
+ }
935
+ this.board = this.createEmptyBoard();
936
+ this.lastCapturedPositions = null;
937
+ for (let i = 0; i < index; i++) {
938
+ const step = this.stepHistory[i];
939
+ if (step.type === 0 /* DROP */ && step.position) {
940
+ const pos = step.position;
941
+ this.board[pos.x][pos.y][pos.z] = step.player;
942
+ if (step.capturedPositions) {
943
+ for (const capturedPos of step.capturedPositions) {
944
+ this.board[capturedPos.x][capturedPos.y][capturedPos.z] = StoneType.EMPTY;
945
+ }
946
+ }
947
+ }
948
+ }
949
+ if (index > 0) {
950
+ const lastAppliedStep = this.stepHistory[index - 1];
951
+ if (lastAppliedStep.type === 0 /* DROP */) {
952
+ this.lastCapturedPositions = lastAppliedStep.capturedPositions || null;
953
+ } else if (lastAppliedStep.type === 1 /* PASS */) {
954
+ this.lastCapturedPositions = null;
955
+ }
956
+ } else {
957
+ this.lastCapturedPositions = null;
958
+ }
959
+ const oldStepIndex = this.currentStepIndex;
960
+ this.currentStepIndex = index;
961
+ const movesPlayed = index;
962
+ this.currentPlayer = movesPlayed % 2 === 0 ? StoneType.BLACK : StoneType.WHITE;
963
+ this.recalculatePassCount();
964
+ this.invalidateAnalysisCache();
965
+ if (index < oldStepIndex && this.callbacks.onStepBack) {
966
+ const currentStep = this.stepHistory[index];
967
+ this.callbacks.onStepBack(currentStep, this.stepHistory.slice(0, index + 1));
968
+ } else if (index > oldStepIndex && this.callbacks.onStepAdvance) {
969
+ const currentStep = this.stepHistory[index];
970
+ this.callbacks.onStepAdvance(currentStep, this.stepHistory.slice(0, index + 1));
971
+ }
972
+ return true;
973
+ }
974
+ /**
975
+ * Advance to next step
976
+ * Equivalent to Game.stepAdvance() (lines 279-287)
977
+ */
978
+ advanceStep(step) {
979
+ if (this.currentStepIndex < this.stepHistory.length) {
980
+ this.stepHistory = this.stepHistory.slice(0, this.currentStepIndex);
981
+ }
982
+ this.stepHistory.push(step);
983
+ this.currentStepIndex++;
984
+ this.currentPlayer = getEnemyColor(this.currentPlayer);
985
+ if (this.callbacks.onStepAdvance) {
986
+ this.callbacks.onStepAdvance(step, this.stepHistory);
987
+ }
988
+ }
989
+ /**
990
+ * Get territory calculation
991
+ * Equivalent to Game.blackDomain() and Game.whiteDomain() (lines 232-244)
992
+ *
993
+ * Returns cached result if territory hasn't changed
994
+ */
995
+ getTerritory() {
996
+ if (!this.cachedTerritory)
997
+ this.cachedTerritory = calculateTerritory(this.board, this.shape);
998
+ return this.cachedTerritory;
999
+ }
1000
+ /**
1001
+ * Get captured stone counts up to current position in history
1002
+ * Only counts captures that have been played (up to currentStepIndex)
1003
+ */
1004
+ getCapturedCounts() {
1005
+ const counts = { black: 0, white: 0 };
1006
+ for (let i = 0; i < this.currentStepIndex; i++) {
1007
+ const step = this.stepHistory[i];
1008
+ if (step.capturedPositions && step.capturedPositions.length > 0) {
1009
+ const enemyColor = getEnemyColor(step.player);
1010
+ if (enemyColor === StoneType.BLACK) {
1011
+ counts.black += step.capturedPositions.length;
1012
+ } else if (enemyColor === StoneType.WHITE) {
1013
+ counts.white += step.capturedPositions.length;
1014
+ }
1015
+ }
1016
+ }
1017
+ return counts;
1018
+ }
1019
+ /**
1020
+ * Serialize game state to JSON
1021
+ * Equivalent to Game.serialize() (lines 250-252)
1022
+ */
1023
+ toJSON() {
1024
+ return {
1025
+ shape: this.shape,
1026
+ currentPlayer: this.currentPlayer,
1027
+ currentStepIndex: this.currentStepIndex,
1028
+ history: this.stepHistory,
1029
+ board: this.board,
1030
+ gameStatus: this.gameStatus,
1031
+ gameResult: this.gameResult,
1032
+ passCount: this.passCount
1033
+ };
1034
+ }
1035
+ /**
1036
+ * Load game state from JSON
1037
+ */
1038
+ fromJSON(data) {
1039
+ try {
1040
+ if (!data || typeof data !== "object") {
1041
+ return false;
1042
+ }
1043
+ if (!data.shape || !data.board || !Array.isArray(data.history)) {
1044
+ return false;
1045
+ }
1046
+ this.shape = data.shape;
1047
+ this.currentPlayer = data.currentPlayer;
1048
+ this.currentStepIndex = data.currentStepIndex;
1049
+ this.stepHistory = data.history || [];
1050
+ this.board = data.board;
1051
+ this.gameStatus = data.gameStatus || "idle";
1052
+ this.gameResult = data.gameResult;
1053
+ this.passCount = data.passCount || 0;
1054
+ if (this.currentStepIndex > 0) {
1055
+ const lastStep = this.stepHistory[this.currentStepIndex - 1];
1056
+ this.lastCapturedPositions = lastStep.capturedPositions || null;
1057
+ } else {
1058
+ this.lastCapturedPositions = null;
1059
+ }
1060
+ this.invalidateAnalysisCache();
1061
+ return true;
1062
+ } catch (error) {
1063
+ console.error("Failed to load game state:", error);
1064
+ return false;
1065
+ }
1066
+ }
1067
+ /**
1068
+ * Get game statistics
1069
+ */
1070
+ getStats() {
1071
+ const captured = this.getCapturedCounts();
1072
+ const territory = this.getTerritory();
1073
+ let blackMoves = 0;
1074
+ let whiteMoves = 0;
1075
+ for (const step of this.stepHistory.slice(0, this.currentStepIndex)) {
1076
+ if (step.type === 0 /* DROP */) {
1077
+ if (step.player === StoneType.BLACK) {
1078
+ blackMoves++;
1079
+ } else if (step.player === StoneType.WHITE) {
1080
+ whiteMoves++;
1081
+ }
1082
+ }
1083
+ }
1084
+ return {
1085
+ totalMoves: this.currentStepIndex,
1086
+ blackMoves,
1087
+ whiteMoves,
1088
+ capturedByBlack: captured.white,
1089
+ // Black captures white stones
1090
+ capturedByWhite: captured.black,
1091
+ // White captures black stones
1092
+ territory
1093
+ };
1094
+ }
1095
+ /**
1096
+ * Save game state to sessionStorage
1097
+ *
1098
+ * @param key Storage key (default: "trigoGameState")
1099
+ * @returns true if save was successful
1100
+ */
1101
+ saveToSessionStorage(key = "trigoGameState") {
1102
+ if (typeof globalThis !== "undefined" && globalThis.sessionStorage) {
1103
+ try {
1104
+ const gameState = this.toJSON();
1105
+ globalThis.sessionStorage.setItem(key, JSON.stringify(gameState));
1106
+ return true;
1107
+ } catch (error) {
1108
+ console.error("Failed to save game state to sessionStorage:", error);
1109
+ return false;
1110
+ }
1111
+ }
1112
+ console.warn("sessionStorage is not available");
1113
+ return false;
1114
+ }
1115
+ /**
1116
+ * Load game state from sessionStorage
1117
+ *
1118
+ * @param key Storage key (default: "trigoGameState")
1119
+ * @returns true if load was successful
1120
+ */
1121
+ loadFromSessionStorage(key = "trigoGameState") {
1122
+ if (typeof globalThis !== "undefined" && globalThis.sessionStorage) {
1123
+ try {
1124
+ const savedState = globalThis.sessionStorage.getItem(key);
1125
+ if (!savedState) {
1126
+ console.log("No saved game state found");
1127
+ return false;
1128
+ }
1129
+ const data = JSON.parse(savedState);
1130
+ return this.fromJSON(data);
1131
+ } catch (error) {
1132
+ console.error("Failed to load game state from sessionStorage:", error);
1133
+ return false;
1134
+ }
1135
+ }
1136
+ console.warn("sessionStorage is not available");
1137
+ return false;
1138
+ }
1139
+ /**
1140
+ * Clear saved game state from sessionStorage
1141
+ *
1142
+ * @param key Storage key (default: "trigoGameState")
1143
+ */
1144
+ clearSessionStorage(key = "trigoGameState") {
1145
+ if (typeof globalThis !== "undefined" && globalThis.sessionStorage) {
1146
+ try {
1147
+ globalThis.sessionStorage.removeItem(key);
1148
+ } catch (error) {
1149
+ console.error("Failed to clear sessionStorage:", error);
1150
+ }
1151
+ } else {
1152
+ console.warn("sessionStorage is not available");
1153
+ }
1154
+ }
1155
+ /**
1156
+ * Export game to TGN (Trigo Game Notation) format
1157
+ *
1158
+ * TGN format is similar to PGN (Portable Game Notation) for chess.
1159
+ * It includes metadata tags and move sequence using ab0yz coordinate notation.
1160
+ *
1161
+ * @param metadata Optional metadata for the game (Event, Site, Date, Players, etc.)
1162
+ * @returns TGN-formatted string
1163
+ *
1164
+ * @example
1165
+ * const tgn = game.toTGN({
1166
+ * event: "World Championship",
1167
+ * site: "Tokyo",
1168
+ * date: "2025.10.31",
1169
+ * black: "Alice",
1170
+ * white: "Bob"
1171
+ * });
1172
+ */
1173
+ toTGN(metadata, { markResult } = {}) {
1174
+ const lines = [];
1175
+ if (metadata) {
1176
+ if (metadata.event) lines.push(`[Event "${metadata.event}"]`);
1177
+ if (metadata.site) lines.push(`[Site "${metadata.site}"]`);
1178
+ if (metadata.date) lines.push(`[Date "${metadata.date}"]`);
1179
+ if (metadata.round) lines.push(`[Round "${metadata.round}"]`);
1180
+ if (metadata.black) lines.push(`[Black "${metadata.black}"]`);
1181
+ if (metadata.white) lines.push(`[White "${metadata.white}"]`);
1182
+ }
1183
+ if (this.gameStatus === "finished" && this.gameResult) {
1184
+ let resultStr = "";
1185
+ if (this.gameResult.winner === "black") {
1186
+ resultStr = "B+";
1187
+ } else if (this.gameResult.winner === "white") {
1188
+ resultStr = "W+";
1189
+ } else {
1190
+ resultStr = "=";
1191
+ }
1192
+ if (this.gameResult.score) {
1193
+ const { black, white } = this.gameResult.score;
1194
+ const diff = Math.abs(black - white);
1195
+ resultStr += `${diff}points`;
1196
+ } else if (this.gameResult.reason === "resignation") {
1197
+ resultStr += "Resign";
1198
+ }
1199
+ }
1200
+ const boardStr = this.shape.z === 1 ? `${this.shape.x}x${this.shape.y}` : `${this.shape.x}x${this.shape.y}x${this.shape.z}`;
1201
+ lines.push(`[Board ${boardStr}]`);
1202
+ if (metadata) {
1203
+ if (metadata.rules) lines.push(`[Rules "${metadata.rules}"]`);
1204
+ if (metadata.timeControl) lines.push(`[TimeControl "${metadata.timeControl}"]`);
1205
+ if (metadata.application) lines.push(`[Application "${metadata.application}"]`);
1206
+ }
1207
+ lines.push("");
1208
+ const moves = [];
1209
+ let moveNumber = 1;
1210
+ for (let i = 0; i < this.stepHistory.length; i++) {
1211
+ const step = this.stepHistory[i];
1212
+ let moveStr = "";
1213
+ if (step.player === StoneType.BLACK) {
1214
+ moveStr = `${moveNumber}. `;
1215
+ }
1216
+ if (step.type === 0 /* DROP */ && step.position) {
1217
+ const pos = [step.position.x, step.position.y, step.position.z];
1218
+ const boardShape = [this.shape.x, this.shape.y, this.shape.z];
1219
+ const coord = encodeAb0yz(pos, boardShape);
1220
+ moveStr += coord;
1221
+ } else if (step.type === 1 /* PASS */) {
1222
+ moveStr += "Pass";
1223
+ } else if (step.type === 2 /* SURRENDER */) {
1224
+ moveStr += "Resign";
1225
+ }
1226
+ moves.push(moveStr);
1227
+ if (step.player === StoneType.WHITE) {
1228
+ moveNumber++;
1229
+ }
1230
+ }
1231
+ let currentLine = "";
1232
+ for (let i = 0; i < moves.length; i++) {
1233
+ const move = moves[i];
1234
+ if (move.match(/^\d+\./)) {
1235
+ if (currentLine) {
1236
+ lines.push(currentLine);
1237
+ }
1238
+ currentLine = move;
1239
+ } else {
1240
+ currentLine += " " + move;
1241
+ }
1242
+ }
1243
+ if (currentLine) {
1244
+ lines.push(currentLine);
1245
+ }
1246
+ if (markResult) {
1247
+ const territory = this.getTerritory();
1248
+ const scoreDiff = territory.black - territory.white;
1249
+ lines.push(`; ${scoreDiff > 0 ? "-" : scoreDiff < 0 ? "+" : ""}${Math.abs(scoreDiff)}`);
1250
+ }
1251
+ lines.push("");
1252
+ return lines.join("\n");
1253
+ }
1254
+ /**
1255
+ * Import game from TGN (Trigo Game Notation) format
1256
+ *
1257
+ * Static factory method that parses a TGN string and creates a new TrigoGame instance
1258
+ * with the board configuration and moves from the TGN file.
1259
+ *
1260
+ * Synchronous operation - requires parser to be loaded via setParserModule()
1261
+ *
1262
+ * @param tgnString TGN-formatted game notation string
1263
+ * @param callbacks Optional game callbacks
1264
+ * @returns New TrigoGame instance with the imported game state
1265
+ * @throws TGNParseError if the TGN string is invalid
1266
+ *
1267
+ * @example
1268
+ * const tgnString = `
1269
+ * [Event "World Championship"]
1270
+ * [Board "5x5x5"]
1271
+ * [Black "Alice"]
1272
+ * [White "Bob"]
1273
+ *
1274
+ * 1. 000 y00
1275
+ * 2. 0y0 pass
1276
+ * `;
1277
+ * const game = TrigoGame.fromTGN(tgnString);
1278
+ */
1279
+ static fromTGN(tgnString, callbacks) {
1280
+ const parsed = parseTGN(tgnString);
1281
+ let boardShape;
1282
+ if (parsed.tags.Board && Array.isArray(parsed.tags.Board)) {
1283
+ const shape = parsed.tags.Board;
1284
+ boardShape = {
1285
+ x: shape[0] || 5,
1286
+ y: shape[1] || 5,
1287
+ z: shape[2] || 1
1288
+ };
1289
+ } else {
1290
+ boardShape = { x: 5, y: 5, z: 5 };
1291
+ }
1292
+ const game = new this(boardShape, callbacks);
1293
+ game.startGame();
1294
+ if (parsed.moves && parsed.moves.length > 0) {
1295
+ for (const round of parsed.moves) {
1296
+ if (round.action_black) {
1297
+ game._applyParsedMove(round.action_black, boardShape);
1298
+ }
1299
+ if (round.action_white) {
1300
+ game._applyParsedMove(round.action_white, boardShape);
1301
+ }
1302
+ }
1303
+ }
1304
+ return game;
1305
+ }
1306
+ /**
1307
+ * Apply a parsed move action to the game
1308
+ * Private helper method for fromTGN
1309
+ *
1310
+ * @param action Parsed move action from TGN parser
1311
+ * @param boardShape Board dimensions for coordinate decoding
1312
+ */
1313
+ _applyParsedMove(action, boardShape) {
1314
+ if (action.type === "pass") {
1315
+ this.pass();
1316
+ } else if (action.type === "resign") {
1317
+ this.surrender();
1318
+ } else if (action.type === "move" && action.position) {
1319
+ const coords = decodeAb0yz(action.position, [boardShape.x, boardShape.y, boardShape.z]);
1320
+ const position = {
1321
+ x: coords[0],
1322
+ y: coords[1],
1323
+ z: coords[2]
1324
+ };
1325
+ this.drop(position);
1326
+ }
1327
+ }
1328
+ };
1329
+
1330
+ // backend/src/services/gameManager.ts
1331
+ var GameManager = class {
1332
+ // Default 5x5x5 board
1333
+ constructor() {
1334
+ this.rooms = /* @__PURE__ */ new Map();
1335
+ this.playerRoomMap = /* @__PURE__ */ new Map();
1336
+ this.defaultBoardShape = { x: 5, y: 5, z: 5 };
1337
+ console.log("GameManager initialized");
1338
+ }
1339
+ createRoom(playerId, nickname, boardShape, preferredColor) {
1340
+ const roomId = this.generateRoomId();
1341
+ const shape = boardShape || this.defaultBoardShape;
1342
+ const playerColor = preferredColor || "black";
1343
+ const room = {
1344
+ id: roomId,
1345
+ adminId: playerId,
1346
+ // Room creator is admin
1347
+ players: {
1348
+ [playerId]: {
1349
+ id: playerId,
1350
+ nickname,
1351
+ color: playerColor,
1352
+ connected: true
1353
+ }
1354
+ },
1355
+ game: new TrigoGame(shape, {
1356
+ onStepAdvance: (_step, history) => {
1357
+ console.log(`Step ${history.length}: Player made move`);
1358
+ },
1359
+ onCapture: (captured) => {
1360
+ console.log(`Captured ${captured.length} stones`);
1361
+ },
1362
+ onWin: (winner) => {
1363
+ console.log(`Game won by ${winner}`);
1364
+ }
1365
+ }),
1366
+ gameState: {
1367
+ gameStatus: "waiting",
1368
+ winner: null
1369
+ },
1370
+ createdAt: /* @__PURE__ */ new Date(),
1371
+ startedAt: null
1372
+ };
1373
+ this.rooms.set(roomId, room);
1374
+ this.playerRoomMap.set(playerId, roomId);
1375
+ console.log(`Room ${roomId} created by ${playerId}`);
1376
+ return room;
1377
+ }
1378
+ joinRoom(roomId, playerId, nickname, preferredColor) {
1379
+ const room = this.rooms.get(roomId);
1380
+ if (!room) {
1381
+ return null;
1382
+ }
1383
+ const playerCount = Object.keys(room.players).length;
1384
+ if (playerCount >= 2) {
1385
+ return null;
1386
+ }
1387
+ const firstPlayer = Object.values(room.players)[0];
1388
+ let assignedColor;
1389
+ if (preferredColor && preferredColor !== firstPlayer.color) {
1390
+ assignedColor = preferredColor;
1391
+ } else {
1392
+ assignedColor = firstPlayer.color === "black" ? "white" : "black";
1393
+ }
1394
+ room.players[playerId] = {
1395
+ id: playerId,
1396
+ nickname,
1397
+ color: assignedColor,
1398
+ connected: true
1399
+ };
1400
+ this.playerRoomMap.set(playerId, roomId);
1401
+ if (playerCount === 1) {
1402
+ room.gameState.gameStatus = "playing";
1403
+ room.startedAt = /* @__PURE__ */ new Date();
1404
+ }
1405
+ return room;
1406
+ }
1407
+ leaveRoom(roomId, playerId) {
1408
+ const room = this.rooms.get(roomId);
1409
+ if (!room) return;
1410
+ if (room.players[playerId]) {
1411
+ room.players[playerId].connected = false;
1412
+ }
1413
+ this.playerRoomMap.delete(playerId);
1414
+ const connectedPlayers = Object.values(room.players).filter((p) => p.connected);
1415
+ if (connectedPlayers.length === 0) {
1416
+ this.rooms.delete(roomId);
1417
+ console.log(`Room ${roomId} deleted - no players remaining`);
1418
+ }
1419
+ }
1420
+ makeMove(roomId, playerId, move) {
1421
+ const room = this.rooms.get(roomId);
1422
+ if (!room) return false;
1423
+ const player = room.players[playerId];
1424
+ if (!player) return false;
1425
+ if (room.gameState.gameStatus !== "playing") {
1426
+ return false;
1427
+ }
1428
+ const expectedPlayer = player.color === "black" ? StoneType.BLACK : StoneType.WHITE;
1429
+ const currentPlayer = room.game.getCurrentPlayer();
1430
+ if (currentPlayer !== expectedPlayer) {
1431
+ return false;
1432
+ }
1433
+ const position = { x: move.x, y: move.y, z: move.z };
1434
+ const success = room.game.drop(position);
1435
+ return success;
1436
+ }
1437
+ passTurn(roomId, playerId) {
1438
+ const room = this.rooms.get(roomId);
1439
+ if (!room) return false;
1440
+ const player = room.players[playerId];
1441
+ if (!player) return false;
1442
+ if (room.gameState.gameStatus !== "playing") {
1443
+ return false;
1444
+ }
1445
+ const expectedPlayer = player.color === "black" ? StoneType.BLACK : StoneType.WHITE;
1446
+ const currentPlayer = room.game.getCurrentPlayer();
1447
+ if (currentPlayer !== expectedPlayer) {
1448
+ return false;
1449
+ }
1450
+ return room.game.pass();
1451
+ }
1452
+ resign(roomId, playerId) {
1453
+ const room = this.rooms.get(roomId);
1454
+ if (!room) return false;
1455
+ const player = room.players[playerId];
1456
+ if (!player) return false;
1457
+ room.game.surrender();
1458
+ room.gameState.gameStatus = "finished";
1459
+ room.gameState.winner = player.color === "black" ? "white" : "black";
1460
+ return true;
1461
+ }
1462
+ /**
1463
+ * Undo the last move (悔棋)
1464
+ */
1465
+ undoMove(roomId, playerId) {
1466
+ const room = this.rooms.get(roomId);
1467
+ if (!room) return false;
1468
+ const player = room.players[playerId];
1469
+ if (!player) return false;
1470
+ if (room.gameState.gameStatus !== "playing") {
1471
+ return false;
1472
+ }
1473
+ return room.game.undo();
1474
+ }
1475
+ /**
1476
+ * Redo the last undone move (forward in history)
1477
+ */
1478
+ redoMove(roomId, playerId) {
1479
+ const room = this.rooms.get(roomId);
1480
+ if (!room) return false;
1481
+ const player = room.players[playerId];
1482
+ if (!player) return false;
1483
+ if (room.gameState.gameStatus !== "playing") {
1484
+ return false;
1485
+ }
1486
+ return room.game.redo();
1487
+ }
1488
+ /**
1489
+ * Reset the game to initial state (new game in same room)
1490
+ * Only admin can reset the game
1491
+ */
1492
+ resetGame(roomId, adminId, options) {
1493
+ const room = this.rooms.get(roomId);
1494
+ if (!room) return { success: false, error: "Room not found" };
1495
+ if (room.adminId !== adminId) {
1496
+ return { success: false, error: "Only room admin can reset the game" };
1497
+ }
1498
+ const boardShape = options?.boardShape;
1499
+ const playerColors = options?.playerColors;
1500
+ if (playerColors) {
1501
+ const playerIds = Object.keys(room.players);
1502
+ for (const playerId of playerIds) {
1503
+ if (playerColors[playerId]) {
1504
+ room.players[playerId].color = playerColors[playerId];
1505
+ }
1506
+ }
1507
+ console.log(`Player colors assigned:`, playerColors);
1508
+ }
1509
+ if (boardShape) {
1510
+ const currentShape = room.game.getShape();
1511
+ if (boardShape.x !== currentShape.x || boardShape.y !== currentShape.y || boardShape.z !== currentShape.z) {
1512
+ room.game = new TrigoGame(boardShape, {
1513
+ onStepAdvance: (_step, history) => {
1514
+ console.log(`Step ${history.length}: Player made move`);
1515
+ },
1516
+ onCapture: (captured) => {
1517
+ console.log(`Captured ${captured.length} stones`);
1518
+ },
1519
+ onWin: (winner) => {
1520
+ console.log(`Game won by ${winner}`);
1521
+ }
1522
+ });
1523
+ console.log(`Game ${roomId} reset with new board shape: ${boardShape.x}x${boardShape.y}x${boardShape.z}`);
1524
+ } else {
1525
+ room.game.reset();
1526
+ console.log(`Game ${roomId} reset to initial state`);
1527
+ }
1528
+ } else {
1529
+ room.game.reset();
1530
+ console.log(`Game ${roomId} reset to initial state`);
1531
+ }
1532
+ room.gameState.gameStatus = "playing";
1533
+ room.gameState.winner = null;
1534
+ room.startedAt = /* @__PURE__ */ new Date();
1535
+ return { success: true };
1536
+ }
1537
+ /**
1538
+ * Get game board state for a room
1539
+ */
1540
+ getGameBoard(roomId) {
1541
+ const room = this.rooms.get(roomId);
1542
+ if (!room) return null;
1543
+ return room.game.getBoard();
1544
+ }
1545
+ /**
1546
+ * Get game statistics for a room
1547
+ */
1548
+ getGameStats(roomId) {
1549
+ const room = this.rooms.get(roomId);
1550
+ if (!room) return null;
1551
+ return room.game.getStats();
1552
+ }
1553
+ /**
1554
+ * Get current player for a room
1555
+ */
1556
+ getCurrentPlayer(roomId) {
1557
+ const room = this.rooms.get(roomId);
1558
+ if (!room) return null;
1559
+ const currentStone = room.game.getCurrentPlayer();
1560
+ return currentStone === StoneType.BLACK ? "black" : "white";
1561
+ }
1562
+ /**
1563
+ * Calculate and get territory for a room
1564
+ */
1565
+ getTerritory(roomId) {
1566
+ const room = this.rooms.get(roomId);
1567
+ if (!room) return null;
1568
+ return room.game.getTerritory();
1569
+ }
1570
+ /**
1571
+ * End the game and determine winner based on territory
1572
+ */
1573
+ endGameByTerritory(roomId) {
1574
+ const room = this.rooms.get(roomId);
1575
+ if (!room) return false;
1576
+ if (room.gameState.gameStatus !== "playing") {
1577
+ return false;
1578
+ }
1579
+ const territory = room.game.getTerritory();
1580
+ if (territory.black > territory.white) {
1581
+ room.gameState.winner = "black";
1582
+ } else if (territory.white > territory.black) {
1583
+ room.gameState.winner = "white";
1584
+ } else {
1585
+ room.gameState.winner = null;
1586
+ }
1587
+ room.gameState.gameStatus = "finished";
1588
+ console.log(
1589
+ `Game ${roomId} ended. Black: ${territory.black}, White: ${territory.white}, Winner: ${room.gameState.winner}`
1590
+ );
1591
+ return true;
1592
+ }
1593
+ /**
1594
+ * Check if both players passed consecutively (game should end)
1595
+ * Returns true if game was ended
1596
+ */
1597
+ checkConsecutivePasses(roomId) {
1598
+ const room = this.rooms.get(roomId);
1599
+ if (!room) return false;
1600
+ const history = room.game.getHistory();
1601
+ if (history.length < 2) return false;
1602
+ const lastMove = history[history.length - 1];
1603
+ const secondLastMove = history[history.length - 2];
1604
+ if (lastMove.type === 1 /* PASS */ && secondLastMove.type === 1 /* PASS */) {
1605
+ this.endGameByTerritory(roomId);
1606
+ return true;
1607
+ }
1608
+ return false;
1609
+ }
1610
+ getRoom(roomId) {
1611
+ return this.rooms.get(roomId);
1612
+ }
1613
+ getPlayerRoom(playerId) {
1614
+ const roomId = this.playerRoomMap.get(playerId);
1615
+ if (!roomId) return void 0;
1616
+ return this.rooms.get(roomId);
1617
+ }
1618
+ getActiveRooms() {
1619
+ return Array.from(this.rooms.values()).filter(
1620
+ (room) => room.gameState.gameStatus !== "finished"
1621
+ );
1622
+ }
1623
+ generateRoomId() {
1624
+ return uuidv4().substring(0, 8).toUpperCase();
1625
+ }
1626
+ };
1627
+
1628
+ // backend/src/sockets/gameSocket.ts
1629
+ function getRoomSummary(room) {
1630
+ const connectedPlayers = Object.values(room.players).filter((p) => p.connected);
1631
+ return {
1632
+ id: room.id,
1633
+ playerCount: connectedPlayers.length,
1634
+ maxPlayers: 2,
1635
+ status: room.gameState.gameStatus,
1636
+ isFull: connectedPlayers.length >= 2,
1637
+ createdAt: room.createdAt.toISOString()
1638
+ };
1639
+ }
1640
+ function setupSocketHandlers(io2, socket, gameManager2) {
1641
+ console.log(`Setting up socket handlers for ${socket.id}`);
1642
+ socket.on("listRooms", (callback) => {
1643
+ const rooms = gameManager2.getActiveRooms();
1644
+ const roomList = rooms.map((room) => getRoomSummary(room));
1645
+ if (callback) {
1646
+ callback({ success: true, rooms: roomList });
1647
+ }
1648
+ });
1649
+ socket.on(
1650
+ "joinRoom",
1651
+ (data, callback) => {
1652
+ console.log("[gameSocket] joinRoom event received:", {
1653
+ roomId: data.roomId,
1654
+ nickname: data.nickname,
1655
+ preferredColor: data.preferredColor,
1656
+ hasCallback: !!callback,
1657
+ socketId: socket.id
1658
+ });
1659
+ const { roomId, nickname, preferredColor } = data;
1660
+ try {
1661
+ let room;
1662
+ if (roomId) {
1663
+ const existingRoom = gameManager2.getRoom(roomId);
1664
+ if (!existingRoom) {
1665
+ if (callback) {
1666
+ callback({
1667
+ success: false,
1668
+ error: "Room not found",
1669
+ errorCode: "ROOM_NOT_FOUND"
1670
+ });
1671
+ }
1672
+ return;
1673
+ }
1674
+ const playerCount = Object.keys(existingRoom.players).length;
1675
+ if (playerCount >= 2) {
1676
+ if (callback) {
1677
+ callback({
1678
+ success: false,
1679
+ error: "Room is full",
1680
+ errorCode: "ROOM_FULL"
1681
+ });
1682
+ }
1683
+ return;
1684
+ }
1685
+ room = gameManager2.joinRoom(roomId, socket.id, nickname, preferredColor);
1686
+ } else {
1687
+ room = gameManager2.createRoom(socket.id, nickname, void 0, preferredColor);
1688
+ }
1689
+ if (room) {
1690
+ socket.join(room.id);
1691
+ const roomSockets = io2.sockets.adapter.rooms.get(room.id);
1692
+ console.log(`[gameSocket] Socket ${socket.id} joined room ${room.id}`);
1693
+ console.log(`[gameSocket] Room ${room.id} now has sockets:`, roomSockets ? Array.from(roomSockets) : []);
1694
+ const currentPlayer = gameManager2.getCurrentPlayer(room.id);
1695
+ const stats = gameManager2.getGameStats(room.id);
1696
+ const tgn = room.game.toTGN();
1697
+ const players = {};
1698
+ for (const [pid, player] of Object.entries(room.players)) {
1699
+ players[pid] = {
1700
+ nickname: player.nickname,
1701
+ color: player.color
1702
+ };
1703
+ }
1704
+ const response = {
1705
+ success: true,
1706
+ roomId: room.id,
1707
+ playerId: socket.id,
1708
+ playerColor: room.players[socket.id]?.color,
1709
+ isAdmin: room.adminId === socket.id,
1710
+ adminId: room.adminId,
1711
+ players,
1712
+ // Include all players in room
1713
+ gameState: {
1714
+ boardShape: room.game.getShape(),
1715
+ currentPlayer,
1716
+ currentMoveIndex: room.game.getCurrentStep(),
1717
+ capturedStones: {
1718
+ black: stats?.capturedByBlack || 0,
1719
+ white: stats?.capturedByWhite || 0
1720
+ },
1721
+ gameStatus: room.gameState.gameStatus,
1722
+ winner: room.gameState.winner,
1723
+ tgn
1724
+ }
1725
+ };
1726
+ if (callback) {
1727
+ console.log("[gameSocket] Sending response via callback:", {
1728
+ roomId: response.roomId,
1729
+ playerColor: response.playerColor
1730
+ });
1731
+ callback(response);
1732
+ } else {
1733
+ console.log("[gameSocket] No callback, using roomJoined emit");
1734
+ socket.emit("roomJoined", response);
1735
+ }
1736
+ console.log(`[gameSocket] Broadcasting playerJoined to room ${room.id} (excluding ${socket.id})`);
1737
+ socket.to(room.id).emit("playerJoined", {
1738
+ playerId: socket.id,
1739
+ nickname
1740
+ });
1741
+ console.log(`[gameSocket] playerJoined broadcast sent`);
1742
+ const roomSummary = getRoomSummary(room);
1743
+ if (roomId) {
1744
+ io2.emit("roomUpdated", roomSummary);
1745
+ } else {
1746
+ io2.emit("roomCreated", roomSummary);
1747
+ }
1748
+ console.log(
1749
+ `Player ${socket.id} ${roomId ? "joined" : "created"} room ${room.id}`
1750
+ );
1751
+ } else {
1752
+ if (callback) {
1753
+ callback({
1754
+ success: false,
1755
+ error: "Failed to join or create room",
1756
+ errorCode: "UNKNOWN_ERROR"
1757
+ });
1758
+ }
1759
+ }
1760
+ } catch (error) {
1761
+ console.error(`Error in joinRoom handler:`, error);
1762
+ if (callback) {
1763
+ callback({
1764
+ success: false,
1765
+ error: "Server error",
1766
+ errorCode: "SERVER_ERROR"
1767
+ });
1768
+ }
1769
+ }
1770
+ }
1771
+ );
1772
+ socket.on("leaveRoom", () => {
1773
+ const room = gameManager2.getPlayerRoom(socket.id);
1774
+ if (room) {
1775
+ const roomId = room.id;
1776
+ socket.leave(room.id);
1777
+ gameManager2.leaveRoom(room.id, socket.id);
1778
+ socket.to(roomId).emit("playerLeft", {
1779
+ playerId: socket.id
1780
+ });
1781
+ const updatedRoom = gameManager2.getRoom(roomId);
1782
+ if (updatedRoom) {
1783
+ io2.emit("roomUpdated", getRoomSummary(updatedRoom));
1784
+ } else {
1785
+ io2.emit("roomDeleted", { roomId });
1786
+ }
1787
+ }
1788
+ });
1789
+ socket.on("makeMove", (data) => {
1790
+ const room = gameManager2.getPlayerRoom(socket.id);
1791
+ if (room && gameManager2.makeMove(room.id, socket.id, data)) {
1792
+ const currentPlayer = gameManager2.getCurrentPlayer(room.id);
1793
+ const stats = gameManager2.getGameStats(room.id);
1794
+ const lastStep = room.game.getLastStep();
1795
+ const tgn = room.game.toTGN();
1796
+ io2.to(room.id).emit("gameUpdate", {
1797
+ currentPlayer,
1798
+ action: "move",
1799
+ lastMove: data,
1800
+ capturedStones: {
1801
+ black: stats?.capturedByBlack || 0,
1802
+ white: stats?.capturedByWhite || 0
1803
+ },
1804
+ capturedPositions: lastStep?.capturedPositions,
1805
+ currentMoveIndex: room.game.getCurrentStep(),
1806
+ tgn
1807
+ });
1808
+ } else {
1809
+ socket.emit("error", { message: "Invalid move" });
1810
+ }
1811
+ });
1812
+ socket.on("pass", () => {
1813
+ const room = gameManager2.getPlayerRoom(socket.id);
1814
+ if (room && gameManager2.passTurn(room.id, socket.id)) {
1815
+ const currentPlayer = gameManager2.getCurrentPlayer(room.id);
1816
+ const tgn = room.game.toTGN();
1817
+ io2.to(room.id).emit("gameUpdate", {
1818
+ currentPlayer,
1819
+ action: "pass",
1820
+ currentMoveIndex: room.game.getCurrentStep(),
1821
+ tgn
1822
+ });
1823
+ if (gameManager2.checkConsecutivePasses(room.id)) {
1824
+ const territory = gameManager2.getTerritory(room.id);
1825
+ io2.to(room.id).emit("gameEnded", {
1826
+ winner: room.gameState.winner,
1827
+ reason: "double-pass",
1828
+ territory
1829
+ });
1830
+ }
1831
+ }
1832
+ });
1833
+ socket.on("resign", () => {
1834
+ const room = gameManager2.getPlayerRoom(socket.id);
1835
+ if (room && gameManager2.resign(room.id, socket.id)) {
1836
+ io2.to(room.id).emit("gameEnded", {
1837
+ winner: room.gameState.winner,
1838
+ reason: "resignation"
1839
+ });
1840
+ }
1841
+ });
1842
+ socket.on("undoMove", (callback) => {
1843
+ const room = gameManager2.getPlayerRoom(socket.id);
1844
+ if (!room) {
1845
+ if (callback) callback({ success: false, error: "Not in a room", errorCode: "NOT_IN_ROOM" });
1846
+ return;
1847
+ }
1848
+ if (room.gameState.gameStatus !== "playing") {
1849
+ if (callback) callback({ success: false, error: "Game not active", errorCode: "GAME_NOT_ACTIVE" });
1850
+ return;
1851
+ }
1852
+ const success = gameManager2.undoMove(room.id, socket.id);
1853
+ if (success) {
1854
+ const currentPlayer = gameManager2.getCurrentPlayer(room.id);
1855
+ const stats = gameManager2.getGameStats(room.id);
1856
+ const tgn = room.game.toTGN();
1857
+ io2.to(room.id).emit("gameUpdate", {
1858
+ currentPlayer,
1859
+ action: "undo",
1860
+ currentMoveIndex: room.game.getCurrentStep(),
1861
+ capturedStones: {
1862
+ black: stats?.capturedByBlack || 0,
1863
+ white: stats?.capturedByWhite || 0
1864
+ },
1865
+ tgn
1866
+ });
1867
+ if (callback) callback({ success: true });
1868
+ } else {
1869
+ if (callback) callback({ success: false, error: "Cannot undo", errorCode: "UNDO_FAILED" });
1870
+ }
1871
+ });
1872
+ socket.on("redoMove", (callback) => {
1873
+ const room = gameManager2.getPlayerRoom(socket.id);
1874
+ if (!room) {
1875
+ if (callback) callback({ success: false, error: "Not in a room", errorCode: "NOT_IN_ROOM" });
1876
+ return;
1877
+ }
1878
+ if (room.gameState.gameStatus !== "playing") {
1879
+ if (callback) callback({ success: false, error: "Game not active", errorCode: "GAME_NOT_ACTIVE" });
1880
+ return;
1881
+ }
1882
+ if (!room.game.canRedo()) {
1883
+ if (callback) callback({ success: false, error: "Nothing to redo", errorCode: "NOTHING_TO_REDO" });
1884
+ return;
1885
+ }
1886
+ const success = gameManager2.redoMove(room.id, socket.id);
1887
+ if (success) {
1888
+ const currentPlayer = gameManager2.getCurrentPlayer(room.id);
1889
+ const stats = gameManager2.getGameStats(room.id);
1890
+ const lastStep = room.game.getLastStep();
1891
+ const tgn = room.game.toTGN();
1892
+ io2.to(room.id).emit("gameUpdate", {
1893
+ currentPlayer,
1894
+ action: "redo",
1895
+ lastMove: lastStep?.position,
1896
+ capturedStones: {
1897
+ black: stats?.capturedByBlack || 0,
1898
+ white: stats?.capturedByWhite || 0
1899
+ },
1900
+ capturedPositions: lastStep?.capturedPositions,
1901
+ currentMoveIndex: room.game.getCurrentStep(),
1902
+ tgn
1903
+ });
1904
+ if (callback) callback({ success: true });
1905
+ } else {
1906
+ if (callback) callback({ success: false, error: "Redo failed", errorCode: "REDO_FAILED" });
1907
+ }
1908
+ });
1909
+ socket.on("resetGame", (data, callback) => {
1910
+ const room = gameManager2.getPlayerRoom(socket.id);
1911
+ if (!room) {
1912
+ const cb = typeof data === "function" ? data : callback;
1913
+ if (cb) cb({ success: false, error: "Not in a room", errorCode: "NOT_IN_ROOM" });
1914
+ return;
1915
+ }
1916
+ const options = typeof data === "object" && data !== null ? {
1917
+ boardShape: data.boardShape,
1918
+ playerColors: data.playerColors
1919
+ } : void 0;
1920
+ const responseCb = typeof data === "function" ? data : callback;
1921
+ const result = gameManager2.resetGame(room.id, socket.id, options);
1922
+ if (result.success) {
1923
+ const currentPlayer = gameManager2.getCurrentPlayer(room.id);
1924
+ const tgn = room.game.toTGN();
1925
+ const players = {};
1926
+ for (const [pid, player] of Object.entries(room.players)) {
1927
+ players[pid] = {
1928
+ nickname: player.nickname,
1929
+ color: player.color
1930
+ };
1931
+ }
1932
+ io2.to(room.id).emit("gameReset", {
1933
+ currentPlayer,
1934
+ boardShape: room.game.getShape(),
1935
+ currentMoveIndex: 0,
1936
+ capturedStones: { black: 0, white: 0 },
1937
+ players,
1938
+ tgn
1939
+ });
1940
+ if (responseCb) responseCb({ success: true });
1941
+ } else {
1942
+ if (responseCb) responseCb({
1943
+ success: false,
1944
+ error: result.error || "Reset failed",
1945
+ errorCode: result.error === "Only room admin can reset the game" ? "NOT_ADMIN" : "RESET_FAILED"
1946
+ });
1947
+ }
1948
+ });
1949
+ socket.on("chatMessage", (data) => {
1950
+ const room = gameManager2.getPlayerRoom(socket.id);
1951
+ if (room) {
1952
+ const player = room.players[socket.id];
1953
+ io2.to(room.id).emit("chatMessage", {
1954
+ author: player?.nickname || "Unknown",
1955
+ content: data.content,
1956
+ playerId: socket.id
1957
+ });
1958
+ }
1959
+ });
1960
+ socket.on(
1961
+ "changeNickname",
1962
+ (data, callback) => {
1963
+ const room = gameManager2.getPlayerRoom(socket.id);
1964
+ if (!room) {
1965
+ const error = { success: false, error: "Not in a room" };
1966
+ if (callback) callback(error);
1967
+ return;
1968
+ }
1969
+ const validation = validateNickname(data.nickname);
1970
+ if (!validation.valid) {
1971
+ const error = { success: false, error: validation.error };
1972
+ if (callback) callback(error);
1973
+ return;
1974
+ }
1975
+ const player = room.players[socket.id];
1976
+ if (player) {
1977
+ const oldNickname = player.nickname;
1978
+ player.nickname = data.nickname;
1979
+ io2.to(room.id).emit("nicknameChanged", {
1980
+ playerId: socket.id,
1981
+ nickname: data.nickname,
1982
+ oldNickname
1983
+ });
1984
+ console.log(`Player ${socket.id} changed nickname: ${oldNickname} -> ${data.nickname}`);
1985
+ if (callback) {
1986
+ callback({ success: true, nickname: data.nickname });
1987
+ }
1988
+ }
1989
+ }
1990
+ );
1991
+ socket.on("disconnect", () => {
1992
+ console.log(`Client disconnected: ${socket.id}`);
1993
+ const room = gameManager2.getPlayerRoom(socket.id);
1994
+ if (room) {
1995
+ const roomId = room.id;
1996
+ gameManager2.leaveRoom(room.id, socket.id);
1997
+ socket.to(room.id).emit("playerDisconnected", {
1998
+ playerId: socket.id
1999
+ });
2000
+ const updatedRoom = gameManager2.getRoom(roomId);
2001
+ if (updatedRoom) {
2002
+ io2.emit("roomUpdated", getRoomSummary(updatedRoom));
2003
+ } else {
2004
+ io2.emit("roomDeleted", { roomId });
2005
+ }
2006
+ }
2007
+ });
2008
+ }
2009
+ function validateNickname(nickname) {
2010
+ if (!nickname || typeof nickname !== "string") {
2011
+ return { valid: false, error: "Invalid nickname" };
2012
+ }
2013
+ const trimmed = nickname.trim();
2014
+ if (trimmed.length < 3) {
2015
+ return { valid: false, error: "Nickname must be at least 3 characters" };
2016
+ }
2017
+ if (trimmed.length > 20) {
2018
+ return { valid: false, error: "Nickname must be 20 characters or less" };
2019
+ }
2020
+ if (!/^[a-zA-Z0-9 ]+$/.test(trimmed)) {
2021
+ return { valid: false, error: "Only letters, numbers, and spaces allowed" };
2022
+ }
2023
+ if (trimmed !== nickname) {
2024
+ return { valid: false, error: "No leading or trailing spaces allowed" };
2025
+ }
2026
+ return { valid: true };
2027
+ }
2028
+
2029
+ // backend/src/server.ts
2030
+ var __filename = fileURLToPath(import.meta.url);
2031
+ var __dirname = path.dirname(__filename);
2032
+ var isDev = __dirname.includes("/src") && !__dirname.includes("/dist");
2033
+ var levelsUp = isDev ? "../" : "../../../";
2034
+ var envPath = path.join(__dirname, levelsUp, ".env");
2035
+ var envLocalPath = path.join(__dirname, levelsUp, ".env.local");
2036
+ if (fs.existsSync(envPath)) {
2037
+ dotenv.config({ path: envPath });
2038
+ console.log("[Config] Loaded .env");
2039
+ } else {
2040
+ console.log(`[Config] .env not found at: ${envPath}`);
2041
+ }
2042
+ if (fs.existsSync(envLocalPath)) {
2043
+ dotenv.config({ path: envLocalPath, override: true });
2044
+ console.log("[Config] Loaded .env.local (overriding .env)");
2045
+ } else {
2046
+ console.log(`[Config] .env.local not found at: ${envLocalPath}`);
2047
+ }
2048
+ var app = express();
2049
+ var httpServer = createServer(app);
2050
+ var io = new Server(httpServer, {
2051
+ cors: {
2052
+ origin: process.env.NODE_ENV === "production" ? process.env.CLIENT_URL || "http://localhost:5173" : true,
2053
+ // Allow all origins in development
2054
+ methods: ["GET", "POST"],
2055
+ credentials: true
2056
+ }
2057
+ });
2058
+ var gameManager = new GameManager();
2059
+ var PORT = parseInt(process.env.PORT || "3000", 10);
2060
+ var HOST = process.env.HOST || "0.0.0.0";
2061
+ console.log(`[Config] Server Configuration:`);
2062
+ console.log(`[Config] PORT: ${PORT}`);
2063
+ console.log(`[Config] HOST: ${HOST}`);
2064
+ console.log(`[Config] NODE_ENV: ${process.env.NODE_ENV || "development"}`);
2065
+ console.log(`[Config] CLIENT_URL: ${process.env.CLIENT_URL || "not set"}`);
2066
+ app.use(cors());
2067
+ app.use(express.json());
2068
+ if (process.env.NODE_ENV === "production") {
2069
+ const frontendPath = path.join(__dirname, "../../../../app/dist");
2070
+ app.use(express.static(frontendPath));
2071
+ app.get("*", (req, res, next) => {
2072
+ if (req.path.startsWith("/health") || req.path.startsWith("/socket.io")) {
2073
+ return next();
2074
+ }
2075
+ res.sendFile(path.join(frontendPath, "index.html"));
2076
+ });
2077
+ }
2078
+ app.get("/health", (_req, res) => {
2079
+ res.json({ status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() });
2080
+ });
2081
+ io.on("connection", (socket) => {
2082
+ console.log(`New client connected: ${socket.id}`);
2083
+ setupSocketHandlers(io, socket, gameManager);
2084
+ socket.on("echo", (data, callback) => {
2085
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2086
+ const responseMessage = `Hello from server! Received: "${data.message}" at ${timestamp}`;
2087
+ console.log(`[Echo] Client ${socket.id}: ${data.message}`);
2088
+ if (callback && typeof callback === "function") {
2089
+ callback({
2090
+ message: responseMessage,
2091
+ serverTime: timestamp,
2092
+ clientTime: data.timestamp
2093
+ });
2094
+ }
2095
+ });
2096
+ socket.on("disconnect", () => {
2097
+ console.log(`Client disconnected: ${socket.id}`);
2098
+ });
2099
+ });
2100
+ httpServer.listen(PORT, HOST, () => {
2101
+ console.log(`Server running on ${HOST}:${PORT}`);
2102
+ console.log(`Health check: http://${HOST}:${PORT}/health`);
2103
+ console.log(`Environment: ${process.env.NODE_ENV || "development"}`);
2104
+ });