№ · 活跃设计 CH.07.13
主页 · 📐 活跃设计 · Phase 5 联机 P2P 房间骨架 v1.0

Phase 5 联机 P2P 房间骨架 v1.0

📅 2026-07-13 · 📦 34.5 KB · 📐 活跃设计 · 📝 MD 文档 🆕 14d 🏷️ Buff🏷️ 短工🏷️ 设计🏷️ 联机🏷️ 拍卖🏷️ 时间

整理时间:2026-07-01 17:30 +08:00 整理人:Mavis(PM 视角) 依据:

  • docs/design/active/产品需求总览-v1.md(D4 决策点)
  • docs/plans/active/2026-07-06-5日开发计划-v1.md(R3 主任务)
  • docs/design/completed/gameplay/官府抄缴库房竞标会-联机版玩法设计.md(联机设计母本)
  • docs/design/backlog/gameplay/p2p-multiplayer-playbook.md(P2P 玩法手册)
  • docs/data/auction/ + src/components/auction/(现有拍卖 UI / 配置)
  • docs/specs/engineering/code-structure-splitting-rules-v1.md(代码拆分规则) 本文档作用:把 Phase 5 联机 P2P 房间状态机 + 接口拍清楚,Day 4 短工能据此起骨架,Day 5+ 再逐步接入实时竞价。 本版本只起房间骨架:不实现实时竞价 / 不实现断线重连 / 不动 auctionRules.ts 现有规则。

0. TL;DR(60 秒版)

  • 房间状态机 = 5 态:idle → waiting → bidding → settled → closed
  • 接口拍 4 个:RoomHost 房主 / RoomGuest 客机 / RoomSeat 席位 / RoomAI AI 补位
  • 最小 UI 拍 2 块:房间卡片(状态总览) + 席位格子(玩家 / AI 占位)
  • 目录命名 multiplayer-lobby/,跟现有 auction/ 平级,auctionRules.ts
  • 数据载体 MultiplayerRoomState(跟 AuctionState 平级,新增 type,动现有 schema)
  • Day 4 验收 = type 通过 tsc + 状态机能单步推进 idle → waiting + 最小 UI 占位 + npm run build 通过

1. 房间状态机(5 态)

1.1 状态转换图

                    [房主创建]
                         ↓
[idle] ───────────→ [waiting] ──→ [bidding] ──→ [settled] ──→ [closed]
   ↑                  ↑    │                       │              │
   │                  │    └── [guest leave] ──────┘              │
   │                  └────── [host cancel] ──────────────────────┘
   │                                  ↓
   └──────────── [host reopen] ←──────┘

1.2 5 态语义(只拍口径)

| 态 | 语义 | 进入条件 | 退出条件 | 持续时间 | | :--- | :--- | :--- | :--- | :--- | | idle | 房间未创建或已 closed 后归档 | 初始态 / closed 终态 | 房主创建 → waiting | 不限时 | | waiting | 等待客机 / AI 补位 / 房主按"开始" | idle + 房主创建 | 满席 / 房主按开始 → bidding
房主取消 → closed
客机离开 → 回到 waiting(席不满) | 默认 5 分钟超时(可配) | | bidding | 实时竞价中 | waiting 满足开始条件 | 流拍 / 中止 → settled
房主中止 → closed | 由实时竞价规则控制(本 v1.0 不实现) | | settled | 本轮结算完成 | bidding 自然结束 | 房主"再来一局" → waiting
房主"关闭房间" → closed | 3-5 秒展示后自动跳到 waitingclosed | | closed | 房间关闭 | 任意态房主关闭 / waiting 超时 / settled 房主关闭 | 不可逆;MultiplayerRoomState 标记 archived: true | 终态 |

1.3 状态机约束(明确)

  • ❌ 不允许 idle → bidding 直跳
  • ❌ 不允许 settled → idle 回退(只能 waiting 重建)
  • ❌ 不允许 closed 复活
  • bidding 任意时刻房主可强制 closed(安全网)

1.4 不在本 v1.0 的事项

  • ❌ 实时竞价毫秒同步
  • ❌ 5 秒流拍
  • ❌ 恶意抬价 / 表情包互动
  • ❌ 断线重连 / AI 接管
  • ❌ 结算暂存 / 重连回写
  • bidding / settled 内部子状态机(留 v1.1)

2. 4 个接口(只拍 type,Day 4 短工可派生)

2.1 RoomHost 房主接口

interface RoomHost {
  hostId: string;              // 房主 playerId(从 GlobalProfile)
  displayName: string;
  createdAt: number;           // 房间创建时间戳
  authority: 'host';           // 权威节点 = 房主(已有规约)
  availableActions: ReadonlyArray<
    | 'cancel-room'            // 任意态关闭
    | 'start-bidding'          // waiting → bidding
    | 'force-close'            // 任意态强制 closed
    | 'reopen-after-settled'   // settled → waiting
  >;
}

2.2 RoomGuest 客机接口

interface RoomGuest {
  guestId: string;             // 客机 playerId
  displayName: string;
  joinedAt: number;            // 加入时间戳
  seatId: RoomSeatId;          // 占哪个席位
  availableActions: ReadonlyArray<
    | 'leave-room'             // 离开 → waiting 席不满
  >;
}

