引擎(packages/engine/src/games/): - holdem.ts: HoldemState + 5 phase (waiting→preflop→flop→turn→river→showdown) + 8 moves (sitDown/startHand/fold/check/call/raise/allIn) + playerView 保密 + 边池结算 + 盲注/按钮轮转 + validateSetupData + HoldemView 类型: 只读 helper 放宽签名, UI 复用无需强转 - holdem-eval.ts: 7 张牌评估器 (evaluate5/7/compareHands/makeDeck/cardLabel, 中文牌型名) - holdem.test.ts (25) + holdem-eval.test.ts (16) UI: - apps/web/HoldemBoard.tsx: 椭圆桌 + N 座位环形布局 + 行动条 + 结果面板 + 日志 - packages/ui/LobbyRoomList.tsx: 可变人数 (maxSeats 2..9) + setupData 表单 + 动态 P0..P(n-1) 加入按钮 + onSelect 携带 numPlayers - apps/web/App.tsx: 注册 holdem + HoldemLocalView (9 座切换) + HoldemOnlineView 测试: - apps/web/e2e/holdem-multiplayer.spec.ts: 双浏览器联机 heads-up 完整一手 - 全量验证: ts 5/5 + unit 84 + e2e 5/5 文档: - docs/dev-log/13-holdem-game.md: 设计要点 + 坑 (NULL 损坏 / HoldemView / pnpm) - decisions D30-D32 + lessons-learned §12.1-12.4 - README.md: 系统启动 + 迁移到其它机器步骤 server games 数组已加 Holdem (上轮完成)。 Co-authored-by: GLM-5.2
516 lines
19 KiB
Markdown
516 lines
19 KiB
Markdown
# 踩坑总结 · Lessons Learned
|
||
|
||
按现象分类。所有 26 条都已验证有效。
|
||
|
||
---
|
||
|
||
## 1. 环境 / 工具
|
||
|
||
### 1.1 pnpm 不在 PATH
|
||
|
||
`which pnpm` 报 NOT FOUND,但 corepack 路径下能找到 shim。
|
||
**修法**:export PATH 或调用 `/home/e2hang/.nvm/versions/node/v22.22.2/lib/node_modules/corepack/shims/pnpm`。
|
||
**项目影响**:Phase 1.1 搭骨架。
|
||
|
||
### 1.2 pnpm workspace exports 必须带通配符后缀
|
||
|
||
```json
|
||
"exports": {
|
||
"./*": "./src/*.ts" // ✅
|
||
"./*": "./src/*" // ❌ 解析出来没扩展名
|
||
}
|
||
```
|
||
**项目影响**:所有跨包 import @tts-like/engine/games/war。
|
||
|
||
### 1.3 Vite 项目需要 `vite-env.d.ts`
|
||
|
||
否则 `import.meta.env` 报错。
|
||
**项目影响**:apps/web/。
|
||
|
||
---
|
||
|
||
## 2. boardgame.io 0.50.2 类型导出
|
||
|
||
### 2.1 类型只在主 entry
|
||
|
||
`Game` / `Ctx` / `State` 等类型只在 `boardgame.io`(legacy 入口)里有;子包 `/react` / `/server` / `/multiplayer` 只导出运行时值。
|
||
**修法**:
|
||
```ts
|
||
import type { Game, Ctx } from 'boardgame.io'; // 类型从主 entry
|
||
import { Client } from 'boardgame.io/react'; // 运行时从子包
|
||
```
|
||
|
||
### 2.2 move 是 Immer 风格
|
||
|
||
形参是 `({ G, ctx, events, playerID }, ...args)`,**直接 mutate G**,不要 return。
|
||
|
||
### 2.3 `events` 不在 ctx 上
|
||
|
||
`events` 来自 `FnContext`(`DefaultPluginAPIs`),不是 `Ctx` 字段。
|
||
|
||
```ts
|
||
// ✅
|
||
moves: { foo: ({ G, ctx, events, playerID }, ...args) => { events.endTurn(); } }
|
||
// ❌
|
||
moves: { foo: ({ G, ctx }, ...args) => { ctx.events.endTurn(); } }
|
||
```
|
||
|
||
### 2.4 `playerID` 总是 `string | undefined`
|
||
|
||
`Ctx.currentPlayer` 是 `string`,自己强转 `PlayerID = '0' | '1'`:
|
||
```ts
|
||
const me = (playerID ?? '0') as PlayerID;
|
||
```
|
||
|
||
### 2.5 React 18 + Client + StrictMode 双重挂载
|
||
|
||
`Client(...)` 用全局 store;StrictMode 双重挂载让 store 注册两次 → state 翻倍 + React duplicate key 警告。
|
||
**修法**:
|
||
```ts
|
||
const LocalClient = useMemo(() => Client({...}), [game]);
|
||
```
|
||
且 `main.tsx` 暂时移除 `<StrictMode>`。
|
||
|
||
---
|
||
|
||
## 3. 拖拽
|
||
|
||
### 3.1 SVG 拦截 pointer events
|
||
|
||
`<rect>`/`<text>` 等会"吃掉"事件,dragstart 不会触发。
|
||
**修法**:把 SVG 包在 `<div style="pointer-events:none">` 里:
|
||
```tsx
|
||
dangerouslySetInnerHTML={{
|
||
__html: `<div style="pointer-events:none;width:100%;height:100%;">${svg}</div>`,
|
||
}}
|
||
```
|
||
|
||
### 3.2 HTML5 拖拽必须 preventDefault
|
||
|
||
`dragover` 上必须 `e.preventDefault()` 才能让 `drop` 触发。
|
||
|
||
### 3.3 自定义 MIME 携带数据
|
||
|
||
```ts
|
||
const DRAG_MIME = 'application/x-tts-cards';
|
||
e.dataTransfer.setData(DRAG_MIME, JSON.stringify({ cardIds, fromZoneId }));
|
||
```
|
||
|
||
### 3.4 多选拖拽必须在 Zone 传 cardIds
|
||
|
||
Zone 渲染 Card 时:
|
||
```tsx
|
||
<Card
|
||
cardIds={selected ? Array.from(selectedIds) : undefined}
|
||
onDragStart={...}
|
||
/>
|
||
```
|
||
Card 内部 `cardIds ?? [card.id]` 兜底。
|
||
|
||
---
|
||
|
||
## 4. Immer + 棋类状态
|
||
|
||
### 4.1 同 key 操作导致重复加
|
||
|
||
```ts
|
||
// ❌ 错误:fromKey === toZoneId 时会重复加
|
||
const fromArr = G[fromKey];
|
||
const toArr = G[toKey];
|
||
G[fromKey] = fromArr.filter(...); // G[fromKey] = G[toKey]
|
||
G[toKey] = [...toArr, ...moved]; // toArr 是旧引用!
|
||
|
||
// ✅ 修法
|
||
if (fromZoneId === toZoneId) return;
|
||
```
|
||
|
||
### 4.2 noUncheckedIndexedAccess 兜底
|
||
|
||
TS 严格模式下 `arr[i]` 是 `T | undefined`:
|
||
```ts
|
||
const cell: Cell = G.board[i] ?? null;
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 棋类 / 卡牌游戏 UX
|
||
|
||
### 5.1 卡牌游戏单 move 自动 collect 的 UX 问题
|
||
|
||
原本 `flip` 一次完成翻+收,pile 永远只显示 0 张。**修法**:拆成 `flip` + `collect` 两个 move,UI 两个按钮。
|
||
|
||
### 5.2 卡牌游戏 visibility:双人都应该看见
|
||
|
||
zone 的 `owner` 默认隐藏其他玩家的私有 zone。2 人卡牌游戏所有 zone 应该是公开的(不设 owner)。
|
||
|
||
### 5.3 discard vs pile 的 face-up 行为
|
||
|
||
- `<Zone type="pile" faceDown>` 面朝下
|
||
- `<Zone type="discard">` 强制面朝上
|
||
|
||
War 里 deck 用 `pile`,战时 pile 用 `discard`。
|
||
|
||
### 5.4 棋类不适合用通用 Zone/Card
|
||
|
||
九子棋 24 个交叉点写成 24 个 zone 会有大量 React 组件开销。**用一个大 SVG + 24 个 `<g>` 反而更简单**。
|
||
|
||
### 5.5 阶段转换用 boardgame.io phases
|
||
|
||
```ts
|
||
phases: {
|
||
placing: { start: true, next: 'moving', endIf: (G) => /* 条件 */ },
|
||
moving: { next: 'flying', endIf: (G, ctx) => /* 条件 */ },
|
||
flying: {},
|
||
}
|
||
```
|
||
|
||
phase 通过 `ctx.phase` 访问,move 内先检查防止跨阶段调用。
|
||
|
||
---
|
||
|
||
## 6. Playwright 测试
|
||
|
||
### 6.1 SVG 元素坐标
|
||
|
||
`circle.cx` / `circle.cy` 是 SVG 坐标系,可用来定位目标。`dispatchEvent('click')` 在 SVG 元素上工作正常。
|
||
|
||
### 6.2 拖拽 timeout 多半是 hit test
|
||
|
||
如果 Card 内部 SVG 没包 `pointer-events:none`,Playwright `dragTo()` 会 timeout:"subtree intercepts pointer events"。
|
||
|
||
### 6.3 切换玩家
|
||
|
||
boardgame.io debug 面板的 `.player` 按钮可点击切换当前玩家(用于绕过单 player 限制测试多人游戏)。
|
||
|
||
---
|
||
|
||
## 7. 验证清单
|
||
|
||
每章都有一条:
|
||
- `pnpm -r ts` — 6 个包全过
|
||
- `pnpm --filter @tts-like/web build` — vite build 成功
|
||
- `pnpm --filter @tts-like/web dev` — HTTP 200
|
||
- Playwright 端到端:点击 + 拖拽 + 状态验证
|
||
|
||
不通过其中任一不算完成。
|
||
|
||
---
|
||
|
||
## 8. 联机 (SocketIO)
|
||
|
||
### 8.1 SocketIO URL 用 http:// 不是 ws://
|
||
|
||
```ts
|
||
// ✅ 正确
|
||
SocketIO({ server: 'http://localhost:8000' })
|
||
|
||
// ❌ 错误
|
||
SocketIO({ server: 'ws://localhost:8000' }) // 浏览器报 ERR_NAME_NOT_RESOLVED
|
||
```
|
||
|
||
SocketIO 内部自动升级到 WebSocket,不需要 `ws://` 前缀。
|
||
|
||
### 8.2 boardgame.io 默认 turn 限制
|
||
|
||
boardgame.io 默认 `currentPlayer='0'`,SocketIO 传输层只允许 currentPlayer 调 move。如果游戏不分回合(双方都"现在"行动),必须显式:
|
||
|
||
```ts
|
||
import { ActivePlayers } from 'boardgame.io/core';
|
||
turn: { activePlayers: ActivePlayers.ALL }
|
||
```
|
||
|
||
否则 player 1 调 move 报 `disallowed move: <name>`。
|
||
|
||
### 8.3 ActivePlayers 类型 vs 值
|
||
|
||
`ActivePlayers` 同时是 type 和 value,**入口不同**:
|
||
|
||
```ts
|
||
// ❌ 拿到 type alias
|
||
import { ActivePlayers } from 'boardgame.io';
|
||
|
||
// ✅ 拿到 const 对象
|
||
import { ActivePlayers } from 'boardgame.io/core';
|
||
```
|
||
|
||
`boardgame.io`(主入口)导出 type;`boardgame.io/core` 导出 const。
|
||
|
||
### 8.4 Docker 镜像里 boardgame.io/server 路径导入报错
|
||
|
||
ESM 模式 Node 拒绝目录导入(即便 `package.json` 里有 `main` 字段)。`ERR_UNSUPPORTED_DIR_IMPORT: Directory import '...boardgame.io/server'`。
|
||
|
||
**v2 部署建议**:服务端在 Docker 里改 CJS(`"module": "CommonJS"`, `moduleResolution: "Node16"`),避免从 ESM 调 CJS 的目录导入问题。本地开发用 tsx 跑即可,不影响。
|
||
|
||
---
|
||
|
||
## 9. 联机功能完善(dev-log 09)
|
||
|
||
### 9.1 boardgame.io 字符串 `'*'` 当 origin 不是通配
|
||
|
||
`Server({origins: ['*']})` 看着像允许所有 origin,实际上 boardgame.io 的 `isOriginAllowed` 把字符串 `'*'` 当**字面量**匹配(`origin === '*'`),任何 HTTP origin 都通不过,CORS 默默失败。
|
||
|
||
```ts
|
||
// boardgame.io/src/server/api.ts:670
|
||
} else if (typeof allowedOrigin === 'string') {
|
||
return origin === allowedOrigin; // 'http://localhost:5173' !== '*'
|
||
}
|
||
```
|
||
|
||
**修法**:dev 默认用 RegExp 列表:
|
||
|
||
```ts
|
||
parseOrigins(): (string | RegExp)[] {
|
||
if (!process.env.ALLOWED_ORIGINS) {
|
||
return [
|
||
/^https?:\/\/localhost(:\d+)?$/,
|
||
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
|
||
'tauri://localhost',
|
||
'http://tauri.localhost',
|
||
];
|
||
}
|
||
return process.env.ALLOWED_ORIGINS.split(',').map(s => s.trim()).filter(Boolean);
|
||
}
|
||
```
|
||
|
||
生产环境必须用具体 origin 列表或 RegExp,不要写 `'*'`。
|
||
|
||
**项目影响**:dev-log 09 — E2E 一开始全 404,浏览器报 `No 'Access-Control-Allow-Origin' header`。
|
||
|
||
### 9.2 boardgame.io 0.50.2 没有 `/leaveSlot` 端点
|
||
|
||
我读 main 分支源码看到 `/leaveSlot`(`api.ts:426-430`),但 installed `boardgame.io@0.50.2` 的 `api.ts` 只有 deprecated 的 `/leave`:
|
||
|
||
```ts
|
||
// 实际安装版本 src/server/api.ts
|
||
router.post('/games/:name/:id/leave', koaBody(), async (ctx) => {...});
|
||
```
|
||
|
||
主入口默认 checkout 可能是更高版本;要确认特定版本的能力,`grep -r '<endpoint>' /…/.pnpm/boardgame.io@<version>/node_modules/boardgame.io/src/server/`。
|
||
|
||
**修法**:测试用 `/leave`。注意:`clearPlayerSlot` 在所有玩家都走完后会 `db.wipe(matchID)`,所以 leave 测试必须至少 join 两个玩家。
|
||
|
||
**项目影响**:dev-log 09 — 服务端 `/leaveSlot` 测试一开就 404。
|
||
|
||
### 9.3 显式 `client.events.setPhase` 会重置 `ctx.currentPlayer`
|
||
|
||
调试脚本确认:
|
||
```
|
||
initial phase: moving ← 构造时 piecesLeft 全 0 → 自动进入 moving
|
||
initial currentPlayer: 0
|
||
after setPhase moving: currentPlayer= 1 ← 但显式 setPhase 会改 currentPlayer
|
||
```
|
||
|
||
**后果**:单测里 setPhase('moving') 之后 `c.moves.selectFrom(0)` 被 boardgame.io 判为 `disallowed move: selectFrom`,因为 `me ('0') !== ctx.currentPlayer ('1')`。
|
||
|
||
**修法**:测试用 setup() 直接构造目标 phase 的初始状态(让 `phases.endIf` 自动触发),**不**显式调 setPhase。
|
||
|
||
**项目影响**:dev-log 09 — Mill 单测一开始 2 个失败就是这个。
|
||
|
||
### 9.4 War 平局时 collect 把牌凭空清空
|
||
|
||
老代码在平局分支没 return,无条件 `G.p0Pile = []; G.p1Pile = []`,但 `won` 又没分配给任何玩家——2 张牌消失。dev-log 07 D13 描述的"平局 → pile 留着"没实现。
|
||
|
||
**修法**:平局分支提早 return:
|
||
|
||
```ts
|
||
if (G.lastWinner === 'tie') {
|
||
G.lastWinner = null;
|
||
return; // ← 让 pile 留着
|
||
}
|
||
```
|
||
|
||
**项目影响**:dev-log 09 — War 单测 `p0Deck.length + p1Deck.length` 期望 50 实际 52(已修)。
|
||
|
||
### 9.5 Dockerfile 用 pnpm 软链拿不到 boardgame.io dist
|
||
|
||
`pnpm deploy --filter X --prod /prod/X` 把 `node_modules/boardgame.io` 留成 symlink 指向 `.pnpm/boardgame.io@<v>/node_modules/boardgame.io`。Dockerfile 第 51 行 `COPY --from=builder /repo/node_modules/boardgame.io ./node_modules/boardgame.io` 只复制 symlink,运行时 Node 解析失败:
|
||
|
||
```
|
||
ERR_UNSUPPORTED_DIR_IMPORT: Directory import '...boardgame.io/server'
|
||
```
|
||
|
||
**修法**:运行时阶段用 `cp -rL` 物化 symlink:
|
||
|
||
```dockerfile
|
||
RUN BGIO_SRC=$(find /prod/server/node_modules/.pnpm -maxdepth 4 \
|
||
-name 'boardgame.io' -type d | head -n1) && \
|
||
mkdir -p /app/node_modules/boardgame.io && \
|
||
cp -rL "$BGIO_SRC/." /app/node_modules/boardgame.io/
|
||
```
|
||
|
||
附带:pnpm v10 默认 `inject-workspace-packages=true`,deploy 会要求 inject;用 `--legacy` flag 退回 v9 行为。
|
||
|
||
**项目影响**:dev-log 09 — Dockerfile 修复。
|
||
|
||
### 9.6 Playwright `[data-testid^="..."]` 选择器会匹配容器
|
||
|
||
`[data-testid^="lobby-room-"]` 同时匹配 `lobby-room-list`(容器)和真正的房间 `lobby-room-{matchID}`。`.first()` 拿到容器,提取的 matchID 是 `"list"`,点 `lobby-join-1-list` 自然失败。
|
||
|
||
**修法**:用 `:not([data-testid="lobby-room-list"])` 排除容器:
|
||
|
||
```ts
|
||
page.locator('[data-testid^="lobby-room-"]:not([data-testid="lobby-room-list"])')
|
||
```
|
||
|
||
**项目影响**:dev-log 09 — DragTest / Mill E2E 一开始都因这个失败。
|
||
|
||
### 9.7 Playwright `toBeVisible` 把尺寸 0 的 absolute div 判为 hidden
|
||
|
||
`<Zone>` 是 `position: absolute` + `width: auto`,子 `<Card>` 也 absolute → 父 div 计算尺寸 0。Playwright 报 hidden 但元素确实在 DOM 里、确实渲染了。
|
||
|
||
**修法**:用 `toBeAttached()` 验证存在性;用 `toHaveText()` 验证内容。
|
||
|
||
**项目影响**:dev-log 09 — War E2E zone check 失败,已改 `toBeAttached`。
|
||
|
||
### 9.8 测试 ID 要随组件生命周期稳定
|
||
|
||
`Zone` 组件的计数徽章原本是 `entities.length > 0 && <span>...</span>`——空 pile 时元素不存在。E2E 想 `expect(getByTestId('zone-count-p0-pile')).toHaveText('0')` 就失败(元素不存在)。
|
||
|
||
**修法**:让组件**总是**渲染测试钩子(计数徽章),空时设 opacity 0.3 或文本 0,方便 e2e 选择器稳定。
|
||
|
||
**项目影响**:dev-log 09 — 已改 Zone.tsx。
|
||
|
||
---
|
||
|
||
## 10. LAN 部署调试(dev-log 10)
|
||
|
||
### 10.1 CORS RegExp 不会自动覆盖 LAN IP
|
||
|
||
09 那轮我把 `parseOrigins` 改成 RegExp,但只写了 `^https?://localhost(:\d+)?$` —— 只匹配 hostname `localhost`,不匹配 `192.168.x.x`、`10.x.x.x`、`172.16-31.x.x`、`100.x.x.x`(Tailscale/CGNAT)。
|
||
|
||
**修法**:在 dev 默认列表里显式覆盖常见 LAN 网段:
|
||
|
||
```ts
|
||
/^http:\/\/192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$/,
|
||
/^http:\/\/10\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/,
|
||
/^http:\/\/172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}(:\d+)?$/,
|
||
/^http:\/\/100\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/,
|
||
```
|
||
|
||
**生产环境**必须用 `ALLOWED_ORIGINS` 环境变量设具体列表,不要用 dev 默认。
|
||
|
||
**项目影响**:dev-log 10 — 用户实测 LAN 访问时 fetch 失败、socket.io 一直 connecting。
|
||
|
||
### 10.2 `joinRoom` 成功后必须 refresh,本地 state 残留导致 409
|
||
|
||
09 那轮 `createRoom` 调了 `await refresh()`,但 `joinRoom` 漏了。结果本地 `rooms` 状态里的 `players[].name` 还是旧值,"加入 P1" 按钮还显示着,重复点击 → server 409。
|
||
|
||
**修法**:join 成功 + 失败都 refresh(失败也刷,可能其他人刚占了 slot):
|
||
|
||
```ts
|
||
try {
|
||
const joinRes = await lobby.joinMatch(...);
|
||
onSelect(matchID, joinRes.playerID, joinRes.playerCredentials);
|
||
await refresh();
|
||
} catch (e) {
|
||
setError(...);
|
||
await refresh();
|
||
}
|
||
```
|
||
|
||
**项目影响**:dev-log 10 — 用户实测重复点加入 → 409。
|
||
|
||
### 10.3 测试 server-side Math.random() 偶发 tie
|
||
|
||
dev-log 09 修了 War 平局时 collect 保留 pile 的 bug(正确行为),但测试没考虑这个。服务端洗牌用 `Math.random()`,偶发平局 → 测试 `expect(...).toHaveText('0')` 失败。
|
||
|
||
**修法**:测试断言容忍 tie:
|
||
|
||
```ts
|
||
// 之前
|
||
await expect(...).toHaveText('0');
|
||
|
||
// 现在
|
||
await expect(...).toHaveText(/^[01]$/);
|
||
```
|
||
|
||
或者更彻底:在 server 测试里把 `Math.random` 注入成确定性 PRNG(dev-log 09 §问题 4 已经做了类似工作),但生产 server 不应改。E2E 层容忍 tie 是最简单做法。
|
||
|
||
**项目影响**:dev-log 10 — War E2E 偶发 flake。
|
||
|
||
### 10.4 server 端状态用 InMemory DB,重启会丢所有房间
|
||
|
||
Playwright 跑多个 spec 时,第一个 spec 留的房间会污染第二个。两种修法:
|
||
1. 每个 spec 之前 HTTP DELETE 清空(server 没暴露这个端点,要加)
|
||
2. 测试容忍旧房间(用 `[data-testid^="lobby-join-1-"]` 而不是 `.first()`)
|
||
|
||
10.2 的修法顺便覆盖了这个:选"含加入按钮"的房间,自然跳过满员房间。
|
||
|
||
**项目影响**:dev-log 10 — E2E 多 spec 顺序运行更稳。
|
||
|
||
### 10.5 vite dev 默认只监听 localhost,LAN 不可达
|
||
|
||
启动 vite 要加 `--host 0.0.0.0`:
|
||
|
||
```bash
|
||
pnpm dev --host 0.0.0.0
|
||
```
|
||
|
||
或者在 `vite.config.ts` 里 `server: { host: '0.0.0.0', port: 5173 }`。
|
||
|
||
**项目影响**:dev-log 10 — LAN 机器访问 `http://192.168.5.11:5173/` 前必须确认 vite 监听 0.0.0.0。
|
||
|
||
---
|
||
|
||
## 11. 统一端口 + 命名系统(dev-log 12)
|
||
|
||
### 11.1 koa-send 装包被拒 → 用原生 fs 实现静态 serve
|
||
|
||
想用 `koa-send` serve 静态文件,但 `pnpm add` 被权限拦截。**修法**:用 `node:fs/promises` 的 `readFile` + `stat` 手写(约 40 行),带 MIME 表 + SPA fallback,不需要第三方包。
|
||
|
||
### 11.2 boardgame.io `LobbyClient.request` 是 TS private,无法继承
|
||
|
||
`LobbyClient` 的 `request` 方法在 TS 里标 `private`,子类无法 override 加前缀。**修法**:放弃继承,直接写 fetch 包装类,行为兼容但请求路径自动加 `/api` 前缀。
|
||
|
||
### 11.3 Koa `ctx.URL` 是只读 getter
|
||
|
||
临时改 path 时报 `Cannot set property URL ... only a getter`。**修法**:只改 `ctx.path`(可写),改完 `finally` 恢复。
|
||
|
||
### 11.4 服务端测试 import index.ts 会触发 `server.run(8000)`
|
||
|
||
`import { mountApiPrefix } from './index.js'` 导致 index.ts 整个执行,底部 `server.run(8000)` 与已运行的 server 冲突(EADDRINUSE)。**修法**:把 `mountApiPrefix` 抽到独立 `mount-api-prefix.ts`。
|
||
|
||
### 11.5 vite proxy rewrite 和 server static serve 冲突
|
||
|
||
vite proxy 把 `/api` rewrite 成 `/games` 转发,但 server 端 static serve 拦截了 `/games`(返回 index.html),导致 `/api/games` 返回 HTML。**修法**:vite proxy 不 rewrite,保留 `/api` 转发到 server,由 server 的 `mountApiPrefix` 剥前缀。
|
||
|
||
### 11.6 `pnpm dev` 在错误目录执行
|
||
|
||
`cd` 到 `packages/server` 后跑 `pnpm dev` 启动的是 server(tsx watch),不是 web 的 vite。**教训**:跑命令前明确 `cd apps/web`,或加 `--filter`。
|
||
|
||
### 11.7 统一端口后 CORS 完全消失
|
||
|
||
同源请求(`fetch('/api/games')`)不需要 CORS,之前 dev-log 10 的 CORS 配置在 dev 模式下变成冗余。生产环境如果前后端不同源仍需要。
|
||
|
||
---
|
||
|
||
## 12. Holdem 游戏落地(dev-log 13)
|
||
|
||
### 12.1 机器重启导致写入文件末尾出现 NULL 字节
|
||
|
||
写 `HoldemBoard.tsx` 时机器重启,文件末尾被填充 128 字节 `0x00`(`wc -c` = 16161,末 128 字节全 NULL)。TypeScript 报 `TS1127: Invalid character` 在第 427 行 128 个字符上。
|
||
|
||
**判定**:`xxd` 看到末尾全是 `0000 0000 ...`;`data.rstrip(b'\x00')` 后实际内容到第 426 行 `};` 完整结束。
|
||
|
||
**教训**:跨会话恢复时,TypeScript 报 "Invalid character" 而该行看着是空行 / 空格 → 先 `xxd` 查 NULL 字节,不要怀疑编码。
|
||
|
||
### 12.2 playerView 剥 secret 后 UI 无法复用 helper
|
||
|
||
UI 的 `ViewG = Omit<HoldemState,'deck'|'hands'> & { myHand }` 无法传给签名是 `G: HoldemState` 的 `HOLDEM_HELPERS.host(G)`(缺 `deck`/`hands`)。
|
||
|
||
**修法**:引擎层加 `HoldemView = Omit<HoldemState,'deck'|'hands'>`,只读 helper 放宽签名。结构类型让 `HoldemState` 和 `ViewG` 都满足,无需 `as` 强转。读 secret 的函数(`buildPots`/`resolveHand`)保持 `HoldemState` 不变。
|
||
|
||
**教训**:boardgame.io 的 playerView 把 secret 字段剥掉后,UI 的 G 类型是 engine state 的子集。设计 helper 时按"只读哪些字段"给签名,而不是全量 `HoldemState`,否则 UI 要么强转要么复制函数。
|
||
|
||
### 12.3 新 shell 没加载 nvm → pnpm 找不到
|
||
|
||
重启后新 shell `which pnpm` 报 NOT FOUND(nvm 没加载,corepack shim 没启用)。
|
||
|
||
**修法**:`export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"; corepack enable`。之后 pnpm 在 PATH 里。见 §1.1。
|
||
|
||
### 12.4 heads-up 先行动者是 SB 而非按钮位
|
||
|
||
实现 `firstToAct = nextSeat(BB)` 后,2 人房里 button=P0 → SB=P1 → BB=P0 → firstToAct=nextSeat(P0)=P1。所以 preflop P1(SB)先动。符合标准 heads-up 规则(SB 先动 preflop,BB 先动 flop 及以后)。
|
||
|
||
**教训**:boardgame.io 的 `turn.order` 默认从 `ctx.currentPlayer` 推进;自己在 `startHandSetup` 里设 `firstToAct` 并配合 `turn.order.first` 才能精确控制 heads-up 的特殊行动顺序。
|