# Phase 3 · 计票器（5 玩法 + 共通挂件）· Implementation Plan (技术大纲)

> 这是 Pawcast MVP 的**灵魂**。spec §1 + 设计稿 vote.html 详细描述 → 本 plan 转为代码模块清单。

**Goal:** 实装 5 玩法引擎（Sticker Dance / Duel Dance / Multi-PK / Solo Stage / Freedom）+ 礼物绑定 + 互动投票 + 12 预设 + 团员库 + 完整运行态 UI + 7 个挂件 HTML 输出。

**Architecture:** 玩法引擎抽象 + 子类策略模式 + 状态机驱动（IDLE → COLLECTING → READY → RUNNING → SETTLING → DONE）。预设 = 配置 JSON，挂件 = 独立 HTML 通过 WS 订阅 pubsub 频道。

**Tech Stack:** Vite 多入口 + React + Zustand + WebSocket + 之前所有 packages

---

## File Structure

```
packages/core/src/vote-engine/
  ├── base.ts                     (BaseEngine 抽象类 + 状态机)
  ├── sticker-dance.ts            (StickerDanceEngine: 6×3 网格 + 衰减 + 轮播 + Gameplay Gift)
  ├── duel-dance.ts               (DuelDanceEngine: 1v1 + 连胜 + 救场)
  ├── multi-pk.ts                 (MultiPKEngine: 3-5 人 + 候补区)
  ├── solo-stage.ts               (SoloStageEngine: 阶段 + Fever Time)
  ├── freedom.ts                  (FreedomEngine: 单人轮换)
  ├── gift-binding.ts             (GiftBindingEngine: 单/多礼物绑定 + 用户绑定记录)
  ├── interactive-vote.ts         (InteractiveVoteEngine: 评论/点赞 → 票)
  ├── score-decay.ts              (Decay/Carousel/Fever Time 子机制)
  ├── widget-pubsub.ts            (主进程 → 挂件 WS 推送桥)
  └── index.ts

packages/core/src/preset/
  ├── manager.ts                  (PresetManager: CRUD + 切换 + Apply to All)
  └── cast-library.ts             (团员库跨预设共享)

packages/db/src/repositories/
  ├── presets.ts                  (★ 新增)
  ├── broadcasters.ts             (★ 新增 复用 Phase 5 共享)
  └── (live-sessions, live-events, fans 在 Phase 7 已建)

packages/ipc-contract/src/
  └── vote.ts                     (VoteContract: ~30 个 RPC method)

apps/widgets/src/                 (★ Vite 多入口构建)
  ├── sticker-dance/index.html + main.tsx
  ├── duel-dance/index.html + main.tsx
  ├── multi-pk/index.html + main.tsx
  ├── solo-stage/index.html + main.tsx
  ├── freedom/index.html + main.tsx
  ├── ranking/index.html + main.tsx       (整场榜)
  ├── user-round-rank/index.html + main.tsx (单轮榜)
  └── shared/                             (动画 / 数字滚动 / pulse)

apps/desktop/src/pages/Vote/
  ├── index.tsx                   (Vote 主页 + 12 预设侧栏)
  ├── components/
  │   ├── PresetRail.tsx
  │   ├── CastSetting.tsx          (主播槽 + 礼物绑定)
  │   ├── modes/
  │   │   ├── StickerDanceConfig.tsx (6×3 网格配置)
  │   │   ├── DuelDanceConfig.tsx
  │   │   ├── MultiPKConfig.tsx
  │   │   ├── SoloStageConfig.tsx
  │   │   └── FreedomConfig.tsx
  │   ├── runtime/                 (运行态 UI，对应每个 mode)
  │   │   ├── StickerDanceRunning.tsx
  │   │   ├── DuelDanceRunning.tsx
  │   │   ├── MultiPKRunning.tsx
  │   │   ├── SoloStageRunning.tsx
  │   │   └── FreedomRunning.tsx
  │   ├── modals/
  │   │   ├── MemberLibraryModal.tsx
  │   │   ├── GameplayGiftModal.tsx
  │   │   ├── ManualBindingModal.tsx
  │   │   └── CopyUrlToast.tsx
  │   ├── ActionBar.tsx
  │   └── DataPanel.tsx           (右侧 Round Data / Ranking / Top Users)
  ├── stores/voteStore.ts
  └── hooks/useVoteEngine.ts
```

---

## Task 列表（按依赖顺序）