2.3 RoomSeat 席位接口

type RoomSeatId = 'seat-1' | 'seat-2' | 'seat-3' | 'seat-4';

interface RoomSeat {
  seatId: RoomSeatId;
  occupant: RoomHost | RoomGuest | RoomAI | null;
  lockedAt: number | null;     // 占座时间戳
}

2.4 RoomAI AI 补位接口

interface RoomAI {
  aiId: 'ai-classic-bidder' | 'ai-cautious-bidder' | 'ai-aggressive-bidder';
  displayName: string;         // 例如"老江湖 / 保守贾 / 豪气张"
  seatId: RoomSeatId;
  joinedAt: number;
  availableActions: ReadonlyArray<'leave-room'>;
}

2.5 不在本 v1.0 的事项

  • ❌ 客机的"出价"动作(留 v1.1 实时竞价)
  • ❌ AI 的"出价"策略实现(留 v1.1)
  • ❌ 客机重连(留 v1.1 断线重连)
  • ❌ 观战者接口(留 v1.1)
  • ❌ 房间规模 4 / 6 / 8 选(本 v1.0 默认 4 席)

3. 数据载体:MultiplayerRoomState

3.1 跟 AuctionState 的关系

  • AuctionState = 现有联机拍卖的"拍卖实例"数据
  • MultiplayerRoomState = 新增的"房间实例"数据
  • 关系:MultiplayerRoomStateAuctionState 的"前置容器",但本 v1.0 联通两者,各自独立
  • auctionRules.ts 任何字段

3.2 MultiplayerRoomState type

type RoomPhase = 'idle' | 'waiting' | 'bidding' | 'settled' | 'closed';

interface MultiplayerRoomState {
  roomId: string;              // 房间唯一 ID
  phase: RoomPhase;            // 当前态(见 § 1.2)
  host: RoomHost | null;       // 房主(idle 时为 null)
  seats: RoomSeat[];           // 默认 4 席(seat-1 ~ seat-4)
  guests: RoomGuest[];         // 客机列表(可空)
  ai: RoomAI[];                // AI 补位列表(可空)
  createdAt: number;
  updatedAt: number;
  signature: string;           // 防篡改签名(沿用项目惯例)
}

3.3 落盘与隔离

  • 存储 Key:tongfu_multiplayer_room_<roomId>(跟 tongfu_save_slot_* 平级)
  • 隔离:MultiplayerRoomState GlobalProfile,PlaythroughSaveSlot
  • 清理:closed 态的 room 保留 1 周后自动归档(留 v1.1)

3.4 不在本 v1.0 的事项

  • ❌ 跟 AuctionState 的实际联通
  • ❌ 房间历史 / 战绩统计
  • ❌ 房间模板 / 预设配置
  • ❌ 跨设备同步(本 v1.0 是本地 P2P 模拟)

4. 最小 UI 拍 2 块(占位即可)

4.1 房间卡片(RoomCard.tsx)

┌─────────────────────────────────────────┐
│ 房间 #R-2026-07-09-001  状态:[waiting]  │
│ ─────────────────────────────────────── │
│ 房主:白展堂  (host)                     │
│ 席位: ① 白展堂  ② 吕秀才  ③ AI-老江湖  ④ [空] │
│ ─────────────────────────────────────── │
│ [开始竞价(房主)]  [关闭房间(房主)]       │
│ [加入(客机)]  [离开(客机)]              │
└─────────────────────────────────────────┘
  • 组件文件:src/components/multiplayer-lobby/RoomCard.tsx
  • 状态:由 props 传入 MultiplayerRoomState,接数据流(占位)
  • 样式:复用 components/auction/AuctionPanel.tsx 既有拟物化类(.tongfu-parchment .tongfu-button-wooden)

4.2 席位格子(RoomSeatCell.tsx)

┌──────────┐
│ ① 席位 1  │
│ [玩家名]  │
│ / [AI 名] │
│ / [空]    │
└──────────┘
  • 组件文件:src/components/multiplayer-lobby/RoomSeatCell.tsx
  • 状态:由 props 传入 RoomSeat,接数据流(占位)
  • 复用:跟 StaffRegisterModal.tsx 的"员工卡"样式一致

4.3 入口占位

  • src/components/multiplayer-lobby/MultiplayerLobby.tsx(最小入口,只放 RoomCard 列表)
  • 接入位置:接入 App.tsx 主导航(留 v1.1,避免污染当前 in-flight)
  • 入口方式:?tag:v2|task|2026-07-09-multiplayer-and-storyline 短工验证时通过 URL query 触发

4.4 不在本 v1.0 的事项

  • ❌ 实时竞价进度条 / 倒计时
  • ❌ 出价历史 / 流拍动画
  • ❌ 表情包 / 聊天
  • ❌ 跟现有 auction/ 入口的联通

5. 文件边界(明确)

5.1 Day 4 短工允许动

  • 新增 src/components/multiplayer-lobby/RoomCard.tsx
  • 新增 src/components/multiplayer-lobby/RoomSeatCell.tsx
  • 新增 src/components/multiplayer-lobby/MultiplayerLobby.tsx
  • 新增 src/data/multiplayer/types.ts(只放 type)
  • 新增 src/data/multiplayer/roomStateMachine.ts(状态机纯函数)
  • 新增 src/utils/multiplayer/roomReducer.ts(跟 accountProfile.ts 风格一致)