| # | 主题 | 关键文件 / 类 | 输出 |
|---|---|---|---|
| 1 | Migrations: presets / broadcasters 表 | `003-vote.sql` | 表 schema 落地 |
| 2 | PresetsRepo + BroadcastersRepo | `repositories/presets.ts`, `broadcasters.ts` | CRUD test 通过 |
| 3 | BaseEngine 状态机 | `vote-engine/base.ts` | 6 状态 + transition 测试 |
| 4 | GiftBindingEngine | `gift-binding.ts` | 单/多绑定 + 手动分配 + 用户绑定记录 |
| 5 | InteractiveVoteEngine | `interactive-vote.ts` | 评论/点赞 → 票 + 关键词匹配 + 冷却 |
| 6 | StickerDanceEngine | `sticker-dance.ts` | 6×3 网格 + 衰减 + A/B 轮播 + Gameplay Gift |
| 7 | DuelDanceEngine | `duel-dance.ts` | 守攻判定 + 连胜 + 救场动画 trigger |
| 8 | MultiPKEngine | `multi-pk.ts` | 3-5 人 + 候补换人 |
| 9 | SoloStageEngine | `solo-stage.ts` | 阶段进度 + Fever Time |
| 10 | FreedomEngine | `freedom.ts` | 单人轮换 + 目标分数 |
| 11 | WidgetPubsub | `widget-pubsub.ts` | 主进程 → 挂件 WS 推送 |
| 12 | PresetManager | `preset/manager.ts` | CRUD + 切换 + Apply to All |
| 13 | VoteContract IPC schema | `ipc-contract/vote.ts` | 30+ RPC method |
| 14 | 主进程 IPC wire-up | `apps/desktop/electron/ipc-router.ts` 扩展 | listPresets / savePreset / start / stop / etc. |
| 15 | apps/widgets vite multi-entry build | `apps/widgets/vite.config.ts` | 7 个独立 HTML 输出到 widget-server |
| 16 | StickerDance widget | `apps/widgets/src/sticker-dance/main.tsx` | 6×3 网格挂件 + 实时礼物动画 |
| 17 | DuelDance widget | 同上 | 双色进度条 + 中线特效 + 满屏 FX |
| 18 | MultiPK widget | 同上 | 3-5 横排进度条 |
| 19 | SoloStage widget | 同上 | 阶段叠加进度 + Fever Time 光波 |
| 20 | Freedom widget | 同上 | 单进度条 + 目标超额显示 |
| 21 | Ranking widget | 同上 | TOP N 主播榜（整场） |
| 22 | UserRoundRank widget | 同上 | TOP N 用户榜（单轮） |
| 23 | xlsx Exporter | `packages/core/src/exporter/xlsx.ts` (用 exceljs) | 主播榜 + 用户榜 + 礼物明细 sheets |
| 24 | Renderer Vote 主页 | `pages/Vote/index.tsx` | 12 预设侧栏 + 配置面板 + 运行态 UI |
| 25 | 5 个 Mode Config 组件 | `components/modes/*.tsx` | 1:1 还原设计稿 |
| 26 | 5 个 Mode Runtime 组件 | `components/runtime/*.tsx` | 1:1 还原设计稿 |
| 27 | 4 个 Modal 组件 | `components/modals/*.tsx` | 团员库 / Gameplay Gift / 手动绑定 / Copy URL |
| 28 | useVoteEngine hook + voteStore | `hooks/useVoteEngine.ts` | 订阅 IPC events 自动 re-render |
| 29 | 集成 e2e: 录制喂事件流 → 验证 UI + 挂件 | `tests/e2e/vote-flow.test.ts` | 全链路通过 |
| 30 | xlsx 导出 e2e | `tests/e2e/vote-export.test.ts` | 文件结构 + 数据正确 |

---

## 关键代码 Sketch

### BaseEngine 状态机

```typescript
// packages/core/src/vote-engine/base.ts
export type EngineState = 'IDLE' | 'COLLECTING' | 'READY' | 'RUNNING' | 'SETTLING' | 'DONE';

export abstract class BaseEngine {
  protected state: EngineState = 'IDLE';
  protected listeners = new Set<(state: EngineState) => void>();

  abstract onGiftEvent(ev: NormalizedEvent): void;
  abstract onChatEvent(ev: NormalizedEvent): void;
  abstract onLikeEvent(ev: NormalizedEvent): void;

  startCollecting(): void { this.transition('COLLECTING'); }
  ready(): void { this.transition('READY'); }
  start(): void {
    this.transition('RUNNING');
    if (this.config.round_time > 0) {
      setTimeout(() => this.end(), this.config.round_time * 1000);
    }
  }
  end(): void {
    this.transition('SETTLING');
    setTimeout(() => this.settle(), 3000);  // 3s 结算动画
  }
  protected settle(): void {
    this.persistResults();
    this.transition('DONE');
  }

  protected transition(next: EngineState): void {
    this.state = next;
    this.listeners.forEach((l) => l(next));
  }
}
```

### StickerDanceEngine 核心（6×3 网格）

```typescript
// packages/core/src/vote-engine/sticker-dance.ts
export class StickerDanceEngine extends BaseEngine {
  private cells: Map<string /*gift_id*/, { count: number; lastTs: number; bound_to?: number }>;
  private decayTimer: ReturnType<typeof setInterval>;

  onGiftEvent(ev: NormalizedEvent): void {
    if (this.state !== 'RUNNING') return;
    const giftId = ev.gift!.id;
    const cell = this.cells.get(giftId);
    if (!cell) return;

    cell.count += ev.gift!.count;
    cell.lastTs = ev.ts;
    this.publishWidget('cell_update', { giftId, count: cell.count });
  }

  private startDecayTimer(): void {
    if (!this.config.decay_enabled) return;
    this.decayTimer = setInterval(() => {
      const now = Date.now();
      for (const [giftId, cell] of this.cells) {
        if (now - cell.lastTs > this.config.decay_time * 1000 && cell.count > 0) {
          cell.count = 0;
          this.publishWidget('cell_decay', { giftId });
        }
      }
    }, 1000);
  }

  // Carousel: 配置启用后每 5s 推 'page_switch' 给挂件
}
```

### 挂件 WS 推送

```typescript
// packages/core/src/vote-engine/widget-pubsub.ts
import { pubsub } from '@pawcast/widget-server';

export class WidgetPubsub {
  publish(presetId: number, type: string, payload: unknown): void {
    pubsub.publish(`vote:${presetId}`, { type, payload, ts: Date.now() });
  }
}

// 挂件 HTML 端
// apps/widgets/src/sticker-dance/main.tsx
const ws = new WebSocket(`ws://127.0.0.1:17777/ws/widget/vote/${presetId}`);
ws.onmessage = (msg) => {
  const { type, payload } = JSON.parse(msg.data);
  if (type === 'cell_update') {
    document.querySelector(`[data-cell="${payload.giftId}"]`)
      ?.dispatchEvent(new CustomEvent('pulse', { detail: payload }));
  }
};
```

---

## 准入条件

- ✅ Phase 1 + Phase 2 已 merge

## 准出条件（MVP 灵魂里程碑）

- ✅ 配置 Sticker Dance 6×3 预设，4 主播，4 礼物绑定
- ✅ 启动采集 + 开始 → OBS Browser Source 加载挂件 URL，看到实时礼物贴纸 + 数字跳动
- ✅ Duel Dance 1v1 跑通完整一轮（Ready → Start → 倒计时 → End → 自动开下一轮）
- ✅ 一场结束后导出 .xlsx，包含主播榜 + 用户榜 + 礼物明细
- ✅ 切换预设挂件无缝切换（OBS 端不需重新加载源）
- ✅ Apply to All 跨预设复用主播配置
- ✅ 互动投票（评论 5 票 + 点赞 100:1）正确累计

## 关键技术决策

| 决策 | 理由 |
|---|---|
| 引擎抽象用策略模式 | 5 玩法共享状态机 + 礼物处理流程，差异在 onGiftEvent 计分逻辑 |
| 挂件用 Vite 多入口构建 7 个独立 HTML | 每挂件独立 URL + 各自 WS 频道，不互相干扰；Hot reload 也独立 |
| 数据流 main → pubsub → WS → 挂件 | 解耦：主进程 die 不影响挂件，可热更预设 |
| xlsx 用 exceljs（Pure JS） | 跨平台，不依赖 native binding |

## Self-Review

- ✅ 涵盖 spec §1 计票器全部 5 玩法 + 礼物绑定 + 互动投票 + 挂件输出
- ✅ design vote.html 描述的 12 屏 + 4 弹窗 全部映射到 task
- ✅ Phase 4 (Extras) 共用本 phase 的 widget-server pubsub 基础设施