5.2 Day 4 短工允许动

  • src/data/auction/**(不动现有拍卖数据)
  • src/utils/auctionRules.ts(不动现有拍卖规则)
  • src/components/auction/**(不动现有拍卖 UI)
  • src/App.tsx(不接主导航)
  • src/types.ts(RoomHost/RoomGuest 等 type 放 src/data/multiplayer/types.ts,污染全局)
  • docs/handoff/index.md 当前 in-flight

5.3 目录命名规范

  • multiplayer-lobby/(连字符)跟 auction/console/ 既有目录风格一致
  • 不用 multiplayerLobby/(不用驼峰目录)

6. 验收口径(R3 短工派发时)

R3 短工派发后,完成标准 = 全部满足:

  1. src/data/multiplayer/types.ts 包含 RoomHost / RoomGuest / RoomSeat / RoomAI / MultiplayerRoomState / RoomPhase 6 个 type
  2. src/data/multiplayer/roomStateMachine.ts 导出 transitionRoom(phase, action) → phase 纯函数,支持 idle → waiting 单步推进
  3. src/components/multiplayer-lobby/RoomCard.tsx + RoomSeatCell.tsx + MultiplayerLobby.tsx 3 个组件落盘
  4. cmd /c npm run build 通过,npm run lint 无新增错误
  5. docs/handoff/tasks/2026-07-09-multiplayer-and-storyline.md task 卡已创建(已写)
  6. auctionRules.ts / auction/ / App.tsx 任何文件被改动

6.1 不算收口的情形

  • ❌ 6 个 type 任一缺失
  • ❌ 状态机函数跑不通 idle → waiting
  • ❌ 动了 auctionRules.tsauction/**
  • ❌ 接入了 App.tsx 主导航
  • ❌ build 报错

6.2 R3 短工可保留的 residual risk

  • ✅ 实时竞价毫秒同步(本 v1.0 不实现)
  • ✅ 5 秒流拍 / 恶意抬价 / 表情包(本 v1.0 不实现)
  • ✅ 断线重连 / AI 接管(本 v1.0 不实现)
  • ✅ 跟 AuctionState 实际联通(本 v1.0 独立)
  • ✅ 房间卡片 UI 的视觉稿细化(留 UI 重构 lane)
  • ✅ 房间规模 4 / 6 / 8 选(本 v1.0 默认 4 席)

7. 不在本 v1.0 的事项汇总(明确划走)

  • ❌ 实时竞价毫秒同步
  • ❌ 5 秒流拍
  • ❌ 恶意抬价 / 表情包
  • ❌ 断线重连 / AI 接管
  • ❌ 结算暂存 / 重连回写
  • bidding / settled 内部子状态机
  • ❌ 客机"出价"动作
  • ❌ AI"出价"策略
  • ❌ 观战者接口
  • ❌ 房间规模选(本 v1.0 默认 4 席)
  • ❌ 跟 AuctionState 实际联通
  • ❌ 房间历史 / 战绩统计
  • ❌ 房间模板 / 预设配置
  • ❌ 跨设备同步
  • closed 后自动归档(留 v1.1)
  • ❌ 实时竞价进度条 / 倒计时
  • ❌ 出价历史 / 流拍动画
  • ❌ 表情包 / 聊天
  • ❌ 跟 auction/ 入口联通
  • ❌ 接入 App.tsx 主导航
  • ❌ 改 auctionRules.ts / auction/** / types.ts

8. 参考入口

| 想看什么 | 去这里 | | :--- | :--- | | 5 日计划上游 | docs/plans/active/2026-07-06-5日开发计划-v1.md § R3 | | 产品总览 | docs/design/active/产品需求总览-v1.md § 8.2 D4 | | 联机设计母本 | docs/design/completed/gameplay/官府抄缴库房竞标会-联机版玩法设计.md | | P2P 玩法手册 | docs/design/backlog/gameplay/p2p-multiplayer-playbook.md | | 现有拍卖实现 | src/components/auction/ + src/utils/auctionRules.ts + src/data/auction/ | | 代码拆分规则 | docs/specs/engineering/code-structure-splitting-rules-v1.md | | AGENTS.md 三层原则 | AGENTS.md § 1 | | PM 角色铁律 | docs/handoff/roles/pm.md |


9. 版本与变更记录

| 版本 | 时间 | 整理人 | 关键变更 | | :---: | :---: | :--- | :--- | | v1.0 | 2026-07-01 17:30 | Mavis(PM 视角) | 首版:拍 5 态状态机 + 4 个接口 + MultiplayerRoomState type + 2 块最小 UI + 文件边界;不实现实时竞价 / 不动 auction 现有规则 / 不接 App.tsx 主导航 | | v1.1(待) | — | — | 待实时竞价 spec 拍板后,补 bidding/settled 子状态机 + 出价动作 + 跟 AuctionState 联通 + 接入主导航 | | v1.1 | 2026-07-13 | [长工]#后续任务连续执行推进@v1.0 | R7/R8/R10 跑完后增量:只增不删追加 § 10 v1.1 增量段。补 RealtimeBidState 4 态(waiting/bidding/antiSnipe/settled)+ ReconnectState 4 态(online/reconnecting/recovered/offline)+ 5 个 reducer 函数签名(applyEvent / applyTimeout / applyDisconnect / applyReconnect / transferHost)+ 3 个组件(RealtimeBidPanel / ReconnectBanner / RoomCard 升级)+ R14 跨周目联动 1 行引用 + R10 长工 = summarized-partial-gate-waived-by-p1 明示标注 + WebRTC 留 v1.4 / 跨设备同步留 Phase 7 残余风险。v1.0 段作 W28 历史母本完整保留(§ 0-§ 9 一字未动)。settledsettling 命名微调仅在 v1.1 增量段说明,回改 v1.0 § 1.2。|


10. v1.1 增量(R7/R8/R10 跑完后)

整理时间:2026-07-13(Asia/Shanghai) 整理人:[长工]#后续任务连续执行推进@v1.0 增量范围:只增不删。本节为 v1.1 增量段,§ 0-§ 9 之上追加。v1.0 段(§ 0-§ 9)作 W28 历史母本完整保留,回改。 承接:docs/handoff/tasks/2026-07-13-multiplayer-skeleton-sync.md(本卡)+ R7/R8/R10 实际代码产物。 依据:

  • R7 实时竞价:src/data/multiplayer/realtimeBid.ts + src/utils/multiplayer/realtimeBidding.ts + src/utils/multiplayer/realtimeBidding.regression.ts
  • R8 流拍 + 断线重连:src/utils/multiplayer/timeout.ts + src/utils/multiplayer/reconnect.ts + src/utils/multiplayer/timeout.regression.ts + src/utils/multiplayer/reconnect.regression.ts
  • R10 UI 集成:src/components/multiplayer-lobby/RealtimeBidPanel.tsx + ReconnectBanner.tsx + RoomCard.tsx(升级)+ MultiplayerLobby.tsx + RoomSeatCell.tsx
  • R14 跨周目:docs/design/active/跨周目v1.1锚点-v1.md + src/data/endgame/multiRun.ts + src/utils/endgame/multiRun.ts
  • 5 日计划 v1.1:docs/plans/active/2026-07-13-5日开发计划-v1.md § 1 / § 2 / § 4 / § 16.3

10.0 TL;DR(60 秒版,v1.1 增量)

  • R7 实时竞价 = RealtimeBidState 4 态(waiting → bidding → antiSnipe → settled)+ 反狙击(最后 30s 内出价 → 自动 +30s,extends++)+ 最小出价单位 = currentBid * 0.05
  • R8 5s 流拍 + 断线重连 = ReconnectState 4 态(online → reconnecting → recovered → offline,对齐 RoomConnectionState)+ 5s 流拍(applyTimeout)+ 30s 重连窗口(ROOM_RECONNECT_WINDOW_MS)+ 60s 房主转移(transferHost 把房主权威给第一个 online / recovered seat)
  • R10 UI 集成 = RoomCard 升级(v1.0 仅占位,v1.1 内嵌 ReconnectBanner + 4 态 + 当前 bid + 反狙击倒计时 + RealtimeBidPanel)+ MultiplayerLobby 接入 R7/R8 reducer + 主导航 multiplayer tab 入口
  • 5 个 reducer 函数签名:applyEvent / applyTimeout / applyDisconnect / applyReconnect / transferHost
  • 3 个新/升级组件:RealtimeBidPanel / ReconnectBanner / RoomCard(升级)
  • R10 长工状态明示标注 = summarized-partial-gate-waived-by-p1(P1 于 2026-07-12 13:06:48 +08:00 豁免;R10 仍缺真实 in-browser 4 态切换 + 掉线手验 5 场景,缺口由 R10-followup 短工 + R11 收口文档双层记录)
  • R10 owner review = pending(非阻塞);视觉 checklist 与浏览器截图只认实际产物,当前不得代签为 approved
  • R14 跨周目联动(1 行):联机房间的 settlingPlaythroughSaveSlot(v1.0 锁保留),跨周目 startDay 边界触发 computeLegacyPoints(房间是 transient 容器,非周目结算)
  • 残余风险(v1.1 增量):WebRTC 留 v1.4 / 跨设备同步留 Phase 7 / 联机 UI 动效 + 移动端适配 + 暗色模式均留 v1.1 续
  • 命名微调:settledsettling(v1.0 § 1.2 仍写 settled 作历史母本;v1.1 增量段说明:RoomPhase 实际 type = 'idle' | 'waiting' | 'bidding' | 'settling' | 'closed',代码已统一)

10.1 增量状态机(R7/R8 拍)

10.1.1 RealtimeBidState 4 态(R7 拍,type 落 src/data/multiplayer/realtimeBid.ts)

export type RealtimeBidState = 'waiting' | 'bidding' | 'antiSnipe' | 'settled';

export const REALTIME_BID_WINDOW_MS = 5_000;                 // 主 5s 流拍窗口
export const REALTIME_BID_ANTI_SNIPE_WINDOW_MS = 30_000;     // 进入 antiSnipe 阈值(30s 前)
export const REALTIME_BID_ANTI_SNIPE_EXTENSION_MS = 30_000;  // 触发后延长 30s

| 态 | 语义 | 进入条件 | 退出条件 | | :--- | :--- | :--- | :--- | | waiting | 未开拍 | 初始态 | 房主/客机首拍 → bidding | | bidding | 主出价中(5s 倒计时) | startBid 事件 | 最后 30s 内新出价 → antiSnipe / 5s 无新出价 → settled | | antiSnipe | 反狙击延长期(30s 倒计时) | bidding 最后 30s 内 placeBid | 30s 无新出价 → settled / 新出价 → 继续 antiSnipe(extends++) | | settled | 本轮已结束 | applyTimeout 检测到期 / settle 事件 | 不可逆;房主"再来一局" → RoomPhase 重置 |

10.1.2 ReconnectState 4 态(R8 拍,type 落 src/data/multiplayer/types.ts)

export type RoomConnectionState = 'online' | 'reconnecting' | 'recovered' | 'offline';
export type ReconnectState = RoomConnectionState;   // 别名,语义一致
export const ROOM_RECONNECT_WINDOW_MS = 30_000;

| 态 | 语义 | 进入条件 | 退出条件 | | :--- | :--- | :--- | :--- | | online | 在线 | 默认态 / recovered 后下一拍 | 收到断线信号 → reconnecting | | reconnecting | 30s 重连窗口中 | applyDisconnect(seatId, now) | applyReconnect 在 30s 内 → recovered / 超期 → offline | | recovered | 重连成功,保留 live bid 上下文 | applyReconnect 在 30s 内 | 下一拍自然 → online | | offline | 重连窗口超期,标记为离线历史 | 30s 内未重连 | 房主 → 60s 触发 transferHost / 客机 → 席位标记 takeover |

10.1.3 与 v1.0 房间状态机的关系

  • v1.0 § 1 RoomPhase 5 态(idle / waiting / bidding / settled / closed)保留为 W28 历史母本
  • v1.1 增量:代码已将 v1.0 settled 重命名为 settling(src/data/multiplayer/types.ts:9:type RoomPhase = 'idle' | 'waiting' | 'bidding' | 'settling' | 'closed'),v1.1 增量段沿用 settling,回改 v1.0 § 1.2
  • RealtimeBidStateRealtimeBidSnapshot.state 字段(R7 拍),跟 RoomPhase 平级互通
  • ReconnectStateRoomSeat.connectionState 字段(R8 拍),跟 RoomPhase 平级互通
  • 关键:R7/R8 子状态机 + R10 房间状态机 = 3 层叠加(房间层 / 出价层 / 重连层),各自独立推进

10.2 增量数据流(5 个 reducer 函数签名)

约束:全部纯函数,接受 now: number 注入,直接调 Date.now();auction/**;PlaythroughSaveSlot;settling 态字段变更只在内存中。

10.2.1 applyEvent — R7 实时竞价事件 reducer

// src/utils/multiplayer/realtimeBidding.ts
export const applyEvent = (
  snapshot: RealtimeBidSnapshot,
  event: RealtimeBidEvent,  // { kind: 'startBid' | 'placeBid' | 'extendAntiSnipe' | 'settle'; payload: { bidderId; amount; timestamp } }
): RealtimeBidSnapshot;
  • 处理 4 类事件:startBid / placeBid / extendAntiSnipe / settle
  • 自动检测 antiSnipe(shouldAntiSnipe(snapshot, timestamp)):若 placeBid 落在 deadlineAt - 30s 之前 → 自动 extendDeadline
  • 失败回退(非法 amount / 状态不匹配)→ 返回原 snapshot(不变)

10.2.2 applyTimeout — R8 5s 流拍 reducer

// src/utils/multiplayer/timeout.ts
export const applyTimeout = (
  snapshot: RealtimeBidSnapshot,
  now: number,
): RealtimeBidSnapshot;
  • snapshot.state === 'bidding' | 'antiSnipe'now > deadlineAt → 返回 state: 'settled'
  • 其他态(等待/已结算)→ 返回原 snapshot
  • 单元测试覆盖(R8 验收 5 场景之 ①):出价后 5s 无新出价 → 自动 settled

10.2.3 applyDisconnect — R8 客机/房主断线 reducer

// src/utils/multiplayer/reconnect.ts
export const applyDisconnect = (
  roomState: MultiplayerRoomState,
  seatId: string,
  now: number,
): MultiplayerRoomState;
  • 找到目标 seat → connectionState: 'reconnecting' + reconnectDeadlineAt: now + 30_000 + disconnectStartedAt: now
  • 同步更新 participant.ready = false
  • realtimeBidSnapshot 变(R7 拍:断线不丢 live bid 上下文)
  • 单元测试覆盖(R8 验收 5 场景之 ② / ④)

10.2.4 applyReconnect — R8 重连成功 reducer

// src/utils/multiplayer/reconnect.ts
export const applyReconnect = (
  roomState: MultiplayerRoomState,
  seatId: string,
  now: number,
): MultiplayerRoomState;
  • 仅当 connectionState === 'reconnecting'now <= reconnectDeadlineAtconnectionState: 'recovered'
  • 同步更新 participant.ready = true
  • realtimeBidSnapshot 变(关键:重连后保留 live bid 上下文)
  • 超期调用 → 返回原 snapshot(无效)
  • 单元测试覆盖(R8 验收 5 场景之 ② / ④)

10.2.5 transferHost — R8 房主断线 60s 转移 reducer

// src/utils/multiplayer/reconnect.ts
export const transferHost = (
  roomState: MultiplayerRoomState,
  now: number,
): MultiplayerRoomState;
  • 仅当房主 seat connectionState === 'offline'(30s 重连窗口已超期)→ 触发转移
  • 找第一个 online / recovered 的非房主 seat → 升级为新 RoomHost
  • 原房主降级为 RoomGuest(convertHostToGuest)+ 原 seat 标记 status: 'takeover'
  • 新房主 seat 标记 status: 'occupied',locked: true
  • lastHostTransfer: { fromSeatId; toSeatId; fromOccupantId; toOccupantId; transferredAt: now }
  • 单元测试覆盖(R8 验收 5 场景之 ⑤)

10.2.6 5 个 reducer 的调用关系

[bidder UI click] -> onBid(amount) -> applyEvent(snapshot, { kind: 'placeBid', payload })
                                                                  ↓ (snapshot 变化)
                                                       RealtimeBidPanel 重渲染
                                                                  ↓
                                                       (setInterval 1s tick)
                                                                  ↓
                                                       applyTimeout(snapshot, now)
                                                                  ↓
                                                       (state === 'settled')
                                                                  ↓
                                                       handleBidSettled callback

[WebRTC onDisconnected] -> applyDisconnect(roomState, seatId, now)
                                                                  ↓
                                                       ReconnectBanner 渲染 "XX 正在重连 30s"
                                                                  ↓
                                                       (30s 内) applyReconnect → ReconnectBanner 渲染 "XX 已重连"
                                                       (30s 外) → connectionState='offline' → ReconnectBanner 渲染 "XX 已离线"
                                                                  ↓
                                                       (host seat, 60s) → transferHost → ReconnectBanner 渲染 "房主已转移给 YY"

10.3 增量 UI(R10 拍,3 个组件 + 1 个升级)

10.3.1 RealtimeBidPanel.tsx(新,src/components/multiplayer-lobby/)

  • 位置:嵌入 RoomCard 底部
  • Props:{ snapshot: RealtimeBidSnapshot; onBid: (amount: number) => void; onSnapshotChange?: (nextSnapshot) => void; onSettled?: (finalBid, finalBidder) => void }
  • 核心功能:
    • 状态徽章 + 标签(REALTIME_BID_STATE_LABELS):waiting / bidding / antiSnipe / settled
    • antiSnipe 时显「Anti-snipe +{extends}」+ 倒计时
    • Highest Bid 卡 + Leader 卡
    • Deadline 倒计时(setInterval 1s tickapplyTimeout 自动 settled)
    • 出价输入框(min = currentBid + computeMinIncrement(currentBid))
    • 提交按钮(失效条件:amount < minstate === 'settled')
  • 状态机边界:本组件applyDisconnect / applyReconnect / transferHost,这些走 ReconnectBanner
  • R10 视觉:复用 tongfu-parchment + tongfu-button-wooden 既有类,跟 auction/console/ 视觉一致

10.3.2 ReconnectBanner.tsx(新,src/components/multiplayer-lobby/)

  • 位置:RoomCard 顶部条幅
  • Props:{ roomState: MultiplayerRoomState; now: number }
  • 三态渲染:
    1. reconnecting seat 存在(任一 seat 处于 reconnecting):蓝色条幅 + WifiSync 图标 + "XX 正在重连(倒计时 N 秒)"
    2. offline seat 存在(任一 seat 处于 offline):红色条幅 + WifiOff/AlertTriangle 图标(房主用 AlertTriangle)+ "XX 已离线"
    3. lastHostTransfer 在 3s 内:琥珀色条幅 + Crown 图标 + "房主已转移给 YY"
  • 优先级:lastHostTransfer > reconnecting > offline(都无 → 渲染 null)
  • R8 边界:ReconnectBanner 只读 roomState,触发 reducer;reducer 在 MultiplayerLobby 1s tick 里跑

10.3.3 RoomCard.tsx(升级,v1.0 占位 → v1.1 集成)

  • v1.0 状态:仅占位卡片 + RoomSeatCell 4 块 + 不接数据流
  • v1.1 升级:
    • 顶部:内嵌 ReconnectBanner
    • 头部徽章:RoomPhase(ROOM_PHASE_LABELS)+ roomCode(如 YM-XXXX)
    • 4 个数据卡:Seats(锁定/空席)/ Sync(Funds frozen or pending)/ Lots(房间 lot 数)/ Occupancy
    • 4 个状态卡:Bid State(REALTIME_BID_STATE_LABELS)/ Current Bid(leader 名字)/ Anti-snipe(倒计时 / Inactive)/ Occupancy(active seats)
    • 4 块 RoomSeatCell(沿用 v1.0 组件)
    • 底部:内嵌 RealtimeBidPanel
    • 底部条:阶段推进按钮(onAdvanceadvanceRoomPhase)
  • R10-followup 视觉调整边界:动视觉调整,动 props 签名(防止撞 R7/R8 reducer 集成);主导航 multiplayer tab 入口走 R10-followup 单独改 App.tsx

10.3.4 MultiplayerLobby.tsx(升级)

  • v1.0 状态:最小入口,只放 RoomCard 列表(占位)
  • v1.1 升级:
    • 5 态阶段条(ROOM_PHASE_ORDER + 高亮当前 phase)
    • 1s tick:setIntervaltransferHost(roomState, Date.now())(只在无外部 room props 时)
    • handleBid(amount):构造 placeBid / startBid 事件 → 调 applyBid(= applyEvent 别名)→ syncBidSnapshot
    • handleBidSettled:state 强制写 settled + statusMessage 写"Current lot settled with X at Y"
    • readOnly 模式(readOnly=true → 推进按钮禁用)

10.3.5 不在 v1.1 增量的 UI

  • ❌ 联机 panel 动效(留 v1.1 续)
  • ❌ 移动端适配(留 v1.1 续)
  • ❌ 暗色模式切换(留 v1.1 续)
  • ❌ 表情包 / 弹幕 / 聊天(留 W30+)
  • ❌ 真实网络动画 / WebRTC 进度条(留 v1.4)

10.4 跨 Lane 接入(R14 联动 + 落盘边界)

10.4.1 R14 跨周目 v1.1 锚点(1 行引用)

R14 跨周目 v1.1 锚点已落:docs/design/active/跨周目v1.1锚点-v1.md(2026-07-13 收口),含 CrossWeekAnchor / WeekRunSnapshot type + computeLegacyPoints(weekNum, baseScore, mode) 纯函数 + CROSS_WEEK_LEGACY_DECAY = [1.0, 0.8, 0.6, 0.4] 常量。R14 与本骨架的边界:联机房间的 settling 态不写 PlaythroughSaveSlot,跨周目 startDay 边界触发 computeLegacyPoints(房间是 transient 容器,非周目结算)。

10.4.2 v1.0 锁保留(显式重述)

  • ❌ 联机房间GlobalProfile(v1.0 § 3.3 锁)
  • ❌ 联机房间PlaythroughSaveSlot(v1.0 § 3.3 锁)
  • ❌ 联机房间auctionRules.ts 任何字段(v1.0 § 3.3 锁)
  • ❌ 联机房间联通 AuctionState(v1.0 § 3.1 锁)
  • ✅ 联机房间的 settling 态字段变更在内存 + tongfu_multiplayer_room_<roomId> 落盘
  • ✅ 跨周目 startDay 边界由单机 victoryConditions.ts + computeLegacyPoints 计算

10.4.3 跟 R7/R8/R9 的文件边界(强制,跟 v1.0 § 5 对齐)

  • ✅ R7 落 src/data/multiplayer/realtimeBid.ts(type only)+ src/utils/multiplayer/realtimeBidding.ts(纯函数)+ src/components/multiplayer-lobby/RealtimeBidPanel.tsx
  • ✅ R8 落 src/utils/multiplayer/timeout.ts + src/utils/multiplayer/reconnect.ts + src/components/multiplayer-lobby/ReconnectBanner.tsx
  • ✅ R10 升级 src/components/multiplayer-lobby/RoomCard.tsx + MultiplayerLobby.tsx + RoomSeatCell.tsx(微调)+ src/App.tsx multiplayer tab 入口 3-5 行
  • ❌ 任何 lane 都src/data/auction/** / src/utils/auctionRules.ts / src/components/auction/**
  • ❌ 任何 lane 都src/data/endgame/** / src/utils/endgame/**(multiRun.ts 是 R14 独立 lane)
  • ❌ 任何 lane 都story/** / src/data/items/** / src/components/item/**

10.5 残余风险(v1.1 增量显式标注)

| 风险 ID | 风险描述 | 状态 | 缓解 / 后续 lane | | :-: | :--- | :---: | :--- | | rr-1 | WebRTC 真实网络实装缺失 | ⚠️ 留 v1.4 | 当前是 local P2P 模拟(模拟掉线 / 模拟出价都在单机内跑);v1.4 计划引入真实 WebRTC 房间信号 | | rr-2 | 跨设备同步缺失 | ⚠️ 留 Phase 7 | tongfu_multiplayer_room_<roomId> 只存本机;Phase 7 才会接云存档 / 跨设备 | | rr-3 | R10 长工 = summarized-partial-gate-waived-by-p1(P1 7.12 13:06 豁免) | ⚠️ 明示标注 | R10 仍缺 4 态切换 4 张 + 掉线 3 张 = 7 张真实手验截图;由 R10-followup 短工(docs/handoff/tasks/2026-07-13-r10-browser-walkthrough.md)+ R11 收口文档(docs/handoff/tasks/2026-07-17-w29-closeout.md)双层登记;豁免不等于 verified | | rr-4 | R10 视觉对照 checklist 12+6=18 项 | ⚠️ owner review: pending(非阻塞) | docs/design/active/W29联机UI视觉对照checklist-v1.md 由 R10-followup 落盘;只认实际 checklist / 截图证据,不得代签为 approved | | rr-5 | 联机 UI 动效 / 移动端适配 / 暗色模式 | ⚠️ 留 v1.1 续 | W29 范围外,留 v1.1 续 / v1.2 lane | | rr-6 | settledsettling 命名微调 | ⚠️ 历史母本保留 | v1.0 § 1.2 仍写 settled(W28 历史母本);代码已统一 settling;v1.1 增量段沿用 settling | | rr-7 | 5 场景真实手验缺口 | ⚠️ 留 R10-followup | 4 态切换 + 模拟掉线 + 房主转移 = 7 张截图;留 R10-followup 短工补手验 | | rr-8 | settling 内部子状态机未细分 | ⚠️ 留 v1.1.1 | 当前 settling 是单态(settlement 走 applyTimeout 一步到 settled);细分(正在结算中 / 结算待确认)留 v1.1.1 |


10.6 验收 + 不做(v1.1 增量)

10.6.1 v1.1 增量验收(本 doc-sync lane 必查)

  • [x] RealtimeBidState 4 态 type 落盘(src/data/multiplayer/realtimeBid.ts:6)
  • [x] ReconnectState(= RoomConnectionState)4 态 type 落盘(src/data/multiplayer/types.ts:14)
  • [x] 5 个 reducer 函数签名落盘:
    • applyEvent(src/utils/multiplayer/realtimeBidding.ts:51)
    • applyTimeout(src/utils/multiplayer/timeout.ts:8)
    • applyDisconnect(src/utils/multiplayer/reconnect.ts:137)
    • applyReconnect(src/utils/multiplayer/reconnect.ts:161)
    • transferHost(src/utils/multiplayer/reconnect.ts:190)
  • [x] 3 个新/升级组件落盘:
    • RealtimeBidPanel(src/components/multiplayer-lobby/RealtimeBidPanel.tsx)
    • ReconnectBanner(src/components/multiplayer-lobby/ReconnectBanner.tsx)
    • RoomCard 升级(src/components/multiplayer-lobby/RoomCard.tsx)
  • [x] 跨 Lane 接入段(§ 10.4)R14 1 行引用已写
  • [x] 残余风险段(§ 10.5)R10 长工 = summarized-partial-gate-waived-by-p1 明示标注
  • [x] v1.0 段(§ 0-§ 9)作 W28 历史母本完整保留,一字未动
  • [x] 文档 v1.1 落盘 7 段真填(§ 10.0 - § 10.6)
  • [x] 纯文档,src/** 改动(本 lane 唯一动作 = docs/design/active/联机P2P房间骨架-v1.md)

10.6.2 v1.1 增量做(明示)

  • ❌ 重写 v1.0 任何段(只增不删,锁保留)
  • ❌ 改 src/** 任何文件(纯文档 lane)
  • ❌ 重开 R7 / R8 / R10 任何 lane
  • ❌ 写 5 场景真实手验(留 R10-followup)
  • ❌ 落 W29 视觉对照 checklist(留 R10-followup)
  • ❌ 拍 P1 视觉拍板结果(短工代决视觉)
  • ❌ 改 v1.0 § 1.2 settledsettling(历史母本保留,代码已统一)
  • ❌ 写 PlaythroughSaveSlot / GlobalProfile / auctionRules.ts(v1.0 锁)

10.7 v1.1 增量参考入口

| 想看什么 | 去这里 | | :--- | :--- | | R7 task 卡 | docs/handoff/tasks/2026-07-13-to-14-p2p-realtime-bidding.md | | R8 task 卡 | docs/handoff/tasks/2026-07-15-p2p-timeout-reconnect.md | | R10-followup task 卡(R10 长工 = summarized-partial-gate-waived-by-p1 缺口补全) | docs/handoff/tasks/2026-07-13-r10-browser-walkthrough.md | | R11 收口 task 卡(W29 收口) | docs/handoff/tasks/2026-07-17-w29-closeout.md | | R14 跨周目 v1.1 锚点(本节 § 10.4 联动引用) | docs/design/active/跨周目v1.1锚点-v1.md | | 5 日计划 v1.1 真相源 | docs/plans/active/2026-07-13-5日开发计划-v1.md § 1 / § 2 / § 4 / § 16.3 | | R7/R8/R10 联机长工 owner 卡 | docs/handoff/tasks/2026-07-13-w29-multiplayer-p2p-owner.md(state: summarized-partial-gate-waived-by-p1)| | R10 联机长工 session 卡 | docs/handoff/sessions/2026-07-13-w29-multiplayer-p2p-owner-*.md | | W28 历史母本(本节 § 0-§ 9 之上) | docs/design/active/联机P2P房间骨架-v1.md § 0-§ 9 |


10.8 派工触发链(流水化)

  • 本卡 = 流水化第 5 条(R10-followup callback 后立即派)
  • 骨架 sync callback 收到 → 管家接力:立即派 R11 收口(docs/handoff/tasks/2026-07-17-w29-closeout.md,已存在)+ 拉 P1 拍 W30 主题
  • 本短工回骨架 sync callback,R11 派工由管家(本 root session)接力

(End of § 10 v1.1 增量段)