diff --git a/docs/features/F160-cat-journey.md b/docs/features/F160-cat-journey.md new file mode 100644 index 0000000000..0956c4cfb8 --- /dev/null +++ b/docs/features/F160-cat-journey.md @@ -0,0 +1,271 @@ +--- +feature_ids: [F160] +related_features: [F100, F093, F101, F129] +topics: [gamification, engagement, identity] +doc_kind: spec +created: 2026-04-10 +--- + +# F160: Cat Journey (猫猫足迹) — Activity Footprint Visualization + +> **Status**: Phase A-C complete, Phase D 6/7, Phase E 3/7 | **Owner**: Ragdoll | **Priority**: P1 +> +> **Naming pivot (ADR-023):** Renamed from "Cat Growth RPG". XP→足迹点, +> Level→历练, Achievement→珍贵瞬间, Radar→特质画像. See ADR-023 for rationale. + +## Why + +Clowder AI 的猫猫已经有持久身份、记忆和协作记录,但这些协作足迹对用户是不可见的。用户无法感知自己的猫团队在积累、在协作、在留下印记。 + +Cat Journey 系统把猫猫的真实协作数据"结晶"成可见、可感、可分享的足迹轨迹。这不是模拟数据,而是从真实 session events、task tracking、PR review 中自动结算的量化投影。 + +**重要区分**:Cat Journey 是活动投影/观测层(温度计),不是能力涌现层(体温)。 +真正的 Agent 成长(知识涌现、记忆沉淀)属于 F102/F152 的职责。 + +核心价值: +- **留存**:用户舍不得丢弃有丰富足迹的猫团队 +- **传播**:可分享的足迹卡和珍贵瞬间天然驱动社交传播 +- **差异化**:没有任何 AI 平台把 agent 协作足迹做成可见的存档 + +## What + +### Phase A: Attribute System + Cat Profile Card + +**六维属性体系**: + +| 维度 | 数据源 | Phase A 状态 | 说明 | +|------|--------|-------------|------| +| 架构力 | 讨论消息 (discussion) | ✅ 活跃 | 设计与系统思考 | +| 审查力 | Review 拦截率、bug 发现数 | ⏳ Phase B | 需 invocation intent tracking | +| 审美力 | Rich block 创建 (rich_block_create) | ✅ 活跃 | 视觉与体验 | +| 执行力 | tool_use / task_complete / session_seal | ✅ 活跃 | 落地交付 | +| 协作力 | 跨猫 @mention 协作 (mention_collab) | ✅ 活跃 | 团队配合 | +| 洞察力 | evidence search / reflect (evidence_cite) | ✅ 活跃 | 自驱与发现 | + +> **Phase A scope note**: 审查力 (review) 维度在 Phase A 中定义但未激活。 +> 根因:当前 A2A / multi-mention 系统的 `InvocationRecord` 不携带 invocation intent +> 元数据,无法在 route 层面区分「猫做 review」和「猫做 discussion」。 +> `review_given` / `bug_caught` XP 源已在 GrowthService 中定义,待 Phase B +> 添加 invocation intent tracking 后激活。 + +**经验值结算引擎**: +- 每个 task / 讨论闭环 / 工具调用后自动结算 +- 数据源:session events、task 状态变更、rich block 创建、evidence 检索 +- 经验值公式透明可审计 + +**猫猫名片(Profile Card)**: +- 头像 + 等级 + 六维雷达图 +- 当前称号 +- 高光时刻 Top 3(链接到真实 session) +- 可导出为图片用于社交分享 + +**Hub UI**: +- 猫猫详情页增加"成长"Tab +- 团队总览页(冒险者公会风格) + +### Phase B: Skill Tree + Title System + +**技能树**: +- 属性达到特定等级解锁称号 +- 审查力 Lv.5 -> "Eagle Eye" +- 架构力 Lv.4 + 协作力 Lv.3 -> "Chief Architect" +- 洞察力 Lv.4 + 连续拦截 3 次未遂事故 -> "Prophet" + +**羁绊系统**: +- 两只猫频繁协作产生"羁绊值" +- 羁绊等级可见,高等级解锁组合名称 +- 展示在各自名片和团队总览中 + +### Phase C: Achievement System + Co-Creator Growth + +**铲屎官成长档案**: +- 铲屎官也是团队成员,协作行为应可见于成长系统 +- XP 来源映射:发消息→collaboration、拍板方向→architecture、触发 review→review、纠偏反馈→insight +- 在成长总览页与猫猫并列展示,共用六维体系和称号路径 +- 实现要点:callback 触发点追加 `awardXp('co-creator', source)`,`getProfile` 对 co-creator 走独立配置路径 + +**成就分类**: + +1. **个猫成就**(绑定单只猫) + - 普通:初啼(首次完成任务)、百炼(100 任务) + - 稀有:守门员(拦下首个 P0) + - 史诗:打脸王(否决自己的方案)、日不落(单日 10+ 任务) + - 传说:预言家(指出的风险 7 天内发生)、涅槃(错误后改进被采纳) + +2. **团队成就**(绑定猫猫组合) + - 普通:初次握手(首次协作) + - 稀有:诤友(5+ 次建设性分歧)、全员集合 + - 史诗:心有灵犀(独立给出相同方案)、众猫拾柴(3+ 猫接力完成 feature) + +3. **里程碑成就**(绑定猫咖实例) + - 普通:开业大吉(首个完整 feature 生命周期) + - 稀有:百日维新(持续运行 100 天) + - 史诗:千锤百炼(1000 次 review 交互) + - 传说:无人之境(无人干预完成 feature 全流程)、事故归零(30 天零 P0 回退) + +4. **隐藏成就**(不提前显示,触发时惊喜弹出) + - 夜猫子(凌晨 2-5 点完成关键任务) + - 时间旅行者(引用 3 月前讨论佐证决策) + - 破壁人(首次外部社区贡献者参与协作) + - 凌晨三点半(猫猫在铲屎官离线时自主完成协作) + +**展示**: +- 解锁弹窗动画 + 音效 +- 成就墙:已解锁发光 / 未解锁灰色剪影 + 模糊提示 +- 成就卡片可导出分享 +- 解锁事件可推送到飞书/Telegram + +**联动效果**: +- 部分成就解锁 Hub 中的视觉标记 +- 传说成就解锁 README badge + +### Phase D: Co-Creator Leadership Growth — 铲屎官独立成长系统 + +> 设计讨论结论来自 opus + gpt52 联合评审(2026-04-14) + +**核心理念**:猫猫六维衡量 AI 执行能力,铲屎官六维衡量「人与 AI 的协作领导力」。 + +**铲屎官专属六维**: + +| 维度 | 说明 | 数据来源 | +|---|---|---| +| 决策力 | 拍板方向的速度和质量 | 方向确认频率、执行成功率(v1 shadow score,需补 `decision_confirmed` 事件) | +| 引导力 | 给猫猫的指令清晰度 | 一次完成率、追问轮数(v1 proxy,需补 `clarification_requested` 事件) | +| 授权力 | 放手让猫猫自主完成 | 无干预 task 完成比、deep_collab 触发数 | +| 反馈力 | 纠偏和正向反馈质量 | 纠偏后改进采纳率(v1 shadow score,需补 `feedback_applied` 事件) | +| 协调力 | 知猫善任,多猫调度 | multi-mention 频率 + 成功率 + targetCats 广度 + bond 覆盖面 | +| 开拓力 | 推动边界的有效探索 | F150 工具类别广度 + 新 skill 首次使用 + feature 发起数 | + +**落地策略**: +- v1 先做 4 维实分(协调力 / 授权力 / 开拓力 / 引导力-proxy),决策力 / 反馈力 做 shadow score +- 等补齐 `decision_confirmed / clarification_requested / feedback_applied` 事件后转正 + +**等级体系**:独立于猫猫 +- 猫猫 = Operator Level,铲屎官 = Leadership Level +- 复用 `sqrt(xp/N)` 骨架但阈值更大(事件少杠杆大) +- 独立称号路径:初心铲屎官 → 团队指挥官 → 猫猫军师 + +**前端**:Mission Control 面板(非 RPG 角色卡) +- 顶部:Leadership Level + 称号 + 趋势 +- 中部:六维雷达图(暖色系)+ 4 个 KPI(一次完成率 / 授权完成率 / 调度成功率 / 工具广度) +- 底部:「领导时刻」时间线 +- 关系区:「最佳搭档」(非"羁绊") + +### Phase E: Evolution Events + Growth Timeline + Observability Integration + +**进化事件**: +- 关键里程碑触发叙事事件(如"守护时刻"、"独立日") +- 事件记录进猫猫成长史,可回溯浏览 + +**成长时间线**: +- 可视化的猫猫成长轨迹 +- 支持按时间段查看属性变化趋势 +- 猫猫考古:定期自动生成"自我回顾报告" + +**可观测数据 → Growth 接入**: +- Token 效率:F128 UsageAggregator 已有 cacheReadTokens 数据,按 session 结算 insight XP +- 意图区分:InvocationRecord.intent 已区分 execute/ideate,ideate 应给更多 architecture XP +- 错误恢复:跨 invocation 追踪 failed → succeeded,奖励韧性 execution XP +- 调用性能:F153 OTel 落地后,invocation.duration 可驱动 fast_execution 奖励 + +## Acceptance Criteria + +### Phase A (Attribute System + Profile Card) ✅ +- [x] AC-A1: 五维属性自动结算(审查力降级至 Phase B,需 invocation intent tracking) +- [x] AC-A2: 每只猫的 Hub 详情页展示六维雷达图和等级 +- [x] AC-A3: 猫猫名片可导出为 PNG 图片 +- [x] AC-A4: 团队总览页展示所有猫的站位图和属性概览 +- [x] AC-A5: 经验值结算逻辑透明、可审计(可查看结算明细) + +### Phase B (Skill Tree + Title System + Review Activation) ✅ +- [x] AC-B0: 审查力维度激活 — invocation intent tracking 落地,`review_given` / `bug_caught` 有真实调用方 +- [x] AC-B1: 属性达标自动解锁称号,显示在名片和 Hub 中 +- [x] AC-B2: 羁绊值从协作记录自动计算,展示在双方名片中 +- [x] AC-B3: 技能树页面展示已解锁/未解锁的称号路径 + +### Phase C (Achievement System + Co-Creator Growth) ✅ +- [x] AC-C1: 四类成就(个猫/团队/里程碑/隐藏)覆盖至少 20 个成就 +- [x] AC-C2: 成就从真实数据自动触发,绑定触发 session 链接 +- [x] AC-C3: 成就墙 UI 展示已解锁/未解锁状态 +- [x] AC-C4: 成就卡片可导出为图片 +- [x] AC-C5: 解锁事件支持 WebSocket 广播(`achievement_unlocked` 事件) +- [x] AC-C6: 铲屎官(Co-Creator)成长档案 — 从协作行为自动结算 XP,在成长总览中与猫猫并列展示 +- [x] AC-C7: 工具调用 XP 分级 — native→execution(+1), mcp→insight(+3), skill→aesthetics(+3) +- [x] AC-C8: 深度协作奖励 — 3+ 猫参与 multi-mention 时所有成功响应者 + 铲屎官获 deep_collab XP(+20) + +### Phase D (Co-Creator Leadership Growth) ✅ (6/7, D7 awaiting calibration) +- [x] AC-D1: 铲屎官专属六维类型定义(LeadershipDimension)+ 独立 XP 存储 +- [x] AC-D2: v1 四维实分引擎 — 协调力 / 授权力 / 开拓力 / 引导力(proxy) +- [x] AC-D3: 决策力 + 反馈力 shadow score(记录但不展示,待数据校准) +- [x] AC-D4: Leadership Level 独立等级体系 + 铲屎官称号路径 +- [x] AC-D5: Mission Control 前端面板 — KPI 仪表盘 + 领导时刻时间线 +- [x] AC-D6: 补齐事件采集 — `decision_confirmed` / `clarification_requested` / `feedback_applied` +- [ ] AC-D7: v2 六维转正 — shadow score 校准后升级为正式维度(⏳ 等 2-3 周自然使用积累校准数据) + +### Phase E (Evolution Events + Growth Timeline + Observability Integration) (3/7, E4-E7 awaiting upstream) +- [x] AC-E1: 关键里程碑触发叙事事件并记录 +- [x] AC-E2: 成长时间线可视化展示 +- [x] AC-E3: 猫猫自我回顾报告自动生成(月度) +- [ ] AC-E4: Token 效率奖励 — session 结算时按 cacheReadTokens/inputTokens 比率奖励 insight XP(🔌 投影器就绪,等 F128 UsageAggregator 上游数据) +- [ ] AC-E5: 调用意图区分 — ideate 意图 discussion 提升至 architecture +25(🔌 投影器就绪,等 InvocationRecord.intent 填充) +- [ ] AC-E6: 错误恢复韧性 — invocation failed → 同 thread 后续 succeeded 时奖励 execution XP(🔌 投影器就绪,等 recoveredFromFailure 元数据) +- [ ] AC-E7: 调用延迟表现 — invocation duration < P50 时额外 execution XP(🔌 投影器就绪,等 F153 OTel metrics 落地) + +## Dependencies + +- **Related**: F100(Self-Evolution — 进化行为层提供洞察力数据源) +- **Related**: F093(Cats & U 世界引擎 — 共创世界中的角色成长可复用本系统) +- **Related**: F101(Mode v2 游戏引擎 — 游戏活动可作为经验值来源) +- **Related**: F129(Pack System — Pack 可扩展自定义成就和称号) + +## Risk + +| Risk | Mitigation | +|------|-----------| +| 经验值公式偏差导致属性不反映真实能力 | Phase A 先用简单线性公式 + 人工校准,迭代调整 | +| 成就触发条件过于依赖数据完整性 | 只使用已有稳定数据源,不为成就新增数据采集 | +| 游戏化元素干扰严肃工作流 | 成长系统只展示不干预,不改变任何工作流行为 | + +## Open Questions + +| # | Question | Status | +|---|----------|--------| +| OQ-1 | 经验值是否跨 session 持久化到 Redis 还是独立 SQLite? | **resolved** — Redis INCRBY + sorted set audit trail | +| OQ-2 | 多实例场景下成就是否全球排名? | open | +| OQ-3 | 猫猫名片的视觉风格——像素风 vs 手绘风 vs 扁平风? | open | + +## Key Decisions + +| # | Decision | Rationale | Date | +|---|----------|-----------|------| +| KD-1 | 数据全部从已有系统自动结算,不新增人工打标环节 | 降低使用摩擦,保证数据真实性 | 2026-04-10 | +| KD-2 | 成长系统只展示不干预工作流 | 避免游戏化污染严肃协作 | 2026-04-10 | +| KD-3 | Phase A 最小切片:属性 + 名片,先证明价值再扩展 | P1 先做终态基座,不做脚手架 | 2026-04-10 | +| KD-4 | 铲屎官六维独立于猫猫六维,不复用 TraitDimension | 衡量对象不同(领导力 vs 执行力),混在一起无意义 | 2026-04-14 | +| KD-5 | 决策力/反馈力 v1 做 shadow score,proxy 共存待 D7 校准 | 显式信号覆盖面窄,需积累数据验证 proxy 精度后再转正 | 2026-04-14 | +| KD-6 | E4-E7 投影器先行实现,数据源就位即激活 | 避免上游就绪后还要改 F160 代码 | 2026-04-15 | +| KD-7 | MemoryProjector 用语义 anchor + 模板 summary,规则硬编码不配置化 | upsert 幂等性需语义 key;推送规则是产品决策应版本化;同步 handler 不引 LLM | 2026-04-17 | + +## Timeline + +| Date | Event | +|------|-------| +| 2026-04-10 | Kickoff: brainstorm + spec | +| 2026-04-12 | Phase A-C complete: attribute system, titles, bonds, achievements, co-creator growth | +| 2026-04-14 | Phase D design: opus + gpt52 joint review of leadership dimensions | +| 2026-04-15 | Phase D1-D5 complete + Phase E1-E3 complete | +| 2026-04-16 | Phase D6 complete: leadership event detection (clarification/decision/feedback) | +| 2026-04-17 | MemoryProjector implemented: F160→F102 high-value event promotion bridge | + +## Review Gate + +- Phase A: @codex review attribute calculation logic + Hub UI +- Phase C: @gemini25 review achievement UX + visual design + +## Links + +| Type | Path | Description | +|------|------|-------------| +| **Feature** | `docs/features/F100-self-evolution.md` | Self-Evolution, data source for insight attribute | +| **Feature** | `docs/features/F093-cats-and-u-world-engine.md` | Cats & U, character growth synergy | +| **Feature** | `docs/features/F101-mode-v2-game-engine.md` | Game engine, XP source from game activities | diff --git a/packages/api/src/domains/activity/ActivityEventBus.ts b/packages/api/src/domains/activity/ActivityEventBus.ts new file mode 100644 index 0000000000..31d8982d84 --- /dev/null +++ b/packages/api/src/domains/activity/ActivityEventBus.ts @@ -0,0 +1,54 @@ +/** + * Activity Event Spine (ADR-023) + * + * In-process typed event bus. Routes/hooks emit ActivityEvents here; + * projectors (Journey, Leaderboard, Memory) subscribe and interpret. + * + * Phase 1: EventEmitter, no persistence. + * Phase 2: Optional event log for replay/recomputation. + */ + +import { EventEmitter } from 'node:events'; +import type { ActivityEvent, ActivityEventType } from '@cat-cafe/shared'; +import { createModuleLogger } from '../../infrastructure/logger.js'; + +const log = createModuleLogger('activity-bus'); + +type ActivityListener = (event: ActivityEvent) => void; + +export class ActivityEventBus { + private readonly emitter = new EventEmitter(); + + constructor() { + this.emitter.setMaxListeners(20); + } + + /** Emit an activity event. Fire-and-forget — errors in listeners are caught and logged. */ + emit(event: ActivityEvent): void { + try { + this.emitter.emit('activity', event); + } catch (err: unknown) { + log.warn({ err, type: event.type, actorId: event.actorId }, 'ActivityEventBus emit error'); + } + } + + /** Convenience: build and emit in one call. */ + record(type: ActivityEventType, actorId: string, metadata: Record = {}, threadId?: string): void { + this.emit({ type, actorId, timestamp: new Date().toISOString(), threadId, metadata }); + } + + /** Subscribe to all activity events. */ + on(listener: ActivityListener): void { + this.emitter.on('activity', listener); + } + + /** Unsubscribe. */ + off(listener: ActivityListener): void { + this.emitter.off('activity', listener); + } + + /** Number of registered listeners (for diagnostics). */ + get listenerCount(): number { + return this.emitter.listenerCount('activity'); + } +} diff --git a/packages/api/src/domains/activity/JourneyProjector.ts b/packages/api/src/domains/activity/JourneyProjector.ts new file mode 100644 index 0000000000..06205d731c --- /dev/null +++ b/packages/api/src/domains/activity/JourneyProjector.ts @@ -0,0 +1,124 @@ +/** + * Journey Projector (ADR-023) + * + * Subscribes to ActivityEventBus and translates events into footfall awards + * via GrowthService.awardFootfall(). This decouples product logic (which event + * earns which footfall) from the transport layer (routes/hooks). + * + * Mapping: ActivityEventType → FootfallSource + */ + +import type { ActivityEvent, ActivityEventType, FootfallSource } from '@cat-cafe/shared'; +import { CO_CREATOR_ACTOR_ID, isCoCreatorActor } from '@cat-cafe/shared'; +import { createModuleLogger } from '../../infrastructure/logger.js'; +import type { ActivityEventBus } from './ActivityEventBus.js'; + +const log = createModuleLogger('journey-projector'); + +interface GrowthServiceLike { + awardFootfall(catId: string, source: FootfallSource, multiplier?: number): void; + recordBondEvent?(catA: string, catB: string): void; +} + +/** + * Maps ActivityEventType → FootfallSource + optional multiplier. + * Events not listed here are silently ignored by the projector. + */ +const EVENT_TO_FOOTFALL: Partial> = { + tool_used: { source: 'tool_use' }, + task_completed: { source: 'task_complete' }, + message_sent: { source: 'discussion' }, + review_submitted: { source: 'review_given' }, + bug_caught: { source: 'bug_caught' }, + multi_mention_completed: { source: 'mention_collab' }, + deep_collab_completed: { source: 'deep_collab' }, + evidence_cited: { source: 'evidence_cite' }, + session_sealed: { source: 'session_seal' }, + rich_block_created: { source: 'rich_block_create' }, + design_feedback_given: { source: 'design_feedback' }, +}; + +export class JourneyProjector { + constructor( + private readonly bus: ActivityEventBus, + private readonly growthService: GrowthServiceLike, + ) { + this.bus.on(this.handleEvent); + log.info('JourneyProjector subscribed to ActivityEventBus'); + } + + private handleEvent = (event: ActivityEvent): void => { + try { + // Bond recording FIRST — runs for events that may not carry footfall (e.g. a2a_handoff_completed) + if (isBondEvent(event.type)) { + const participants = event.metadata.participants as string[] | undefined; + if (participants && this.growthService.recordBondEvent) { + for (let i = 0; i < participants.length; i++) { + for (let j = i + 1; j < participants.length; j++) { + this.growthService.recordBondEvent(participants[i], participants[j]); + } + } + } + } + + // Footfall — events not in the mapping are silently skipped + const mapping = EVENT_TO_FOOTFALL[event.type]; + if (!mapping) return; + + // Tool events may carry a category override (mcp/skill) + let { source } = mapping; + if (event.type === 'tool_used' && event.metadata.category) { + const cat = event.metadata.category as string; + if (cat === 'mcp') source = 'tool_use_mcp'; + else if (cat === 'skill') source = 'tool_use_skill'; + } + + // AC-E5: Ideate intent → architecture bonus (overrides discussion → ideate_discussion) + if (event.type === 'message_sent' && event.metadata.intent === 'ideate') { + source = 'ideate_discussion'; + } + + this.growthService.awardFootfall(event.actorId, source, mapping.multiplier); + + // AC-E6: Error recovery — retry succeeded after prior failure → bonus execution footfall + if (event.type === 'task_completed' && event.metadata.recoveredFromFailure) { + this.growthService.awardFootfall(event.actorId, 'error_recovery'); + } + + // AC-E7: Fast execution — invocation completed quickly → bonus execution footfall + if (event.type === 'task_completed' && event.metadata.fastExecution) { + this.growthService.awardFootfall(event.actorId, 'fast_execution'); + } + + // AC-E4: Cache efficiency bonus — award insight footfall when cache hit ratio ≥ 30% + if (event.type === 'session_sealed') { + const usage = event.metadata.lastUsage as { cacheReadTokens?: number; inputTokens?: number } | undefined; + if (usage && usage.inputTokens && usage.inputTokens > 0) { + const ratio = (usage.cacheReadTokens ?? 0) / usage.inputTokens; + if (ratio >= 0.3) { + this.growthService.awardFootfall(event.actorId, 'cache_efficiency'); + } + } + } + + // Co-creator also gets footfall for collaborative events + if (!isCoCreatorActor(event.actorId) && isCollabEvent(event.type)) { + this.growthService.awardFootfall(CO_CREATOR_ACTOR_ID, source, mapping.multiplier); + } + } catch (err: unknown) { + log.warn({ err, type: event.type, actorId: event.actorId }, 'JourneyProjector error'); + } + }; + + dispose(): void { + this.bus.off(this.handleEvent); + } +} + +function isBondEvent(type: ActivityEventType): boolean { + return type === 'multi_mention_completed' || type === 'deep_collab_completed' || type === 'a2a_handoff_completed'; +} + +function isCollabEvent(type: ActivityEventType): boolean { + return type === 'multi_mention_completed' || type === 'deep_collab_completed' || type === 'session_sealed'; +} diff --git a/packages/api/src/domains/activity/LeadershipProjector.ts b/packages/api/src/domains/activity/LeadershipProjector.ts new file mode 100644 index 0000000000..26630d40b0 --- /dev/null +++ b/packages/api/src/domains/activity/LeadershipProjector.ts @@ -0,0 +1,140 @@ +/** + * Leadership Projector (Phase D) + * + * Subscribes to ActivityEventBus and translates co-creator-relevant events + * into leadership footfall via LeadershipService.awardFootfall(). + * + * IMPORTANT: Uses request-level events for multi-mention scoring (not per-responder). + * - multi_mention_dispatched → fires ONCE when co-creator dispatches + * - multi_mention_request_completed → fires ONCE when all responders finish (from flushResult) + * - Per-responder events (multi_mention_completed, deep_collab_completed) are for cat footfall only. + */ + +import type { ActivityEvent, LeadershipFootfallSource } from '@cat-cafe/shared'; +import { isCoCreatorActor } from '@cat-cafe/shared'; +import { createModuleLogger } from '../../infrastructure/logger.js'; +import type { ActivityEventBus } from './ActivityEventBus.js'; + +const log = createModuleLogger('leadership-projector'); + +interface LeadershipServiceLike { + awardFootfall(source: LeadershipFootfallSource, multiplier?: number): void; +} + +/** Minimum target count to earn target_diversity bonus. */ +const DIVERSITY_THRESHOLD = 3; + +export class LeadershipProjector { + constructor( + private readonly bus: ActivityEventBus, + private readonly leadershipService: LeadershipServiceLike, + ) { + this.bus.on(this.handleEvent); + log.info('LeadershipProjector subscribed to ActivityEventBus'); + } + + private handleEvent = (event: ActivityEvent): void => { + try { + // Leadership measures co-creator behaviour. Cat-originated coordination + // events (e.g. cat-initiated multi-mention) must not inflate leadership scores. + const isCoCreator = isCoCreatorActor(event.actorId); + + switch (event.type) { + // ── Coordination (协调力) ──────────────────────── + case 'multi_mention_dispatched': + if (!isCoCreator) break; + this.leadershipService.awardFootfall('multi_mention_dispatch'); + break; + case 'multi_mention_request_completed': + if (!isCoCreator) break; + this.onRequestCompleted(event); + break; + // ── Delegation + Guidance (授权力 + 引导力) ────── + case 'task_completed': + this.onTaskCompleted(event); + break; + case 'session_sealed': + this.onSessionSealed(event); + break; + // ── Exploration (开拓力) ───────────────────────── + case 'tool_used': + this.onToolUsed(event); + break; + // ── Shadow (AC-D3) → AC-D6 real events ───────────── + case 'review_submitted': + // D6 TODO: Replace with real feedback_applied event chain (proxy P1) + this.leadershipService.awardFootfall('feedback_applied'); + break; + case 'decision_confirmed': + // D6: Explicit decision — separate source from proxy for D7 calibration + this.leadershipService.awardFootfall('direction_confirmed_explicit'); + break; + case 'clarification_requested': + // D6: Record to audit trail via shadow dim (1 XP) for D7 calibration data. + this.leadershipService.awardFootfall('clarification_observed'); + break; + } + } catch (err: unknown) { + log.warn({ err, type: event.type }, 'LeadershipProjector error'); + } + }; + + /** Request-level: fires ONCE per multi-mention request from flushResult(). */ + private onRequestCompleted(event: ActivityEvent): void { + const successCount = (event.metadata.successCount as number) ?? 0; + if (successCount === 0) return; // No successful responses — skip + + this.leadershipService.awardFootfall('multi_mention_success'); + + // Diversity bonus based on total target count (not per-responder participants) + const targetCount = (event.metadata.targetCount as number) ?? 0; + if (targetCount >= DIVERSITY_THRESHOLD) { + this.leadershipService.awardFootfall('target_diversity'); + } + + // Deep collab bonus (3+ cats collaborated successfully) + if (event.metadata.isDeepCollab) { + this.leadershipService.awardFootfall('deep_collab_initiated'); + } + } + + private onTaskCompleted(event: ActivityEvent): void { + // Baseline: every task completion gives a small guidance signal + this.leadershipService.awardFootfall('one_shot_completion', 0.2); + + const clarifications = (event.metadata.clarificationCount as number) ?? -1; + // 引导力 bonus: cat completed on first try (zero clarification rounds) + if (clarifications === 0) { + this.leadershipService.awardFootfall('one_shot_completion', 0.8); + } + // 授权力: task finished without co-creator intervention + const interventions = (event.metadata.interventionCount as number) ?? -1; + if (interventions === 0) { + this.leadershipService.awardFootfall('task_no_intervention'); + } + // 决策力 (shadow): quick task completion implies good initial direction + if (clarifications >= 0 && clarifications <= 1) { + this.leadershipService.awardFootfall('direction_confirmed'); + } + } + + private onSessionSealed(event: ActivityEvent): void { + const clarifications = (event.metadata.clarificationCount as number) ?? -1; + // 引导力: session with few clarification rounds (≤ 2) + if (clarifications >= 0 && clarifications <= 2) { + this.leadershipService.awardFootfall('low_clarification'); + } + } + + private onToolUsed(event: ActivityEvent): void { + const category = event.metadata.category as string | undefined; + // 开拓力: using MCP/skill tools counts as boundary-pushing exploration + if (category === 'mcp' || category === 'skill') { + this.leadershipService.awardFootfall('tool_category_breadth'); + } + } + + dispose(): void { + this.bus.off(this.handleEvent); + } +} diff --git a/packages/api/src/domains/activity/MemoryProjector.ts b/packages/api/src/domains/activity/MemoryProjector.ts new file mode 100644 index 0000000000..13137d2e93 --- /dev/null +++ b/packages/api/src/domains/activity/MemoryProjector.ts @@ -0,0 +1,193 @@ +/** + * Memory Projector (ADR-023) + * + * Subscribes to ActivityEventBus and forwards high-value events to F102 + * EvidenceStore. Applies promotion rules defined in ADR-023 §Memory + * promotion rules — only events that merit memory crystallization are + * forwarded; noisy lifecycle events are dropped. + * + * Promotion tiers: + * Always — deep_collab_completed, bug_caught, evidence_cited + * Conditional — review_submitted (hasFindings), decision_confirmed (threadId) + * Never — tool_used, message_sent, session_sealed, multi_mention_*, clarification_requested + */ + +import type { ActivityEvent, ActivityEventType } from '@cat-cafe/shared'; +import { isCoCreatorActor } from '@cat-cafe/shared'; +import { createModuleLogger } from '../../infrastructure/logger.js'; +import type { EvidenceKind } from '../memory/interfaces.js'; +import type { ActivityEventBus } from './ActivityEventBus.js'; + +const log = createModuleLogger('memory-projector'); + +/** Subset of IEvidenceStore — keeps the projector decoupled from full interface. */ +interface EvidenceStoreLike { + upsert(items: EvidenceUpsertItem[]): Promise; +} + +interface EvidenceUpsertItem { + anchor: string; + kind: EvidenceKind; + status: 'active'; + title: string; + summary?: string; + keywords?: string[]; + updatedAt: string; +} + +// ── Promotion rules ──────────────────────────────────────────────────── + +const ALWAYS_PROMOTE = new Set(['deep_collab_completed', 'bug_caught', 'evidence_cited']); + +const CONDITIONAL_PROMOTE = new Set(['review_submitted', 'decision_confirmed', 'feedback_applied']); + +/** Map event type → EvidenceKind for promoted events. */ +const EVENT_KIND: Partial> = { + bug_caught: 'lesson', + deep_collab_completed: 'discussion', + evidence_cited: 'research', + review_submitted: 'discussion', + decision_confirmed: 'decision', + feedback_applied: 'lesson', +}; + +// ── Anchor builders (semantic keys for upsert idempotency) ───────────── + +function buildAnchor(event: ActivityEvent): string { + const m = event.metadata; + switch (event.type) { + case 'decision_confirmed': + return `activity-decision-${(m.blockId as string) ?? event.threadId ?? event.timestamp}`; + case 'deep_collab_completed': { + // Fires per-responder — use sorted participants as stable dedup key + const parts = ((m.participants as string[]) ?? []).slice().sort().join('+'); + return `activity-collab-${event.threadId ?? 'unknown'}-${parts || event.timestamp}`; + } + case 'review_submitted': + return `activity-review-${event.actorId}-${event.threadId ?? 'unknown'}-${(m.taskId as string) ?? event.timestamp}`; + case 'bug_caught': + return `activity-bug-${event.actorId}-${event.threadId ?? 'unknown'}-${event.timestamp}`; + case 'evidence_cited': + return `activity-cite-${event.actorId}-${(m.citedAnchor as string) ?? event.timestamp}`; + case 'feedback_applied': + return `activity-feedback-${event.actorId}-${(m.sourceMessageId as string) ?? event.timestamp}`; + default: + return `activity-${event.type}-${event.timestamp}`; + } +} + +// ── Summary templates ────────────────────────────────────────────────── + +function buildSummary(event: ActivityEvent): string { + // Emitter-supplied summary takes precedence + if (typeof event.metadata.summary === 'string') return event.metadata.summary; + + switch (event.type) { + case 'bug_caught': + return `${event.actorId} identified a bug`; + case 'deep_collab_completed': + return `Deep collaboration completed with ${((event.metadata.participants as string[]) ?? []).join(', ') || 'multiple cats'}`; + case 'evidence_cited': + return `${event.actorId} cited evidence: ${(event.metadata.citedAnchor as string) ?? 'unknown'}`; + case 'review_submitted': + return `Code review by ${event.actorId} with ${(event.metadata.findingCount as number) ?? 0} findings`; + case 'decision_confirmed': + return 'Direction confirmed by co-creator'; + case 'feedback_applied': + return `Feedback adopted by ${event.actorId}`; + default: + return event.type; + } +} + +// ── Title templates ──────────────────────────────────���───────────────── + +function buildTitle(event: ActivityEvent): string { + switch (event.type) { + case 'bug_caught': + return `Bug caught by ${event.actorId}`; + case 'deep_collab_completed': + return 'Deep collaboration completed'; + case 'evidence_cited': + return `Evidence cited: ${(event.metadata.citedAnchor as string) ?? 'unknown'}`; + case 'review_submitted': + return `Review by ${event.actorId}`; + case 'decision_confirmed': + return 'Direction confirmed'; + case 'feedback_applied': + return `Feedback applied by ${event.actorId}`; + default: + return event.type; + } +} + +// ── Conditional promotion checks ─────────────────────────────────────── + +function passesCondition(event: ActivityEvent): boolean { + switch (event.type) { + case 'review_submitted': + return event.metadata.hasFindings === true; + case 'decision_confirmed': + return event.threadId != null; + case 'feedback_applied': + // Always promote when implemented — feedback adoption is high-value + return true; + default: + return false; + } +} + +// ── Projector class ──────────────────────────────────────────────────── + +export class MemoryProjector { + constructor( + private readonly bus: ActivityEventBus, + private readonly evidenceStore: EvidenceStoreLike, + ) { + this.bus.on(this.handleEvent); + log.info('MemoryProjector subscribed to ActivityEventBus'); + } + + private handleEvent = (event: ActivityEvent): void => { + try { + if (!this.shouldPromote(event)) return; + + const kind = EVENT_KIND[event.type]; + if (!kind) return; + + const item: EvidenceUpsertItem = { + anchor: buildAnchor(event), + kind, + status: 'active', + title: buildTitle(event), + summary: buildSummary(event), + keywords: this.extractKeywords(event), + updatedAt: event.timestamp, + }; + + // Fire-and-forget async upsert — errors are caught below + void this.evidenceStore.upsert([item]).catch((err: unknown) => { + log.warn({ err, anchor: item.anchor, type: event.type }, 'MemoryProjector upsert failed'); + }); + } catch (err: unknown) { + log.warn({ err, type: event.type, actorId: event.actorId }, 'MemoryProjector error'); + } + }; + + private shouldPromote(event: ActivityEvent): boolean { + if (ALWAYS_PROMOTE.has(event.type)) return true; + if (CONDITIONAL_PROMOTE.has(event.type)) return passesCondition(event); + return false; + } + + private extractKeywords(event: ActivityEvent): string[] { + const kw: string[] = [event.type]; + if (event.actorId && !isCoCreatorActor(event.actorId)) kw.push(event.actorId); + if (event.threadId) kw.push(`thread:${event.threadId}`); + return kw; + } + + dispose(): void { + this.bus.off(this.handleEvent); + } +} diff --git a/packages/api/src/domains/activity/OtelBridgeProjector.ts b/packages/api/src/domains/activity/OtelBridgeProjector.ts new file mode 100644 index 0000000000..9f6b2ddb34 --- /dev/null +++ b/packages/api/src/domains/activity/OtelBridgeProjector.ts @@ -0,0 +1,69 @@ +/** + * OTel Bridge Projector (Issue #480 commitment) + * + * Subscribes to ActivityEventBus and forwards L1-L3 product-level events + * to F153 OTel instruments. This bridges the product fact spine with the + * runtime observability infrastructure — OTel owns telemetry, the bus + * owns typed facts, this projector connects them. + */ + +import type { ActivityEvent } from '@cat-cafe/shared'; +import { isCoCreatorActor } from '@cat-cafe/shared'; +import { createModuleLogger } from '../../infrastructure/logger.js'; +import { + catInvocationCount, + sessionRounds, + taskCompleted, + taskDuration, +} from '../../infrastructure/telemetry/instruments.js'; +import type { ActivityEventBus } from './ActivityEventBus.js'; + +const log = createModuleLogger('otel-bridge-projector'); + +const CAT_ACTIVITY_EVENTS = new Set([ + 'tool_used', + 'task_completed', + 'message_sent', + 'review_submitted', + 'bug_caught', + 'evidence_cited', + 'rich_block_created', + 'design_feedback_given', +]); + +export class OtelBridgeProjector { + constructor(bus: ActivityEventBus) { + bus.on(this.handleEvent); + log.info('OtelBridgeProjector subscribed to ActivityEventBus'); + } + + private handleEvent = (event: ActivityEvent): void => { + try { + const agentId = event.actorId ?? 'unknown'; + + if (event.type === 'task_completed') { + const status = (event.metadata?.status as string) ?? 'ok'; + taskCompleted.add(1, { 'agent.id': agentId, status }); + + const durationS = event.metadata?.durationMs ? Number(event.metadata.durationMs) / 1000 : undefined; + if (durationS !== undefined && durationS > 0) { + taskDuration.record(durationS, { 'agent.id': agentId }); + } + } + + if (event.type === 'session_sealed') { + const rounds = event.metadata?.rounds; + if (typeof rounds === 'number' && rounds > 0) { + sessionRounds.record(rounds, { 'agent.id': agentId }); + } + } + + if (CAT_ACTIVITY_EVENTS.has(event.type) && !isCoCreatorActor(agentId)) { + const trigger = (event.metadata?.trigger as string) ?? 'default'; + catInvocationCount.add(1, { 'agent.id': agentId, trigger }); + } + } catch (err: unknown) { + log.warn({ err, type: event.type }, 'OtelBridgeProjector error'); + } + }; +} diff --git a/packages/api/src/domains/cats/services/journey/AchievementService.ts b/packages/api/src/domains/cats/services/journey/AchievementService.ts new file mode 100644 index 0000000000..fbb6141eb1 --- /dev/null +++ b/packages/api/src/domains/cats/services/journey/AchievementService.ts @@ -0,0 +1,170 @@ +/** + * F160 Phase C — Achievement Service + * + * Checks achievement conditions against current state and unlocks achievements. + * Integrates with GrowthService for attribute/title/bond data and Redis counters + * for event-based achievements (task_count, review_count, session_count). + */ + +import type { + AchievementCondition, + AchievementDefinition, + BondLevel, + CatAttributes, + UnlockedAchievement, +} from '@cat-cafe/shared'; +import { ACHIEVEMENT_DEFINITIONS } from '@cat-cafe/shared'; +import type { RedisClient } from '@cat-cafe/shared/utils'; +import { createModuleLogger } from '../../../../infrastructure/logger.js'; +import { achievementCountersKey, achievementUnlockedKey } from '../stores/redis-keys/achievement-keys.js'; +import type { GrowthService } from './GrowthService.js'; + +const log = createModuleLogger('achievement'); + +const BOND_LEVEL_ORDER: Record = { acquaintance: 1, partner: 2, soulmate: 3 }; + +/** Callback invoked when achievements are newly unlocked (AC-C5). */ +export type AchievementUnlockHandler = ( + memberId: string, + unlocked: UnlockedAchievement[], + definitions: AchievementDefinition[], +) => void; + +export class AchievementService { + /** Optional handler called on new unlocks — set from outside to avoid circular deps. */ + onUnlock?: AchievementUnlockHandler; + + constructor( + private readonly redis: RedisClient, + private readonly growthService: GrowthService, + ) {} + + // ── Counter increment (fire-and-forget) ─────────────────────────── + + /** Increment an event counter. Called from trigger points. */ + incrementCounter(memberId: string, counter: 'tasks' | 'reviews' | 'sessions'): void { + this.redis + .pipeline() + .hincrby(achievementCountersKey(memberId), counter, 1) + .exec() + .then(() => this.checkAndUnlock(memberId)) + .catch((err: unknown) => log.warn({ err, memberId, counter }, 'Counter increment failed')); + } + + // ── Check + unlock ──────────────────────────────────────────────── + + /** Check all achievements for a member and unlock newly qualified ones. */ + async checkAndUnlock(memberId: string): Promise { + const existing = await this.getUnlocked(memberId); + const existingIds = new Set(existing.map((a) => a.achievementId)); + const attrs = await this.growthService.getAttributes(memberId); + const counters = await this.getCounters(memberId); + const titleCount = (await this.growthService.getUnlockedTitles(memberId)).length; + const bonds = await this.growthService.getBonds(memberId); + + const newlyUnlocked: UnlockedAchievement[] = []; + + for (const def of ACHIEVEMENT_DEFINITIONS) { + if (existingIds.has(def.id)) continue; + if (def.conditions.length === 0) continue; // event-driven only + if (!this.allConditionsMet(def, attrs, counters, titleCount, bonds.length, bonds)) continue; + + const unlock: UnlockedAchievement = { + achievementId: def.id, + memberId, + unlockedAt: Date.now(), + }; + await this.redis.zadd(achievementUnlockedKey(memberId), unlock.unlockedAt, JSON.stringify(unlock)); + newlyUnlocked.push(unlock); + log.info({ memberId, achievementId: def.id }, 'Achievement unlocked'); + } + if (newlyUnlocked.length > 0 && this.onUnlock) { + const defMap = new Map(ACHIEVEMENT_DEFINITIONS.map((d) => [d.id, d])); + const defs = newlyUnlocked.map((u) => defMap.get(u.achievementId)!).filter(Boolean); + this.onUnlock(memberId, newlyUnlocked, defs); + } + return newlyUnlocked; + } + + /** Manually unlock an event-driven achievement (hidden, etc). */ + async unlockManual(memberId: string, achievementId: string, triggerRef?: string): Promise { + const existing = await this.getUnlocked(memberId); + if (existing.some((a) => a.achievementId === achievementId)) return false; + + const def = ACHIEVEMENT_DEFINITIONS.find((d) => d.id === achievementId); + if (!def) return false; + + const unlock: UnlockedAchievement = { + achievementId, + memberId, + unlockedAt: Date.now(), + triggerRef, + }; + await this.redis.zadd(achievementUnlockedKey(memberId), unlock.unlockedAt, JSON.stringify(unlock)); + log.info({ memberId, achievementId, triggerRef }, 'Achievement manually unlocked'); + if (this.onUnlock && def) { + this.onUnlock(memberId, [unlock], [def]); + } + return true; + } + + // ── Read ────────────────────────────────────────────────────────── + + async getUnlocked(memberId: string): Promise { + const raw = await this.redis.zrevrange(achievementUnlockedKey(memberId), 0, -1); + return raw.map((s) => JSON.parse(s) as UnlockedAchievement); + } + + async getCounters(memberId: string): Promise> { + const raw = await this.redis.hgetall(achievementCountersKey(memberId)); + if (!raw) return {}; + const result: Record = {}; + for (const [k, v] of Object.entries(raw)) { + result[k] = parseInt(v, 10) || 0; + } + return result; + } + + // ── Condition evaluation ────────────────────────────────────────── + + private allConditionsMet( + def: AchievementDefinition, + attrs: CatAttributes, + counters: Record, + titleCount: number, + bondCount: number, + bonds: { level: BondLevel }[], + ): boolean { + return def.conditions.every((c) => this.conditionMet(c, attrs, counters, titleCount, bondCount, bonds)); + } + + private conditionMet( + cond: AchievementCondition, + attrs: CatAttributes, + counters: Record, + titleCount: number, + bondCount: number, + bonds: { level: BondLevel }[], + ): boolean { + switch (cond.type) { + case 'total_footfall': + return attrs.totalFootfall >= cond.minFootfall; + case 'overall_level': + return attrs.overallLevel >= cond.minLevel; + case 'dimension_level': + return (attrs.stats[cond.dimension]?.level ?? 0) >= cond.minLevel; + case 'title_count': + return titleCount >= cond.minCount; + case 'bond_count': + return bondCount >= cond.minCount; + case 'bond_level': + return bonds.some((b) => BOND_LEVEL_ORDER[b.level] >= BOND_LEVEL_ORDER[cond.minLevel]); + case 'task_count': + return (counters.tasks ?? 0) >= cond.minCount; + case 'review_count': + return (counters.reviews ?? 0) >= cond.minCount; + case 'session_count': + return (counters.sessions ?? 0) >= cond.minCount; + } + } +} diff --git a/packages/api/src/domains/cats/services/journey/EvolutionService.ts b/packages/api/src/domains/cats/services/journey/EvolutionService.ts new file mode 100644 index 0000000000..087cba37e4 --- /dev/null +++ b/packages/api/src/domains/cats/services/journey/EvolutionService.ts @@ -0,0 +1,103 @@ +/** + * F160 Phase E — Evolution Event Service (AC-E1) + * + * Records narrative milestone events for the growth timeline: + * - Level-up (dimension or overall) + * - First XP in a new dimension + * - Achievement/moment unlocked + * - Title unlocked + * + * Redis: evolution:{catId} → sorted set (score = timestamp, member = JSON) + */ + +import type { EvolutionEvent, EvolutionEventType, TraitDimension } from '@cat-cafe/shared'; +import { DIMENSION_LABELS } from '@cat-cafe/shared'; +import type { RedisClient } from '@cat-cafe/shared/utils'; +import { createModuleLogger } from '../../../../infrastructure/logger.js'; +import { evolutionEventKey } from '../stores/redis-keys/growth-keys.js'; + +const log = createModuleLogger('evolution'); + +/** Max events stored per cat (sorted set trimmed after insert). */ +const MAX_EVENTS_PER_CAT = 200; + +export class EvolutionService { + constructor(private readonly redis: RedisClient) {} + + /** Record a level-up event. */ + recordLevelUp(catId: string, dimension: TraitDimension, oldLevel: number, newLevel: number): void { + const dimLabel = DIMENSION_LABELS[dimension]; + this.record(catId, { + type: 'level_up', + catId, + narrative: { + zh: `${dimLabel.zh}从 Lv.${oldLevel} 升级到 Lv.${newLevel}!`, + en: `${dimLabel.en} leveled up from Lv.${oldLevel} to Lv.${newLevel}!`, + }, + details: { dimension, oldLevel, newLevel }, + timestamp: Date.now(), + }); + } + + /** Record first XP earned in a dimension. */ + recordFirstDimension(catId: string, dimension: TraitDimension): void { + const dimLabel = DIMENSION_LABELS[dimension]; + this.record(catId, { + type: 'first_dim', + catId, + narrative: { + zh: `首次获得${dimLabel.zh}经验!旅程开启`, + en: `First ${dimLabel.en} experience earned! Journey begins`, + }, + details: { dimension }, + timestamp: Date.now(), + }); + } + + /** Record achievement unlock (called from AchievementService.onUnlock). */ + recordAchievement(catId: string, achievementId: string, label: { zh: string; en: string }): void { + this.record(catId, { + type: 'achievement_unlocked', + catId, + narrative: { + zh: `解锁成就「${label.zh}」!`, + en: `Achievement "${label.en}" unlocked!`, + }, + details: { achievementId }, + timestamp: Date.now(), + }); + } + + /** Record title unlock. */ + recordTitleUnlock(catId: string, titleId: string, label: { zh: string; en: string }): void { + this.record(catId, { + type: 'title_unlocked', + catId, + narrative: { + zh: `获得称号「${label.zh}」!`, + en: `Title "${label.en}" earned!`, + }, + details: { titleId }, + timestamp: Date.now(), + }); + } + + /** Read evolution events, newest first. */ + async getEvents(catId: string, limit = 50, offset = 0): Promise { + const raw = await this.redis.zrevrange(evolutionEventKey(catId), offset, offset + limit - 1); + return raw.map((s) => JSON.parse(s) as EvolutionEvent); + } + + /** Fire-and-forget: write event + trim oldest. */ + private record(catId: string, event: EvolutionEvent): void { + const member = JSON.stringify({ ...event, _seq: Math.random().toString(36).slice(2, 8) }); + this.redis + .pipeline() + .zadd(evolutionEventKey(catId), event.timestamp, member) + .zremrangebyrank(evolutionEventKey(catId), 0, -(MAX_EVENTS_PER_CAT + 1)) + .exec() + .catch((err: unknown) => { + log.warn({ err, catId, type: event.type }, 'Failed to record evolution event'); + }); + } +} diff --git a/packages/api/src/domains/cats/services/journey/GrowthService.ts b/packages/api/src/domains/cats/services/journey/GrowthService.ts new file mode 100644 index 0000000000..d756058bd0 --- /dev/null +++ b/packages/api/src/domains/cats/services/journey/GrowthService.ts @@ -0,0 +1,343 @@ +/** + * F160 Cat Journey RPG — Journey Service + * Reads/writes footfall counters in Redis, computes attributes and profiles. + * + * Footfall is stored as simple Redis integers: growth:{catId}:{dimension} → total footfall. + * Level formula: level = floor(sqrt(footfall / 100)) (quadratic curve) + * Footfall to next: (level+1)^2 * 100 - footfall + */ + +import type { + BondLevel, + CatAttributes, + CatBond, + CatGrowthProfile, + CatTitle, + DimensionStat, + FootfallEvent, + FootfallSource, + GrowthDimension, + GrowthOverview, + TitleCondition, + TitleDefinition, + UnlockedTitle, +} from '@cat-cafe/shared'; +import { + CO_CREATOR_ACTOR_ID, + catRegistry, + GROWTH_DIMENSIONS, + isCoCreatorActor, + TITLE_DEFINITIONS, +} from '@cat-cafe/shared'; +import type { RedisClient } from '@cat-cafe/shared/utils'; +import { createModuleLogger } from '../../../../infrastructure/logger.js'; +import { growthAuditKey, growthBondKey, growthTitleKey, growthXpKey } from '../stores/redis-keys/growth-keys.js'; + +const log = createModuleLogger('growth'); + +/** Footfall per source type, mapped to the dimension it feeds. */ +const FOOTFALL_RULES: Record = { + task_complete: { dimension: 'execution', footfall: 50 }, + session_seal: { dimension: 'execution', footfall: 20 }, + review_given: { dimension: 'review', footfall: 40 }, + review_received: { dimension: 'collaboration', footfall: 15 }, + tool_use: { dimension: 'execution', footfall: 1 }, + tool_use_mcp: { dimension: 'insight', footfall: 3 }, + tool_use_skill: { dimension: 'aesthetics', footfall: 3 }, + mention_collab: { dimension: 'collaboration', footfall: 10 }, + deep_collab: { dimension: 'collaboration', footfall: 20 }, + discussion: { dimension: 'architecture', footfall: 15 }, + pr_merged: { dimension: 'execution', footfall: 80 }, + bug_caught: { dimension: 'review', footfall: 60 }, + design_feedback: { dimension: 'aesthetics', footfall: 30 }, + rich_block_create: { dimension: 'aesthetics', footfall: 20 }, + evidence_cite: { dimension: 'insight', footfall: 25 }, + cache_efficiency: { dimension: 'insight', footfall: 15 }, + ideate_discussion: { dimension: 'architecture', footfall: 25 }, + error_recovery: { dimension: 'execution', footfall: 30 }, + fast_execution: { dimension: 'execution', footfall: 20 }, +}; + +function levelFromFootfall(footfall: number): number { + return Math.floor(Math.sqrt(footfall / 100)); +} + +function footfallForLevel(level: number): number { + return level * level * 100; +} + +function buildDimensionStat(dimension: GrowthDimension, footfall: number): DimensionStat { + const level = levelFromFootfall(footfall); + return { dimension, footfall, level, footfallToNext: footfallForLevel(level + 1) - footfall }; +} + +/** Footfall sources that map to achievement counters. */ +const SOURCE_TO_COUNTER: Partial> = { + task_complete: 'tasks', + review_given: 'reviews', + session_seal: 'sessions', +}; + +export class GrowthService { + /** Phase C: Optional AchievementService — set after construction to avoid circular deps. */ + achievementService?: { incrementCounter(memberId: string, counter: string): void }; + /** Phase E: Optional EvolutionService — records milestone narrative events. */ + evolutionService?: import('./EvolutionService.js').EvolutionService; + + constructor(private readonly redis: RedisClient) {} + + /** Resolve ioredis keyPrefix for SCAN operations. */ + private get keyPrefix(): string { + return (this.redis as { options?: { keyPrefix?: string } }).options?.keyPrefix ?? ''; + } + + /** Award footfall + record audit event. Fire-and-forget — caller should not await. */ + awardFootfall(catId: string, source: FootfallSource, multiplier = 1): void { + const rule = FOOTFALL_RULES[source]; + if (!rule) return; + const amount = Math.max(1, Math.round(rule.footfall * multiplier)); + const key = growthXpKey(catId, rule.dimension); + const ts = Date.now(); + + // Increment total + append audit entry (pipelined, single RTT) + const pipeline = this.redis.pipeline(); + pipeline.incrby(key, amount); + const event: FootfallEvent = { catId, dimension: rule.dimension, footfall: amount, source, timestamp: ts }; + // Nonce ensures uniqueness when identical events fire in the same millisecond (e.g. tool_use bursts) + const member = JSON.stringify({ ...event, _seq: Math.random().toString(36).slice(2, 8) }); + pipeline.zadd(growthAuditKey(catId), ts, member); + pipeline + .exec() + .then((results) => { + // Phase E (AC-E1): Detect level transitions from INCRBY result + if (this.evolutionService && results) { + const newFootfall = results[0]?.[1] as number | undefined; + if (newFootfall != null) { + const oldFootfall = newFootfall - amount; + if (oldFootfall === 0) this.evolutionService.recordFirstDimension(catId, rule.dimension); + const oldLevel = levelFromFootfall(oldFootfall); + const newLevel = levelFromFootfall(newFootfall); + if (newLevel > oldLevel) this.evolutionService.recordLevelUp(catId, rule.dimension, oldLevel, newLevel); + } + } + + // Phase B: Check title unlocks after footfall change (fire-and-forget) + this.getAttributes(catId) + .then((attrs) => this.checkTitleUnlocks(catId, attrs)) + .catch((err: unknown) => log.warn({ err, catId }, 'Title check failed')); + }) + .catch((err: unknown) => { + log.warn({ err, catId, source }, 'Failed to award footfall'); + }); + + // Phase C: Increment achievement counter if applicable (fire-and-forget) + const counter = SOURCE_TO_COUNTER[source]; + if (counter && this.achievementService) { + this.achievementService.incrementCounter(catId, counter); + } + } + + /** AC-A5: Fetch recent footfall events for a cat, newest first. */ + async getFootfallEvents(catId: string, limit = 50, offset = 0): Promise { + const raw = await this.redis.zrevrange(growthAuditKey(catId), offset, offset + limit - 1); + return raw.map((s) => JSON.parse(s) as FootfallEvent); + } + + /** Read one cat's attributes from Redis. */ + async getAttributes(catId: string): Promise { + const keys = GROWTH_DIMENSIONS.map((d) => growthXpKey(catId, d)); + const values = await this.redis.mget(...keys); + + const stats = {} as Record; + let totalFootfall = 0; + let levelSum = 0; + let activeDimensions = 0; + + for (let i = 0; i < GROWTH_DIMENSIONS.length; i++) { + const dim = GROWTH_DIMENSIONS[i]!; + const footfall = parseInt(values[i] ?? '0', 10) || 0; + stats[dim] = buildDimensionStat(dim, footfall); + totalFootfall += footfall; + levelSum += stats[dim].level; + if (footfall > 0) activeDimensions++; + } + + return { + catId, + stats, + overallLevel: activeDimensions > 0 ? Math.floor(levelSum / activeDimensions) : 0, + totalFootfall, + updatedAt: Date.now(), + }; + } + + // ── Phase B: Title System ────────────────────────────────────────── + + /** Check if a single title condition is met. */ + private conditionMet(cond: TitleCondition, attrs: CatAttributes): boolean { + switch (cond.type) { + case 'dimension_level': + return (attrs.stats[cond.dimension]?.level ?? 0) >= cond.minLevel; + case 'overall_level': + return attrs.overallLevel >= cond.minLevel; + case 'total_footfall': + return attrs.totalFootfall >= cond.minFootfall; + } + } + + /** Check all title definitions against current attributes. Returns newly unlocked titles. */ + async checkTitleUnlocks(catId: string, attrs: CatAttributes): Promise { + const existing = await this.getUnlockedTitles(catId); + const existingIds = new Set(existing.map((t) => t.titleId)); + const newlyUnlocked: UnlockedTitle[] = []; + + for (const def of TITLE_DEFINITIONS) { + if (existingIds.has(def.id)) continue; + const allMet = def.conditions.every((c) => this.conditionMet(c, attrs)); + if (!allMet) continue; + + const unlock: UnlockedTitle = { titleId: def.id, catId, unlockedAt: Date.now() }; + await this.redis.zadd(growthTitleKey(catId), unlock.unlockedAt, JSON.stringify(unlock)); + newlyUnlocked.push(unlock); + log.info({ catId, titleId: def.id }, 'Title unlocked'); + } + return newlyUnlocked; + } + + /** Get all unlocked titles for a cat, newest first. */ + async getUnlockedTitles(catId: string): Promise { + const raw = await this.redis.zrevrange(growthTitleKey(catId), 0, -1); + return raw.map((s) => JSON.parse(s) as UnlockedTitle); + } + + /** Get the highest-rarity title for display in profile card. */ + async getCurrentTitle(catId: string): Promise { + const unlocked = await this.getUnlockedTitles(catId); + if (unlocked.length === 0) return undefined; + + const RARITY_ORDER: Record = { legendary: 4, epic: 3, rare: 2, common: 1 }; + const defMap = new Map(); + for (const d of TITLE_DEFINITIONS) defMap.set(d.id, d); + + let best: { def: TitleDefinition; unlock: UnlockedTitle } | undefined; + for (const u of unlocked) { + const def = defMap.get(u.titleId); + if (!def) continue; + if (!best || (RARITY_ORDER[def.rarity] ?? 0) > (RARITY_ORDER[best.def.rarity] ?? 0)) { + best = { def, unlock: u }; + } + } + if (!best) return undefined; + return { id: best.def.id, label: best.def.label, unlockedAt: best.unlock.unlockedAt }; + } + + // ── Phase B: Bond System ──────────────────────────────────────────── + + /** Record a collaboration event between two cats. Fire-and-forget. */ + recordBondEvent(catA: string, catB: string): void { + if (catA === catB) return; + const key = growthBondKey(catA, catB); + const pipeline = this.redis.pipeline(); + pipeline.hincrby(key, 'score', 1); + pipeline.hincrby(key, 'interactions', 1); + pipeline.hset(key, 'lastInteractionAt', String(Date.now())); + pipeline.hset(key, 'catA', catA < catB ? catA : catB); + pipeline.hset(key, 'catB', catA < catB ? catB : catA); + pipeline.exec().catch((err: unknown) => { + log.warn({ err, catA, catB }, 'Failed to record bond event'); + }); + } + + /** Get bond level from score. */ + static bondLevel(score: number): BondLevel { + if (score >= 50) return 'soulmate'; + if (score >= 15) return 'partner'; + return 'acquaintance'; + } + + /** Get all bonds for a cat by scanning Redis. */ + async getBonds(catId: string): Promise<(CatBond & { level: BondLevel })[]> { + const allCatIds = catRegistry.getAllIds().map(String); + const bonds: (CatBond & { level: BondLevel })[] = []; + + for (const otherId of allCatIds) { + if (otherId === catId) continue; + const key = growthBondKey(catId, otherId); + const data = await this.redis.hgetall(key); + if (!data || !data.score) continue; + const score = parseInt(data.score, 10) || 0; + if (score === 0) continue; + bonds.push({ + catA: data.catA ?? (catId < otherId ? catId : otherId), + catB: data.catB ?? (catId < otherId ? otherId : catId), + score, + interactions: parseInt(data.interactions ?? '0', 10) || 0, + lastInteractionAt: parseInt(data.lastInteractionAt ?? '0', 10) || 0, + level: GrowthService.bondLevel(score), + }); + } + return bonds.sort((a, b) => b.score - a.score); + } + + static readonly CO_CREATOR_ID = CO_CREATOR_ACTOR_ID; + + /** Build full journey profile for a cat or the co-creator. */ + async getProfile(catId: string): Promise { + if (isCoCreatorActor(catId)) { + return this.getCoCreatorProfile(); + } + const entry = catRegistry.tryGet(catId); + if (!entry) return null; + const config = entry.config; + + const attributes = await this.getAttributes(catId); + const currentTitle = await this.getCurrentTitle(catId); + return { + catId, + displayName: config.displayName ?? config.id, + nickname: config.nickname, + attributes, + currentTitle, + highlights: [], + }; + } + + /** AC-C6: Build journey profile for the co-creator (not in catRegistry). */ + private async getCoCreatorProfile(): Promise { + const attributes = await this.getAttributes(GrowthService.CO_CREATOR_ID); + const currentTitle = await this.getCurrentTitle(GrowthService.CO_CREATOR_ID); + return { + catId: GrowthService.CO_CREATOR_ID, + displayName: '铲屎官', + nickname: 'CVO', + attributes, + currentTitle, + highlights: [], + }; + } + + /** Build team journey overview across all registered cats + co-creator. */ + async getOverview(): Promise { + const catIds = catRegistry.getAllIds().map(String); + + const profiles = await Promise.all(catIds.map((id) => this.getProfile(id))); + const valid = profiles.filter((p): p is CatGrowthProfile => p !== null); + + // AC-C6: Include co-creator if they have any footfall + const coCreatorProfile = await this.getCoCreatorProfile(); + if (coCreatorProfile.attributes.totalFootfall > 0) { + valid.unshift(coCreatorProfile); + } + + const teamTotalFootfall = valid.reduce((s, p) => s + p.attributes.totalFootfall, 0); + const teamLevel = + valid.length > 0 ? Math.floor(valid.reduce((s, p) => s + p.attributes.overallLevel, 0) / valid.length) : 0; + + return { + profiles: valid, + teamLevel, + teamTotalFootfall, + fetchedAt: new Date().toISOString(), + }; + } +} diff --git a/packages/api/src/domains/cats/services/journey/LeadershipService.ts b/packages/api/src/domains/cats/services/journey/LeadershipService.ts new file mode 100644 index 0000000000..33427a4cd2 --- /dev/null +++ b/packages/api/src/domains/cats/services/journey/LeadershipService.ts @@ -0,0 +1,214 @@ +/** + * F160 Phase D — Co-Creator Leadership Service (铲屎官六维) + * + * Independent from cat trait dimensions. Tracks how effectively the co-creator + * coordinates, delegates, explores, and guides their cat team. + * + * Redis keys: + * leadership:{dimension} → total footfall (INCRBY) + * leadership:audit → sorted set of footfall events + * leadership:titles → sorted set of unlocked titles + * + * Level formula: same quadratic curve as cat traits — level = floor(sqrt(footfall / 100)) + */ + +import type { + CatTitle, + LeadershipDimension, + LeadershipFootfallSource, + LeadershipProfile, + LeadershipStat, + LeadershipTitleCondition, + LeadershipTitleDefinition, + UnlockedTitle, +} from '@cat-cafe/shared'; +import { + CO_CREATOR_ACTOR_ID, + LEADERSHIP_DIMENSIONS, + LEADERSHIP_SHADOW_DIMS, + LEADERSHIP_TITLE_DEFINITIONS, +} from '@cat-cafe/shared'; +import type { RedisClient } from '@cat-cafe/shared/utils'; +import { createModuleLogger } from '../../../../infrastructure/logger.js'; +import { leadershipAuditKey, leadershipTitleKey, leadershipXpKey } from '../stores/redis-keys/growth-keys.js'; + +const log = createModuleLogger('leadership'); + +// ── Footfall Rules ────────────────────────────────────────────── + +const LEADERSHIP_FOOTFALL_RULES: Record< + LeadershipFootfallSource, + { dimension: LeadershipDimension; footfall: number } +> = { + multi_mention_dispatch: { dimension: 'coordination', footfall: 15 }, + multi_mention_success: { dimension: 'coordination', footfall: 25 }, + target_diversity: { dimension: 'coordination', footfall: 10 }, + task_no_intervention: { dimension: 'delegation', footfall: 20 }, + deep_collab_initiated: { dimension: 'delegation', footfall: 15 }, + tool_category_breadth: { dimension: 'exploration', footfall: 10 }, + new_skill_first_use: { dimension: 'exploration', footfall: 30 }, + feature_initiated: { dimension: 'exploration', footfall: 20 }, + one_shot_completion: { dimension: 'guidance', footfall: 25 }, + low_clarification: { dimension: 'guidance', footfall: 15 }, + direction_confirmed: { dimension: 'decision', footfall: 20 }, + direction_confirmed_explicit: { dimension: 'decision', footfall: 20 }, + feedback_applied: { dimension: 'feedback', footfall: 20 }, + clarification_observed: { dimension: 'decision', footfall: 1 }, +}; + +// ── Level Math ─────────────────────────────────────────────────── + +function levelFromFootfall(footfall: number): number { + return Math.floor(Math.sqrt(footfall / 100)); +} + +function footfallForLevel(level: number): number { + return level * level * 100; +} + +const shadowSet = new Set(LEADERSHIP_SHADOW_DIMS); + +function buildLeadershipStat(dimension: LeadershipDimension, footfall: number): LeadershipStat { + const level = levelFromFootfall(footfall); + return { + dimension, + footfall, + level, + footfallToNext: footfallForLevel(level + 1) - footfall, + shadow: shadowSet.has(dimension), + }; +} + +const RARITY_ORDER: Record = { legendary: 4, epic: 3, rare: 2, common: 1 }; + +// ── Service ────────────────────────────────────────────────────── + +export class LeadershipService { + constructor(private readonly redis: RedisClient) {} + + /** Award leadership footfall. Fire-and-forget. */ + awardFootfall(source: LeadershipFootfallSource, multiplier = 1): void { + const rule = LEADERSHIP_FOOTFALL_RULES[source]; + if (!rule) return; + const amount = Math.max(1, Math.round(rule.footfall * multiplier)); + const key = leadershipXpKey(rule.dimension); + const ts = Date.now(); + + const pipeline = this.redis.pipeline(); + pipeline.incrby(key, amount); + const event = { dimension: rule.dimension, footfall: amount, source, timestamp: ts }; + const member = JSON.stringify({ ...event, _seq: Math.random().toString(36).slice(2, 8) }); + pipeline.zadd(leadershipAuditKey(), ts, member); + pipeline + .exec() + .then(() => { + // AC-D4: Check title unlocks after footfall change (fire-and-forget) + this.getProfile() + .then((profile) => this.checkTitleUnlocks(profile)) + .catch((err: unknown) => log.warn({ err }, 'Leadership title check failed')); + }) + .catch((err: unknown) => { + log.warn({ err, source }, 'Failed to award leadership footfall'); + }); + } + + /** Read the full leadership profile snapshot (includes currentTitle). */ + async getProfile(): Promise { + const keys = LEADERSHIP_DIMENSIONS.map((d) => leadershipXpKey(d)); + const values = await this.redis.mget(...keys); + + const stats = {} as Record; + let totalFootfall = 0; + let levelSum = 0; + let activeDims = 0; + + for (let i = 0; i < LEADERSHIP_DIMENSIONS.length; i++) { + const dim = LEADERSHIP_DIMENSIONS[i]!; + const footfall = parseInt(values[i] ?? '0', 10) || 0; + stats[dim] = buildLeadershipStat(dim, footfall); + totalFootfall += footfall; + if (!shadowSet.has(dim)) { + levelSum += stats[dim].level; + if (footfall > 0) activeDims++; + } + } + + const currentTitle = await this.getCurrentTitle(); + + return { + stats, + leadershipLevel: activeDims > 0 ? Math.floor(levelSum / activeDims) : 0, + totalFootfall, + currentTitle, + updatedAt: Date.now(), + }; + } + + // ── Title System (AC-D4) ────────────────────────────────────── + + private conditionMet(cond: LeadershipTitleCondition, profile: LeadershipProfile): boolean { + switch (cond.type) { + case 'leadership_dim_level': + return (profile.stats[cond.dimension]?.level ?? 0) >= cond.minLevel; + case 'leadership_level': + return profile.leadershipLevel >= cond.minLevel; + case 'leadership_total_footfall': + return profile.totalFootfall >= cond.minFootfall; + } + } + + async checkTitleUnlocks(profile: LeadershipProfile): Promise { + const existing = await this.getUnlockedTitles(); + const existingIds = new Set(existing.map((t) => t.titleId)); + const newlyUnlocked: UnlockedTitle[] = []; + + for (const def of LEADERSHIP_TITLE_DEFINITIONS) { + if (existingIds.has(def.id)) continue; + if (!def.conditions.every((c) => this.conditionMet(c, profile))) continue; + + const ts = Date.now(); + // Use titleId as member — ZADD is idempotent per member, so concurrent + // fire-and-forget calls cannot create duplicate unlock records. + await this.redis.zadd(leadershipTitleKey(), ts, def.id); + const unlock: UnlockedTitle = { titleId: def.id, catId: CO_CREATOR_ACTOR_ID, unlockedAt: ts }; + newlyUnlocked.push(unlock); + log.info({ titleId: def.id }, 'Leadership title unlocked'); + } + return newlyUnlocked; + } + + async getUnlockedTitles(): Promise { + // Members are titleId strings, scores are unlock timestamps + const raw = await this.redis.zrevrange(leadershipTitleKey(), 0, -1, 'WITHSCORES'); + const titles: UnlockedTitle[] = []; + for (let i = 0; i < raw.length; i += 2) { + titles.push({ titleId: raw[i]!, catId: CO_CREATOR_ACTOR_ID, unlockedAt: parseInt(raw[i + 1]!, 10) }); + } + return titles; + } + + async getCurrentTitle(): Promise { + const unlocked = await this.getUnlockedTitles(); + if (unlocked.length === 0) return undefined; + + const defMap = new Map(); + for (const d of LEADERSHIP_TITLE_DEFINITIONS) defMap.set(d.id, d); + + let best: { def: LeadershipTitleDefinition; unlock: UnlockedTitle } | undefined; + for (const u of unlocked) { + const def = defMap.get(u.titleId); + if (!def) continue; + if (!best || (RARITY_ORDER[def.rarity] ?? 0) > (RARITY_ORDER[best.def.rarity] ?? 0)) { + best = { def, unlock: u }; + } + } + if (!best) return undefined; + return { id: best.def.id, label: best.def.label, unlockedAt: best.unlock.unlockedAt }; + } + + /** Fetch recent leadership footfall events, newest first. */ + async getAuditLog(limit = 50, offset = 0): Promise { + const raw = await this.redis.zrevrange(leadershipAuditKey(), offset, offset + limit - 1); + return raw.map((s) => JSON.parse(s)); + } +} diff --git a/packages/api/src/domains/cats/services/journey/MonthlyReviewService.ts b/packages/api/src/domains/cats/services/journey/MonthlyReviewService.ts new file mode 100644 index 0000000000..4bb4055ffb --- /dev/null +++ b/packages/api/src/domains/cats/services/journey/MonthlyReviewService.ts @@ -0,0 +1,139 @@ +/** + * F160 AC-E3: Monthly Cat Self-Review Generator + * Deterministically builds a Markdown review from journey data. + * Called by the `monthlyReview` scheduler template on cron trigger. + */ + +import type { CatBond, CatJourneyProfile, EvolutionEvent, FootfallEvent, TraitDimension } from '@cat-cafe/shared'; +import { catRegistry, GROWTH_DIMENSIONS } from '@cat-cafe/shared'; +import type { EvolutionService } from './EvolutionService.js'; +import type { GrowthService } from './GrowthService.js'; + +const DIM_LABELS: Record = { + architecture: '架构力', + review: '审查力', + aesthetics: '审美力', + execution: '执行力', + collaboration: '协作力', + insight: '洞察力', +}; + +const SOURCE_LABELS: Record = { + task_complete: '完成任务', + session_seal: '会话封存', + review_given: '代码审查', + review_received: '收到审查', + tool_use: '工具调用', + tool_use_mcp: 'MCP 工具', + tool_use_skill: '技能调用', + mention_collab: '@协作', + deep_collab: '深度协作', + discussion: '讨论', + pr_merged: 'PR 合并', + bug_caught: '发现 Bug', + design_feedback: '设计反馈', + rich_block_create: '创建卡片', + evidence_cite: '引用证据', + cache_efficiency: '缓存效率', + ideate_discussion: '构思讨论', + error_recovery: '错误恢复', + fast_execution: '快速执行', +}; + +const BOND_LEVELS: Record = { soulmate: '灵魂伙伴', partner: '伙伴', acquaintance: '相识' }; + +/** Default review period: 30 days. */ +const DEFAULT_PERIOD_MS = 30 * 86_400_000; + +export class MonthlyReviewService { + constructor( + private readonly growth: GrowthService, + private readonly evolution: EvolutionService, + ) {} + + /** Generate a Markdown review for one cat. Returns null if cat not found. */ + async generate(catId: string, periodMs = DEFAULT_PERIOD_MS): Promise { + const profile = await this.growth.getProfile(catId); + if (!profile) return null; + + const since = Date.now() - periodMs; + const [allEvents, allMilestones, bonds] = await Promise.all([ + this.growth.getFootfallEvents(catId, 200, 0), + this.evolution.getEvents(catId, 50, 0), + this.growth.getBonds(catId), + ]); + + return buildMarkdown( + profile, + allEvents.filter((e) => e.timestamp >= since), + allMilestones.filter((e) => e.timestamp >= since), + bonds, + ); + } +} + +// ── Markdown builder ───────────────────────────────────────────── + +function buildMarkdown( + profile: CatJourneyProfile, + events: FootfallEvent[], + milestones: EvolutionEvent[], + bonds: (CatBond & { level: string })[], +): string { + const { attributes } = profile; + const name = profile.nickname ?? profile.displayName; + const month = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; + const lines: string[] = []; + + // Header + lines.push(`# ${name} 月度足迹回顾`, ''); + lines.push(`> ${month} · Lv.${attributes.overallLevel} · ${attributes.totalFootfall.toLocaleString()} 足迹点`); + if (profile.currentTitle) lines.push(`> 当前称号: 「${profile.currentTitle.label.zh}」`); + lines.push(''); + + // Dimension stats + lines.push('## 维度成长', '', '| 维度 | 等级 | 足迹点 |', '|------|------|--------|'); + for (const dim of GROWTH_DIMENSIONS) { + const s = attributes.stats[dim]; + if (s) lines.push(`| ${DIM_LABELS[dim]} | Lv.${s.level} | ${s.footfall.toLocaleString()} |`); + } + lines.push(''); + + // Milestones + if (milestones.length > 0) { + lines.push('## 本月里程碑', ''); + for (const m of milestones) lines.push(`- ${m.narrative.zh}`); + lines.push(''); + } + + // Activity summary (aggregate by source, sort by total footfall desc) + if (events.length > 0) { + const bySource = new Map(); + for (const ev of events) { + const cur = bySource.get(ev.source) ?? { count: 0, footfall: 0 }; + cur.count++; + cur.footfall += ev.footfall; + bySource.set(ev.source, cur); + } + lines.push('## 活动概要', ''); + for (const [source, { count, footfall }] of [...bySource].sort((a, b) => b[1].footfall - a[1].footfall)) { + lines.push(`- ${SOURCE_LABELS[source] ?? source} ${count} 次 (+${footfall.toLocaleString()} 足迹点)`); + } + lines.push(''); + } + + // Bonds (top 5) + const active = bonds.filter((b) => b.score > 0).slice(0, 5); + if (active.length > 0) { + lines.push('## 协作关系', ''); + for (const bond of active) { + const otherId = bond.catA === profile.catId ? bond.catB : bond.catA; + const other = catRegistry.tryGet(otherId); + const otherName = other?.config.nickname ?? other?.config.displayName ?? otherId; + lines.push(`- 与 ${otherName}: ${BOND_LEVELS[bond.level] ?? bond.level} (${bond.score} 分)`); + } + lines.push(''); + } + + return lines.join('\n'); +} diff --git a/packages/api/src/domains/cats/services/stores/redis-keys/achievement-keys.ts b/packages/api/src/domains/cats/services/stores/redis-keys/achievement-keys.ts new file mode 100644 index 0000000000..15a0c9b68e --- /dev/null +++ b/packages/api/src/domains/cats/services/stores/redis-keys/achievement-keys.ts @@ -0,0 +1,15 @@ +/** + * Redis key patterns for achievement system — F160 Phase C. + * Key: achievement:unlocked:{memberId} → sorted set (score = timestamp, member = JSON) + * Key: achievement:counters:{memberId} → hash of event counters (tasks, reviews, sessions) + */ + +/** Sorted set of unlocked achievements. Score = unlock timestamp. */ +export function achievementUnlockedKey(memberId: string): string { + return `achievement:unlocked:${memberId}`; +} + +/** Hash of event counters for auto-trigger checks. */ +export function achievementCountersKey(memberId: string): string { + return `achievement:counters:${memberId}`; +} diff --git a/packages/api/src/domains/cats/services/stores/redis-keys/growth-keys.ts b/packages/api/src/domains/cats/services/stores/redis-keys/growth-keys.ts new file mode 100644 index 0000000000..13f5257d77 --- /dev/null +++ b/packages/api/src/domains/cats/services/stores/redis-keys/growth-keys.ts @@ -0,0 +1,68 @@ +/** + * Redis key patterns for cat journey footfall counters — F160. + * Key: journey:{catId}:{dimension} → total XP (INCRBY, no TTL — lifetime stat) + * Key: journey:audit:{catId} → sorted set of XP events (score = timestamp) + */ + +/** Persistent XP counter per cat per dimension. */ +export function growthXpKey(catId: string, dimension: string): string { + return `journey:${catId}:${dimension}`; +} + +/** Sorted set holding XP event audit trail for one cat. Score = epoch ms. */ +export function growthAuditKey(catId: string): string { + return `journey:audit:${catId}`; +} + +/** SCAN pattern to match all journey keys for one cat. */ +export function growthCatScan(catId: string): string { + return `journey:${catId}:*`; +} + +/** SCAN pattern to match all journey keys. */ +export const GROWTH_SCAN_ALL = 'journey:*'; + +// ── Phase B: Title + Bond keys ───────────────────────────────────── + +/** Sorted set of unlocked titles for one cat. Score = unlock timestamp, member = title JSON. */ +export function growthTitleKey(catId: string): string { + return `journey:titles:${catId}`; +} + +/** Bond score between two cats. Key is always sorted (catA < catB) to ensure uniqueness. */ +export function growthBondKey(catA: string, catB: string): string { + const [a, b] = catA < catB ? [catA, catB] : [catB, catA]; + return `journey:bond:${a}:${b}`; +} + +/** SCAN pattern to match all bond keys for one cat. */ +export function growthBondScan(catId: string): string { + return `journey:bond:*${catId}*`; +} + +// ── Phase D: Leadership keys (铲屎官六维) ──────────────────────── + +/** Persistent XP counter per leadership dimension. */ +export function leadershipXpKey(dimension: string): string { + return `leadership:${dimension}`; +} + +/** Sorted set holding leadership XP audit trail. Score = epoch ms. */ +export function leadershipAuditKey(): string { + return 'leadership:audit'; +} + +/** Sorted set of unlocked leadership titles. Score = unlock timestamp, member = JSON. */ +export function leadershipTitleKey(): string { + return 'leadership:titles'; +} + +/** SCAN pattern to match all leadership keys. */ +export const LEADERSHIP_SCAN_ALL = 'leadership:*'; + +// ── Phase E: Evolution event keys ──────────────────────────────── + +/** Sorted set holding evolution events for one cat. Score = epoch ms. */ +export function evolutionEventKey(catId: string): string { + return `evolution:${catId}`; +} diff --git a/packages/api/src/infrastructure/scheduler/templates/monthly-review.ts b/packages/api/src/infrastructure/scheduler/templates/monthly-review.ts new file mode 100644 index 0000000000..e18e28c4ce --- /dev/null +++ b/packages/api/src/infrastructure/scheduler/templates/monthly-review.ts @@ -0,0 +1,62 @@ +/** + * F160 AC-E3: Monthly Review Scheduler Template + * + * Generates a Markdown growth review per cat and delivers it to a thread. + * Default cron: 1st of every month at 09:00. + * If no targetCatId param, generates for all registered cats. + */ + +import { catRegistry } from '@cat-cafe/shared'; +import type { MonthlyReviewService } from '../../../domains/cats/services/journey/MonthlyReviewService.js'; +import type { TaskSpec_P1 } from '../types.js'; +import type { DynamicTaskParams, TaskTemplate } from './types.js'; + +export function createMonthlyReviewTemplate(reviewService: MonthlyReviewService): TaskTemplate { + return { + templateId: 'monthlyReview', + label: '月度足迹回顾', + category: 'system', + description: '每月自动生成猫猫成长回顾报告', + subjectKind: 'none', + defaultTrigger: { type: 'cron', expression: '0 9 1 * *' }, + paramSchema: { + targetCatId: { type: 'string', required: false, description: '指定猫猫(留空生成全部)' }, + }, + createSpec(instanceId: string, p: DynamicTaskParams): TaskSpec_P1 { + const targetCatId = (p.params.targetCatId as string) || null; + const threadId = p.deliveryThreadId; + return { + id: instanceId, + profile: 'awareness', + trigger: p.trigger, + admission: { + async gate() { + if (!threadId) return { run: false, reason: 'no deliveryThreadId' }; + const ids = targetCatId ? [targetCatId] : catRegistry.getAllIds().map(String); + return { run: true, workItems: ids.map((id) => ({ signal: id, subjectKey: `review-${id}` })) }; + }, + }, + run: { + overlap: 'skip', + timeoutMs: 60_000, + async execute(signal, _subjectKey, ctx) { + if (!ctx.deliver) throw new Error('deliver not available'); + const catId = signal as string; + const review = await reviewService.generate(catId); + if (!review) return; + await ctx.deliver({ threadId: threadId!, content: review, userId: 'scheduler' }); + }, + }, + state: { runLedger: 'sqlite' }, + outcome: { whenNoSignal: 'drop' }, + enabled: () => true, + display: { + label: '月度足迹回顾', + category: 'system', + description: targetCatId ? `${targetCatId} 月度回顾` : '全猫月度回顾', + subjectKind: 'none', + }, + }; + }, + }; +} diff --git a/packages/api/src/routes/achievements.ts b/packages/api/src/routes/achievements.ts new file mode 100644 index 0000000000..26333937f6 --- /dev/null +++ b/packages/api/src/routes/achievements.ts @@ -0,0 +1,99 @@ +/** + * Achievement Routes — F160 Phase C + * GET /api/achievements/:memberId — unlocked achievements for a member + * GET /api/achievements/:memberId/wall — all achievements with unlock status + * GET /api/achievements/:memberId/counters — event counters for progress display + * POST /api/achievements/:memberId/export-image — PNG screenshot of achievement wall (AC-C4) + */ + +import { ACHIEVEMENT_DEFINITIONS } from '@cat-cafe/shared'; +import type { FastifyPluginAsync } from 'fastify'; +import { resolveFrontendBaseUrl } from '../config/frontend-origin.js'; +import type { AchievementService } from '../domains/cats/services/journey/AchievementService.js'; +import { ImageExporter } from '../services/ImageExporter.js'; +import { resolveHeaderUserId } from '../utils/request-identity.js'; + +export interface AchievementRoutesOptions { + achievementService: AchievementService; +} + +export const achievementRoutes: FastifyPluginAsync = async (app, opts) => { + const { achievementService } = opts; + + // Plugin-scoped singleton ImageExporter (browser reuse across requests) + let sharedExporter: ImageExporter | null = null; + app.addHook('onClose', async () => { + if (sharedExporter) { + await sharedExporter.close(); + sharedExporter = null; + } + }); + + /** Unlocked achievements for a member (cat or co-creator) */ + app.get<{ Params: { memberId: string } }>('/api/achievements/:memberId', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const { memberId } = request.params; + const unlocked = await achievementService.getUnlocked(memberId); + return { memberId, unlocked }; + }); + + /** Achievement wall — all definitions with unlock status */ + app.get<{ Params: { memberId: string } }>('/api/achievements/:memberId/wall', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const { memberId } = request.params; + const unlocked = await achievementService.getUnlocked(memberId); + const unlockedMap = new Map(unlocked.map((u) => [u.achievementId, u])); + + const wall = ACHIEVEMENT_DEFINITIONS.map((def) => ({ + ...def, + unlocked: unlockedMap.get(def.id) ?? null, + })); + return { + memberId, + achievements: wall, + totalUnlocked: unlocked.length, + totalDefined: ACHIEVEMENT_DEFINITIONS.length, + }; + }); + + /** Event counters for progress display */ + app.get<{ Params: { memberId: string } }>('/api/achievements/:memberId/counters', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const { memberId } = request.params; + const counters = await achievementService.getCounters(memberId); + return { memberId, counters }; + }); + + /** AC-C4: Export achievement wall as PNG image */ + app.post<{ Params: { memberId: string } }>('/api/achievements/:memberId/export-image', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const { memberId } = request.params; + const unlocked = await achievementService.getUnlocked(memberId); + if (unlocked.length === 0) { + return reply.status(404).send({ error: '暂无已解锁成就' }); + } + + try { + const frontendUrl = resolveFrontendBaseUrl(process.env, app.log); + const url = `${frontendUrl}/achievement-export/${memberId}`; + app.log.info({ memberId, url }, 'Exporting achievement wall to PNG'); + + const exporter = sharedExporter ?? (sharedExporter = new ImageExporter()); + const imageBuffer = await exporter.capture(url, userId, 480); + + return reply.type('image/png').send(imageBuffer); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + app.log.error({ error: msg, memberId }, 'Achievement export failed'); + return reply.status(500).send({ error: 'Export failed', message: msg }); + } + }); +}; diff --git a/packages/api/src/routes/growth.ts b/packages/api/src/routes/growth.ts new file mode 100644 index 0000000000..aa6730d8ff --- /dev/null +++ b/packages/api/src/routes/growth.ts @@ -0,0 +1,140 @@ +/** + * Journey Routes — F160 Cat Journey RPG + * GET /api/journey/overview — team-wide journey overview + * GET /api/journey/:catId — single cat journey profile + * GET /api/journey/:catId/events — XP event audit trail (AC-A5) + * GET /api/journey/:catId/titles — unlocked titles (AC-B1) + * GET /api/journey/:catId/bonds — bond relationships (AC-B2) + * POST /api/journey/:catId/export-image — PNG screenshot of profile card (AC-A3) + */ + +import type { FastifyPluginAsync } from 'fastify'; +import { resolveFrontendBaseUrl } from '../config/frontend-origin.js'; +import type { GrowthService } from '../domains/cats/services/journey/GrowthService.js'; +import { ImageExporter } from '../services/ImageExporter.js'; +import { resolveHeaderUserId } from '../utils/request-identity.js'; + +export interface GrowthRoutesOptions { + growthService: GrowthService; + /** Phase E (AC-E1): Evolution event service for milestone timeline */ + evolutionService?: import('../domains/cats/services/journey/EvolutionService.js').EvolutionService; +} + +export const journeyRoutes: FastifyPluginAsync = async (app, opts) => { + const { growthService } = opts; + + // Plugin-scoped singleton ImageExporter (browser reuse across requests) + let sharedExporter: ImageExporter | null = null; + app.addHook('onClose', async () => { + if (sharedExporter) { + await sharedExporter.close(); + sharedExporter = null; + } + }); + + /** Team overview — all cats' journey profiles */ + app.get('/api/journey/overview', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const overview = await growthService.getOverview(); + return overview; + }); + + /** Single cat journey profile */ + app.get<{ Params: { catId: string } }>('/api/journey/:catId', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const { catId } = request.params; + const profile = await growthService.getProfile(catId); + if (!profile) { + return reply.status(404).send({ error: `Cat not found: ${catId}` }); + } + return profile; + }); + + /** AC-A5: Footfall event audit trail — newest first, with pagination. */ + app.get<{ Params: { catId: string }; Querystring: { limit?: string; offset?: string } }>( + '/api/journey/:catId/events', + async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const { catId } = request.params; + const limit = Math.min(Math.max(parseInt(request.query.limit ?? '50', 10) || 50, 1), 200); + const offset = Math.max(parseInt(request.query.offset ?? '0', 10) || 0, 0); + + const events = await growthService.getFootfallEvents(catId, limit, offset); + return { catId, events, limit, offset }; + }, + ); + + /** AC-B1: Unlocked titles for a cat — includes all definitions with unlock status. */ + app.get<{ Params: { catId: string } }>('/api/journey/:catId/titles', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const { catId } = request.params; + const unlocked = await growthService.getUnlockedTitles(catId); + return { catId, unlocked }; + }); + + /** AC-B2: Bond relationships for a cat. */ + app.get<{ Params: { catId: string } }>('/api/journey/:catId/bonds', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const { catId } = request.params; + const bonds = await growthService.getBonds(catId); + return { catId, bonds }; + }); + + /** AC-E1: Evolution milestone events — newest first, with pagination. */ + if (opts.evolutionService) { + const evoSvc = opts.evolutionService; + app.get<{ Params: { catId: string }; Querystring: { limit?: string; offset?: string } }>( + '/api/journey/:catId/evolution', + async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const { catId } = request.params; + const limit = Math.min(Math.max(parseInt(request.query.limit ?? '50', 10) || 50, 1), 200); + const offset = Math.max(parseInt(request.query.offset ?? '0', 10) || 0, 0); + + const events = await evoSvc.getEvents(catId, limit, offset); + return { catId, events, limit, offset }; + }, + ); + } + + /** AC-A3: Export cat profile card as PNG image */ + app.post<{ Params: { catId: string } }>('/api/journey/:catId/export-image', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const { catId } = request.params; + + // Verify cat exists before launching Puppeteer + const profile = await growthService.getProfile(catId); + if (!profile) { + return reply.status(404).send({ error: `Cat not found: ${catId}` }); + } + + try { + const frontendUrl = resolveFrontendBaseUrl(process.env, app.log); + const url = `${frontendUrl}/growth-export/${catId}`; + app.log.info({ catId, url }, 'Exporting journey card to PNG'); + + const exporter = sharedExporter ?? (sharedExporter = new ImageExporter()); + const imageBuffer = await exporter.capture(url, userId, 480); + + return reply.type('image/png').send(imageBuffer); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + app.log.error({ error: msg, catId }, 'Journey card export failed'); + return reply.status(500).send({ error: 'Export failed', message: msg }); + } + }); +}; diff --git a/packages/api/src/routes/leadership.ts b/packages/api/src/routes/leadership.ts new file mode 100644 index 0000000000..bee97b97fe --- /dev/null +++ b/packages/api/src/routes/leadership.ts @@ -0,0 +1,51 @@ +/** + * Leadership Routes — F160 Phase D (铲屎官六维) + * GET /api/journey/leadership — co-creator leadership profile + * GET /api/journey/leadership/titles — unlocked leadership titles + * GET /api/journey/leadership/events — leadership footfall audit trail + */ + +import type { FastifyPluginAsync } from 'fastify'; +import type { LeadershipService } from '../domains/cats/services/journey/LeadershipService.js'; +import { resolveHeaderUserId } from '../utils/request-identity.js'; + +export interface LeadershipRoutesOptions { + leadershipService: LeadershipService; +} + +export const leadershipRoutes: FastifyPluginAsync = async (app, opts) => { + const { leadershipService } = opts; + + /** Co-creator leadership profile snapshot */ + app.get('/api/journey/leadership', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const profile = await leadershipService.getProfile(); + return profile; + }); + + /** Unlocked leadership titles, newest first. */ + app.get('/api/journey/leadership/titles', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const titles = await leadershipService.getUnlockedTitles(); + return { titles }; + }); + + /** Leadership footfall audit trail — newest first, with pagination. */ + app.get<{ Querystring: { limit?: string; offset?: string } }>( + '/api/journey/leadership/events', + async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) return reply.status(401).send({ error: 'Missing X-Cat-Cafe-User header' }); + + const limit = Math.min(Math.max(parseInt(request.query.limit ?? '50', 10) || 50, 1), 200); + const offset = Math.max(parseInt(request.query.offset ?? '0', 10) || 0, 0); + + const events = await leadershipService.getAuditLog(limit, offset); + return { events, limit, offset }; + }, + ); +}; diff --git a/packages/api/test/growth-service.test.js b/packages/api/test/growth-service.test.js new file mode 100644 index 0000000000..c40f8080a7 --- /dev/null +++ b/packages/api/test/growth-service.test.js @@ -0,0 +1,280 @@ +/** + * F157 GrowthService — unit tests + * + * Covers: level formula, overallLevel active-dimension averaging, + * audit event uniqueness (nonce), and footfall award pipeline. + */ + +// Disable pino transport worker threads BEFORE any module import. +// Without this, pino.transport() spawns threads that outlive tests. +// Must run before setup-cat-registry (which transitively loads logger via cat-config-loader), +// so setup-cat-registry uses dynamic import — ESM hoists static imports before module code. +process.env.PINO_DISABLE_TRANSPORT = '1'; + +await import('./helpers/setup-cat-registry.js'); + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +/** Import pure helpers directly (they don't depend on Redis). */ +const { GrowthService } = await import('../dist/domains/cats/services/journey/GrowthService.js'); +const { detectInvocationPurpose } = await import('../dist/routes/callback-a2a-trigger.js'); + +// ── level formula ────────────────────────────────────────────── + +describe('Level formula', () => { + test('level 0 at 0 footfall', () => { + assert.equal(Math.floor(Math.sqrt(0 / 100)), 0); + }); + + test('level 0 at 99 footfall', () => { + assert.equal(Math.floor(Math.sqrt(99 / 100)), 0); + }); + + test('level 1 at 100 footfall', () => { + assert.equal(Math.floor(Math.sqrt(100 / 100)), 1); + }); + + test('level 2 at 400 footfall', () => { + assert.equal(Math.floor(Math.sqrt(400 / 100)), 2); + }); + + test('level 3 at 900 footfall', () => { + assert.equal(Math.floor(Math.sqrt(900 / 100)), 3); + }); +}); + +// ── overallLevel excludes zero-XP dimensions ─────────────────── + +describe('overallLevel active-dimension averaging', () => { + /** + * Minimal Redis mock — stores footfall values directly in a Map. + * Tests set store values directly (bypassing awardFootfall) to avoid + * fire-and-forget title check chains that leak async activity. + */ + function createMockRedis() { + const store = new Map(); + return { + store, + options: { keyPrefix: '' }, + async mget(...keys) { + return keys.map((k) => store.get(k) ?? null); + }, + }; + } + + test('overallLevel only averages dimensions with footfall > 0', async () => { + const redis = createMockRedis(); + const svc = new GrowthService(redis); + + redis.store.set('journey:testcat:execution', '1'); + + const attrs = await svc.getAttributes('testcat'); + assert.equal(attrs.overallLevel, 0); + + redis.store.set('journey:testcat:execution', '100'); + + const attrs2 = await svc.getAttributes('testcat'); + assert.equal(attrs2.overallLevel, 1); + assert.equal(attrs2.totalFootfall, 100); + }); + + test('overallLevel is 0 when no dimensions have footfall', async () => { + const redis = createMockRedis(); + const svc = new GrowthService(redis); + const attrs = await svc.getAttributes('empty'); + assert.equal(attrs.overallLevel, 0); + assert.equal(attrs.totalFootfall, 0); + }); +}); + +// ── Phase B: title unlock ────────────────────────────────────── + +describe('Title auto-unlock', () => { + /** Extended mock Redis with sorted set + hash support for title tests. */ + function createTitleMockRedis() { + const store = new Map(); + const zsets = new Map(); + const hashes = new Map(); + return { + options: { keyPrefix: '' }, + async mget(...keys) { + return keys.map((k) => store.get(k) ?? null); + }, + pipeline() { + const ops = []; + const self = { + incrby(key, amount) { + const cur = parseInt(store.get(key) ?? '0', 10); + store.set(key, String(cur + amount)); + ops.push(['incrby', key, amount]); + return self; + }, + zadd(key, score, member) { + if (!zsets.has(key)) zsets.set(key, []); + zsets.get(key).push({ score, member }); + ops.push(['zadd', key, score, member]); + return self; + }, + hincrby(key, field, amount) { + if (!hashes.has(key)) hashes.set(key, {}); + const h = hashes.get(key); + h[field] = String(parseInt(h[field] ?? '0', 10) + amount); + return self; + }, + hset(key, field, value) { + if (!hashes.has(key)) hashes.set(key, {}); + hashes.get(key)[field] = value; + return self; + }, + async exec() { + return ops.map(() => [null, 'OK']); + }, + }; + return self; + }, + async zadd(key, score, member) { + if (!zsets.has(key)) zsets.set(key, []); + zsets.get(key).push({ score, member }); + return 1; + }, + async zrevrange(key, start, stop) { + const items = zsets.get(key) ?? []; + const sorted = [...items].sort((a, b) => b.score - a.score); + const end = stop === -1 ? sorted.length : stop + 1; + return sorted.slice(start, end).map((i) => i.member); + }, + async hgetall(key) { + return hashes.get(key) ?? null; + }, + }; + } + + test('checkTitleUnlocks returns newly unlocked titles', async () => { + const redis = createTitleMockRedis(); + const svc = new GrowthService(redis); + + // Manually set execution footfall to 400 (level 2) → should unlock 'doer' (execution Lv.2) + redis.pipeline().incrby('journey:testcat:execution', 400); + await redis.pipeline().exec(); + // Manually store to make mget work + const curStore = new Map(); + curStore.set('journey:testcat:execution', '400'); + redis.mget = async (...keys) => keys.map((k) => curStore.get(k) ?? null); + + const attrs = await svc.getAttributes('testcat'); + assert.equal(attrs.stats.execution.level, 2, 'execution should be level 2'); + + const newTitles = await svc.checkTitleUnlocks('testcat', attrs); + const doer = newTitles.find((t) => t.titleId === 'doer'); + assert.ok(doer, 'doer title should be unlocked at execution Lv.2'); + + // First-step should also unlock (overall level >= 1) + const firstStep = newTitles.find((t) => t.titleId === 'first-step'); + assert.ok(firstStep, 'first-step should be unlocked at overall Lv.1+'); + }); + + test('checkTitleUnlocks does not re-unlock existing titles', async () => { + const redis = createTitleMockRedis(); + const svc = new GrowthService(redis); + + // Pre-unlock 'first-step' + await redis.zadd( + 'journey:titles:testcat', + Date.now(), + JSON.stringify({ + titleId: 'first-step', + catId: 'testcat', + unlockedAt: Date.now(), + }), + ); + + const curStore = new Map(); + curStore.set('journey:testcat:execution', '400'); + redis.mget = async (...keys) => keys.map((k) => curStore.get(k) ?? null); + + const attrs = await svc.getAttributes('testcat'); + const newTitles = await svc.checkTitleUnlocks('testcat', attrs); + const firstStepDups = newTitles.filter((t) => t.titleId === 'first-step'); + assert.equal(firstStepDups.length, 0, 'first-step should not be re-unlocked'); + }); +}); + +// ── Phase B: bond system ────────────────────────────────────── + +describe('Bond system', () => { + test('bondLevel returns correct tier', () => { + assert.equal(GrowthService.bondLevel(1), 'acquaintance'); + assert.equal(GrowthService.bondLevel(14), 'acquaintance'); + assert.equal(GrowthService.bondLevel(15), 'partner'); + assert.equal(GrowthService.bondLevel(49), 'partner'); + assert.equal(GrowthService.bondLevel(50), 'soulmate'); + assert.equal(GrowthService.bondLevel(100), 'soulmate'); + }); +}); + +// ── Phase B: review intent detection ────────────────────────── + +describe('Review intent detection', () => { + test('detects review from Chinese patterns', () => { + assert.equal(detectInvocationPurpose('请 review 一下这个 PR'), 'review'); + assert.equal(detectInvocationPurpose('帮我看看这段代码'), 'review'); + assert.equal(detectInvocationPurpose('这是 re-review 请求'), 'review'); + }); + + test('detects review from English patterns', () => { + assert.equal(detectInvocationPurpose('request review for F157'), 'review'); + assert.equal(detectInvocationPurpose('code review needed'), 'review'); + }); + + test('returns discussion for non-review content', () => { + assert.equal(detectInvocationPurpose('你觉得这个设计怎么样?'), 'discussion'); + assert.equal(detectInvocationPurpose('help me implement this feature'), 'discussion'); + }); +}); + +// ── audit event uniqueness ───────────────────────────────────── + +describe('Audit event uniqueness', () => { + test('same-millisecond identical events produce unique ZADD members (fixed clock)', async () => { + const members = new Set(); + const mockRedis = { + options: { keyPrefix: '' }, + pipeline() { + const self = { + incrby() { + return self; + }, + zadd(_key, _score, member) { + members.add(member); + return self; + }, + async exec() { + return []; + }, + }; + return self; + }, + async mget(...keys) { + return keys.map(() => null); + }, + async zrevrange() { + return []; + }, + }; + + const fixedTime = 1700000000000; + const origDateNow = Date.now; + Date.now = () => fixedTime; + try { + const svc = new GrowthService(mockRedis); + for (let i = 0; i < 10; i++) { + svc.awardFootfall('cat1', 'tool_use'); + } + assert.equal(members.size, 10, `Expected 10 unique members, got ${members.size}`); + await new Promise((r) => setTimeout(r, 50)); + } finally { + Date.now = origDateNow; + } + }); +}); diff --git a/packages/api/test/journey-projector.test.js b/packages/api/test/journey-projector.test.js new file mode 100644 index 0000000000..0f10c0c8cf --- /dev/null +++ b/packages/api/test/journey-projector.test.js @@ -0,0 +1,280 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import { ActivityEventBus } from '../dist/domains/activity/ActivityEventBus.js'; +import { JourneyProjector } from '../dist/domains/activity/JourneyProjector.js'; + +describe('JourneyProjector', () => { + /** @type {ActivityEventBus} */ + let bus; + /** @type {{ calls: Array<{catId: string, source: string, multiplier?: number}>, bonds: Array<{catA: string, catB: string}>, awardFootfall: Function, recordBondEvent: Function }} */ + let mockService; + /** @type {JourneyProjector} */ + let projector; + + beforeEach(() => { + bus = new ActivityEventBus(); + mockService = { + calls: [], + bonds: [], + awardFootfall(catId, source, multiplier) { + mockService.calls.push({ catId, source, multiplier }); + }, + recordBondEvent(catA, catB) { + mockService.bonds.push({ catA, catB }); + }, + }; + projector = new JourneyProjector(bus, mockService); + }); + + function emit(type, actorId = 'ragdoll', metadata = {}) { + bus.record(type, actorId, metadata); + return mockService.calls; + } + + // ── Basic event→footfall mapping ───────────────────────────── + + describe('event→footfall mapping', () => { + it('awards tool_use on tool_used', () => { + const calls = emit('tool_used'); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'tool_use'); + assert.equal(calls[0].catId, 'ragdoll'); + }); + + it('awards task_complete on task_completed', () => { + const calls = emit('task_completed'); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'task_complete'); + }); + + it('awards discussion on message_sent', () => { + const calls = emit('message_sent'); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'discussion'); + }); + + it('awards review_given on review_submitted', () => { + const calls = emit('review_submitted'); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'review_given'); + }); + + it('awards bug_caught on bug_caught', () => { + const calls = emit('bug_caught'); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'bug_caught'); + }); + + it('awards mention_collab on multi_mention_completed', () => { + const calls = emit('multi_mention_completed', 'ragdoll', { participants: ['ragdoll'] }); + assert.ok(calls.some((c) => c.source === 'mention_collab')); + }); + + it('awards deep_collab on deep_collab_completed', () => { + const calls = emit('deep_collab_completed', 'ragdoll', { participants: ['ragdoll'] }); + assert.ok(calls.some((c) => c.source === 'deep_collab')); + }); + + it('awards evidence_cite on evidence_cited', () => { + const calls = emit('evidence_cited'); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'evidence_cite'); + }); + + it('awards session_seal on session_sealed', () => { + const calls = emit('session_sealed'); + assert.ok(calls.some((c) => c.catId === 'ragdoll' && c.source === 'session_seal')); + }); + + it('awards rich_block_create on rich_block_created', () => { + const calls = emit('rich_block_created'); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'rich_block_create'); + }); + + it('awards design_feedback on design_feedback_given', () => { + const calls = emit('design_feedback_given'); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'design_feedback'); + }); + }); + + // ── Ignored events ──────────────────────────────────────────── + + describe('ignored events', () => { + it('silently ignores unmapped event types', () => { + const calls = emit('clarification_requested'); + assert.equal(calls.length, 0); + }); + + it('silently ignores multi_mention_dispatched', () => { + const calls = emit('multi_mention_dispatched'); + assert.equal(calls.length, 0); + }); + }); + + // ── Tool category overrides ─────────────────────────────────── + + describe('tool category overrides', () => { + it('overrides source to tool_use_mcp for mcp category', () => { + const calls = emit('tool_used', 'ragdoll', { category: 'mcp' }); + assert.equal(calls[0].source, 'tool_use_mcp'); + }); + + it('overrides source to tool_use_skill for skill category', () => { + const calls = emit('tool_used', 'ragdoll', { category: 'skill' }); + assert.equal(calls[0].source, 'tool_use_skill'); + }); + + it('keeps default tool_use for builtin category', () => { + const calls = emit('tool_used', 'ragdoll', { category: 'builtin' }); + assert.equal(calls[0].source, 'tool_use'); + }); + }); + + // ── Ideate intent override ──────────────────────────────────── + + describe('ideate intent override (AC-E5)', () => { + it('overrides discussion → ideate_discussion when intent is ideate', () => { + const calls = emit('message_sent', 'ragdoll', { intent: 'ideate' }); + assert.equal(calls[0].source, 'ideate_discussion'); + }); + + it('keeps discussion for non-ideate message_sent', () => { + const calls = emit('message_sent', 'ragdoll', { intent: 'other' }); + assert.equal(calls[0].source, 'discussion'); + }); + }); + + // ── Error recovery bonus (AC-E6) ───────────────────────────── + + describe('error recovery bonus (AC-E6)', () => { + it('awards error_recovery when recoveredFromFailure is set', () => { + const calls = emit('task_completed', 'ragdoll', { recoveredFromFailure: true }); + const sources = calls.map((c) => c.source); + assert.ok(sources.includes('task_complete')); + assert.ok(sources.includes('error_recovery')); + }); + + it('does not award error_recovery without flag', () => { + const calls = emit('task_completed', 'ragdoll', {}); + const sources = calls.map((c) => c.source); + assert.ok(!sources.includes('error_recovery')); + }); + }); + + // ── Fast execution bonus (AC-E7) ───────────────────────────── + + describe('fast execution bonus (AC-E7)', () => { + it('awards fast_execution when fastExecution is set', () => { + const calls = emit('task_completed', 'ragdoll', { fastExecution: true }); + const sources = calls.map((c) => c.source); + assert.ok(sources.includes('fast_execution')); + }); + + it('does not award fast_execution without flag', () => { + const calls = emit('task_completed', 'ragdoll', {}); + const sources = calls.map((c) => c.source); + assert.ok(!sources.includes('fast_execution')); + }); + }); + + // ── Cache efficiency bonus (AC-E4) ──────────────────────────── + + describe('cache efficiency bonus (AC-E4)', () => { + it('awards cache_efficiency when cache hit ratio >= 30%', () => { + const calls = emit('session_sealed', 'ragdoll', { + lastUsage: { cacheReadTokens: 3000, inputTokens: 10000 }, + }); + const sources = calls.map((c) => c.source); + assert.ok(sources.includes('cache_efficiency')); + }); + + it('does not award cache_efficiency when ratio < 30%', () => { + const calls = emit('session_sealed', 'ragdoll', { + lastUsage: { cacheReadTokens: 100, inputTokens: 10000 }, + }); + const sources = calls.map((c) => c.source); + assert.ok(!sources.includes('cache_efficiency')); + }); + + it('does not award cache_efficiency without usage data', () => { + const calls = emit('session_sealed', 'ragdoll', {}); + const sources = calls.map((c) => c.source); + assert.ok(!sources.includes('cache_efficiency')); + }); + }); + + // ── Co-creator footfall for collab events ───────────────────── + + describe('co-creator footfall', () => { + it('awards co-creator footfall for multi_mention_completed from a cat', () => { + const calls = emit('multi_mention_completed', 'ragdoll', { participants: ['ragdoll'] }); + const coCreatorCalls = calls.filter((c) => c.catId === 'co-creator'); + assert.equal(coCreatorCalls.length, 1); + assert.equal(coCreatorCalls[0].source, 'mention_collab'); + }); + + it('awards co-creator footfall for session_sealed from a cat', () => { + const calls = emit('session_sealed', 'ragdoll', {}); + const coCreatorCalls = calls.filter((c) => c.catId === 'co-creator'); + assert.equal(coCreatorCalls.length, 1); + assert.equal(coCreatorCalls[0].source, 'session_seal'); + }); + + it('does NOT double-award co-creator when actor IS co-creator', () => { + const calls = emit('session_sealed', 'co-creator', {}); + const coCreatorCalls = calls.filter((c) => c.catId === 'co-creator'); + assert.equal(coCreatorCalls.length, 1); + }); + + it('does NOT award co-creator for non-collab events', () => { + const calls = emit('tool_used', 'ragdoll', {}); + const coCreatorCalls = calls.filter((c) => c.catId === 'co-creator'); + assert.equal(coCreatorCalls.length, 0); + }); + }); + + // ── Bond recording ──────────────────────────────────────────── + + describe('bond recording', () => { + it('records bond pairs for multi_mention_completed participants', () => { + emit('multi_mention_completed', 'ragdoll', { participants: ['a', 'b', 'c'] }); + assert.equal(mockService.bonds.length, 3); + assert.deepEqual(mockService.bonds[0], { catA: 'a', catB: 'b' }); + assert.deepEqual(mockService.bonds[1], { catA: 'a', catB: 'c' }); + assert.deepEqual(mockService.bonds[2], { catA: 'b', catB: 'c' }); + }); + + it('records bond pairs for deep_collab_completed participants', () => { + emit('deep_collab_completed', 'ragdoll', { participants: ['x', 'y'] }); + assert.equal(mockService.bonds.length, 1); + assert.deepEqual(mockService.bonds[0], { catA: 'x', catB: 'y' }); + }); + + it('records bond pairs for a2a_handoff_completed', () => { + emit('a2a_handoff_completed', 'ragdoll', { participants: ['p', 'q'] }); + assert.equal(mockService.bonds.length, 1); + }); + + it('does not record bonds when participants is missing', () => { + emit('multi_mention_completed', 'ragdoll', {}); + assert.equal(mockService.bonds.length, 0); + }); + + it('does not record bonds for non-bond events', () => { + emit('task_completed', 'ragdoll', { participants: ['a', 'b'] }); + assert.equal(mockService.bonds.length, 0); + }); + }); + + // ── dispose ─────────────────────────────────────────────────── + + describe('dispose', () => { + it('unsubscribes from bus', () => { + projector.dispose(); + const calls = emit('task_completed'); + assert.equal(calls.length, 0); + }); + }); +}); diff --git a/packages/api/test/leadership-projector.test.js b/packages/api/test/leadership-projector.test.js new file mode 100644 index 0000000000..6cd6457018 --- /dev/null +++ b/packages/api/test/leadership-projector.test.js @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import { ActivityEventBus } from '../dist/domains/activity/ActivityEventBus.js'; +import { LeadershipProjector } from '../dist/domains/activity/LeadershipProjector.js'; + +describe('LeadershipProjector', () => { + /** @type {ActivityEventBus} */ + let bus; + /** @type {{ calls: Array<{source: string, multiplier?: number}>, awardFootfall: Function }} */ + let mockService; + /** @type {LeadershipProjector} */ + let projector; + + beforeEach(() => { + bus = new ActivityEventBus(); + mockService = { + calls: [], + awardFootfall(source, multiplier) { + mockService.calls.push({ source, multiplier }); + }, + }; + projector = new LeadershipProjector(bus, mockService); + }); + + /** Helper: emit an event and return captured service calls. */ + function emit(type, metadata = {}) { + bus.record(type, 'co-creator', metadata); + return mockService.calls; + } + + describe('coordination (协调力)', () => { + it('awards multi_mention_dispatch on dispatched event', () => { + const calls = emit('multi_mention_dispatched', { targets: ['a', 'b'] }); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'multi_mention_dispatch'); + }); + + it('awards multi_mention_success on request-level completion', () => { + const calls = emit('multi_mention_request_completed', { + targetCount: 2, + successCount: 2, + isDeepCollab: false, + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'multi_mention_success'); + }); + + it('skips when successCount is 0', () => { + const calls = emit('multi_mention_request_completed', { + targetCount: 2, + successCount: 0, + isDeepCollab: false, + }); + assert.equal(calls.length, 0); + }); + + it('awards target_diversity when targetCount >= 3', () => { + const calls = emit('multi_mention_request_completed', { + targetCount: 3, + successCount: 3, + isDeepCollab: false, + }); + const sources = calls.map((c) => c.source); + assert.ok(sources.includes('multi_mention_success')); + assert.ok(sources.includes('target_diversity')); + }); + + it('does NOT award target_diversity when targetCount < 3', () => { + const calls = emit('multi_mention_request_completed', { + targetCount: 2, + successCount: 2, + isDeepCollab: false, + }); + const sources = calls.map((c) => c.source); + assert.ok(!sources.includes('target_diversity')); + }); + }); + + describe('delegation (授权力)', () => { + it('awards deep_collab_initiated for deep collab requests', () => { + const calls = emit('multi_mention_request_completed', { + targetCount: 3, + successCount: 3, + isDeepCollab: true, + }); + const sources = calls.map((c) => c.source); + assert.ok(sources.includes('deep_collab_initiated')); + }); + + it('awards task_no_intervention when interventionCount is 0', () => { + const calls = emit('task_completed', { interventionCount: 0 }); + const sources = calls.map((c) => c.source); + assert.ok(sources.includes('task_no_intervention')); + }); + + it('does NOT award task_no_intervention without metadata', () => { + const calls = emit('task_completed', {}); + const sources = calls.map((c) => c.source); + assert.ok(!sources.includes('task_no_intervention')); + }); + }); + + describe('guidance (引导力)', () => { + it('always awards baseline guidance on task_completed', () => { + const calls = emit('task_completed', {}); + assert.ok(calls.some((c) => c.source === 'one_shot_completion' && c.multiplier === 0.2)); + }); + + it('awards bonus one_shot_completion when clarificationCount is 0', () => { + const calls = emit('task_completed', { clarificationCount: 0 }); + const oneShots = calls.filter((c) => c.source === 'one_shot_completion'); + assert.equal(oneShots.length, 2); // baseline 0.2 + bonus 0.8 + assert.ok(oneShots.some((c) => c.multiplier === 0.8)); + }); + + it('awards low_clarification on session_sealed with low count', () => { + const calls = emit('session_sealed', { clarificationCount: 1 }); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'low_clarification'); + }); + + it('skips low_clarification when count is too high', () => { + const calls = emit('session_sealed', { clarificationCount: 5 }); + assert.equal(calls.length, 0); + }); + }); + + describe('exploration (开拓力)', () => { + it('awards tool_category_breadth for mcp tool use', () => { + const calls = emit('tool_used', { category: 'mcp' }); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'tool_category_breadth'); + }); + + it('awards tool_category_breadth for skill tool use', () => { + const calls = emit('tool_used', { category: 'skill' }); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'tool_category_breadth'); + }); + + it('ignores regular tool use', () => { + const calls = emit('tool_used', { category: 'builtin' }); + assert.equal(calls.length, 0); + }); + }); + + describe('shadow dimensions (AC-D3)', () => { + it('awards feedback_applied on review_submitted', () => { + const calls = emit('review_submitted'); + assert.equal(calls.length, 1); + assert.equal(calls[0].source, 'feedback_applied'); + }); + + it('awards direction_confirmed on task with low clarifications', () => { + const calls = emit('task_completed', { clarificationCount: 0 }); + const sources = calls.map((c) => c.source); + assert.ok(sources.includes('direction_confirmed')); + }); + }); + + describe('ignored events', () => { + it('silently ignores unknown event types', () => { + const calls = emit('message_sent', {}); + assert.equal(calls.length, 0); + }); + + it('does NOT react to per-responder multi_mention_completed', () => { + const calls = emit('multi_mention_completed', { participants: ['a', 'b'] }); + assert.equal(calls.length, 0); + }); + + it('does NOT react to per-responder deep_collab_completed', () => { + const calls = emit('deep_collab_completed', { participants: ['a', 'b', 'c'] }); + assert.equal(calls.length, 0); + }); + }); + + describe('dispose', () => { + it('unsubscribes from bus', () => { + projector.dispose(); + const calls = emit('multi_mention_dispatched'); + assert.equal(calls.length, 0); + }); + }); +}); diff --git a/packages/api/test/memory-projector.test.js b/packages/api/test/memory-projector.test.js new file mode 100644 index 0000000000..c8fe08f609 --- /dev/null +++ b/packages/api/test/memory-projector.test.js @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import { ActivityEventBus } from '../dist/domains/activity/ActivityEventBus.js'; +import { MemoryProjector } from '../dist/domains/activity/MemoryProjector.js'; + +describe('MemoryProjector', () => { + /** @type {ActivityEventBus} */ + let bus; + /** @type {{ items: Array, upsert: Function }} */ + let mockStore; + /** @type {MemoryProjector} */ + let projector; + + beforeEach(() => { + bus = new ActivityEventBus(); + mockStore = { + items: [], + async upsert(batch) { + mockStore.items.push(...batch); + }, + }; + projector = new MemoryProjector(bus, mockStore); + }); + + function emit(type, actorId = 'ragdoll', metadata = {}, threadId = undefined) { + bus.record(type, actorId, metadata, threadId); + return mockStore.items; + } + + async function emitAndWait(type, actorId = 'ragdoll', metadata = {}, threadId = undefined) { + bus.record(type, actorId, metadata, threadId); + await new Promise((r) => setTimeout(r, 20)); + return mockStore.items; + } + + // ── Always promote ──────────────────────────────────────────── + + describe('always promote', () => { + it('promotes deep_collab_completed', async () => { + const items = await emitAndWait('deep_collab_completed', 'ragdoll', { + participants: ['ragdoll', 'persian'], + }); + assert.equal(items.length, 1); + assert.equal(items[0].kind, 'discussion'); + assert.ok(items[0].anchor.startsWith('activity-collab-')); + }); + + it('promotes bug_caught', async () => { + const items = await emitAndWait('bug_caught', 'ragdoll', {}, 'thread-1'); + assert.equal(items.length, 1); + assert.equal(items[0].kind, 'lesson'); + assert.ok(items[0].anchor.startsWith('activity-bug-')); + }); + + it('promotes evidence_cited', async () => { + const items = await emitAndWait('evidence_cited', 'ragdoll', { citedAnchor: 'doc-123' }); + assert.equal(items.length, 1); + assert.equal(items[0].kind, 'research'); + assert.ok(items[0].anchor.includes('doc-123')); + }); + }); + + // ── Conditional promote ─────────────────────────────────────── + + describe('conditional promote', () => { + it('promotes review_submitted when hasFindings is true', async () => { + const items = await emitAndWait('review_submitted', 'ragdoll', { + hasFindings: true, + findingCount: 3, + }); + assert.equal(items.length, 1); + assert.equal(items[0].kind, 'discussion'); + }); + + it('does NOT promote review_submitted without hasFindings', async () => { + const items = await emitAndWait('review_submitted', 'ragdoll', { hasFindings: false }); + assert.equal(items.length, 0); + }); + + it('promotes decision_confirmed when threadId is present', async () => { + const items = await emitAndWait('decision_confirmed', 'co-creator', {}, 'thread-42'); + assert.equal(items.length, 1); + assert.equal(items[0].kind, 'decision'); + }); + + it('does NOT promote decision_confirmed without threadId', async () => { + const items = await emitAndWait('decision_confirmed', 'co-creator', {}); + assert.equal(items.length, 0); + }); + + it('promotes feedback_applied unconditionally', async () => { + const items = await emitAndWait('feedback_applied', 'ragdoll', { + sourceMessageId: 'msg-1', + }); + assert.equal(items.length, 1); + assert.equal(items[0].kind, 'lesson'); + }); + }); + + // ── Never promote ───────────────────────────────────────────── + + describe('never promote', () => { + it('does NOT promote tool_used', async () => { + const items = await emitAndWait('tool_used', 'ragdoll', {}); + assert.equal(items.length, 0); + }); + + it('does NOT promote message_sent', async () => { + const items = await emitAndWait('message_sent', 'ragdoll', {}); + assert.equal(items.length, 0); + }); + + it('does NOT promote session_sealed', async () => { + const items = await emitAndWait('session_sealed', 'ragdoll', {}); + assert.equal(items.length, 0); + }); + + it('does NOT promote multi_mention_completed', async () => { + const items = await emitAndWait('multi_mention_completed', 'ragdoll', {}); + assert.equal(items.length, 0); + }); + + it('does NOT promote clarification_requested', async () => { + const items = await emitAndWait('clarification_requested', 'ragdoll', {}); + assert.equal(items.length, 0); + }); + + it('does NOT promote task_completed', async () => { + const items = await emitAndWait('task_completed', 'ragdoll', {}); + assert.equal(items.length, 0); + }); + }); + + // ── Evidence item structure ─────────────────────────────────── + + describe('evidence item structure', () => { + it('includes title, summary, keywords, and status', async () => { + const items = await emitAndWait('bug_caught', 'ragdoll', {}, 'thread-5'); + assert.equal(items.length, 1); + const item = items[0]; + assert.equal(item.status, 'active'); + assert.ok(item.title.includes('ragdoll')); + assert.ok(item.summary.includes('ragdoll')); + assert.ok(item.keywords.includes('bug_caught')); + assert.ok(item.keywords.includes('ragdoll')); + assert.ok(item.keywords.includes('thread:thread-5')); + assert.ok(item.updatedAt); + }); + + it('uses metadata.summary when provided', async () => { + const items = await emitAndWait('bug_caught', 'ragdoll', { + summary: 'Custom summary text', + }); + assert.equal(items[0].summary, 'Custom summary text'); + }); + + it('excludes co-creator actorId from keywords', async () => { + const items = await emitAndWait('deep_collab_completed', 'co-creator', { + participants: ['ragdoll'], + }); + assert.equal(items.length, 1); + assert.ok(!items[0].keywords.includes('co-creator')); + }); + }); + + // ── Anchor uniqueness ───────────────────────────────────────── + + describe('anchor uniqueness', () => { + it('deep_collab anchor is stable for same participants', async () => { + await emitAndWait( + 'deep_collab_completed', + 'ragdoll', + { + participants: ['b', 'a'], + }, + 'thread-1', + ); + const anchor1 = mockStore.items[0].anchor; + + mockStore.items = []; + await emitAndWait( + 'deep_collab_completed', + 'persian', + { + participants: ['a', 'b'], + }, + 'thread-1', + ); + const anchor2 = mockStore.items[0].anchor; + + assert.equal(anchor1, anchor2); + }); + }); + + // ── dispose ─────────────────────────────────────────────────── + + describe('dispose', () => { + it('unsubscribes from bus', async () => { + projector.dispose(); + const items = await emitAndWait('bug_caught'); + assert.equal(items.length, 0); + }); + }); +}); diff --git a/packages/api/test/otel-bridge-projector.test.js b/packages/api/test/otel-bridge-projector.test.js new file mode 100644 index 0000000000..07094c6774 --- /dev/null +++ b/packages/api/test/otel-bridge-projector.test.js @@ -0,0 +1,177 @@ +/** + * OtelBridgeProjector — unit tests + * + * Because OtelBridgeProjector imports real OTel instruments (which use + * global meter), we mock the module before import using node:module register. + * Simpler approach: we test the behavior through the bus and verify + * the projector doesn't throw on any valid event type. + */ + +import assert from 'node:assert/strict'; +import { beforeEach, describe, it, mock } from 'node:test'; +import { ActivityEventBus } from '../dist/domains/activity/ActivityEventBus.js'; + +describe('OtelBridgeProjector', () => { + /** @type {ActivityEventBus} */ + let bus; + + beforeEach(() => { + bus = new ActivityEventBus(); + }); + + it('does not throw when processing task_completed', () => { + // OtelBridgeProjector subscribes in constructor; if OTel instruments + // are unavailable it falls back to noop meter. We just verify no throw. + assert.doesNotThrow(() => { + bus.record('task_completed', 'ragdoll', { status: 'ok', durationMs: 5000 }); + }); + }); + + it('does not throw when processing session_sealed', () => { + assert.doesNotThrow(() => { + bus.record('session_sealed', 'ragdoll', { rounds: 5 }); + }); + }); + + it('does not throw when processing cat activity events', () => { + const catEvents = [ + 'tool_used', + 'message_sent', + 'review_submitted', + 'bug_caught', + 'evidence_cited', + 'rich_block_created', + 'design_feedback_given', + ]; + for (const type of catEvents) { + assert.doesNotThrow(() => { + bus.record(type, 'ragdoll', { trigger: 'test' }); + }); + } + }); + + it('does not throw for co-creator events (should be filtered)', () => { + assert.doesNotThrow(() => { + bus.record('tool_used', 'co-creator', { category: 'mcp' }); + }); + }); +}); + +describe('OtelBridgeProjector with mock instruments', () => { + /** @type {ActivityEventBus} */ + let bus; + let counters; + let histograms; + + beforeEach(async () => { + bus = new ActivityEventBus(); + counters = { taskCompleted: [], catInvocation: [] }; + histograms = { taskDuration: [], sessionRounds: [] }; + + // We test the event handling logic by re-implementing the handler + // with mock instruments, matching OtelBridgeProjector's behavior exactly. + const CAT_ACTIVITY_EVENTS = new Set([ + 'tool_used', + 'task_completed', + 'message_sent', + 'review_submitted', + 'bug_caught', + 'evidence_cited', + 'rich_block_created', + 'design_feedback_given', + ]); + + bus.on((event) => { + const agentId = event.actorId ?? 'unknown'; + + if (event.type === 'task_completed') { + const status = event.metadata?.status ?? 'ok'; + counters.taskCompleted.push({ value: 1, attrs: { 'agent.id': agentId, status } }); + + const durationS = event.metadata?.durationMs ? Number(event.metadata.durationMs) / 1000 : undefined; + if (durationS !== undefined && durationS > 0) { + histograms.taskDuration.push({ value: durationS, attrs: { 'agent.id': agentId } }); + } + } + + if (event.type === 'session_sealed') { + const rounds = event.metadata?.rounds; + if (typeof rounds === 'number' && rounds > 0) { + histograms.sessionRounds.push({ value: rounds, attrs: { 'agent.id': agentId } }); + } + } + + if (CAT_ACTIVITY_EVENTS.has(event.type) && agentId !== 'co-creator') { + const trigger = event.metadata?.trigger ?? 'default'; + counters.catInvocation.push({ value: 1, attrs: { 'agent.id': agentId, trigger } }); + } + }); + }); + + describe('task_completed handling', () => { + it('increments taskCompleted counter with agent and status', () => { + bus.record('task_completed', 'ragdoll', { status: 'error', durationMs: 3000 }); + assert.equal(counters.taskCompleted.length, 1); + assert.equal(counters.taskCompleted[0].attrs['agent.id'], 'ragdoll'); + assert.equal(counters.taskCompleted[0].attrs.status, 'error'); + }); + + it('records taskDuration histogram when durationMs is present', () => { + bus.record('task_completed', 'ragdoll', { durationMs: 5000 }); + assert.equal(histograms.taskDuration.length, 1); + assert.equal(histograms.taskDuration[0].value, 5); + }); + + it('skips taskDuration when durationMs is missing', () => { + bus.record('task_completed', 'ragdoll', {}); + assert.equal(histograms.taskDuration.length, 0); + }); + + it('defaults status to ok when not provided', () => { + bus.record('task_completed', 'ragdoll', {}); + assert.equal(counters.taskCompleted[0].attrs.status, 'ok'); + }); + }); + + describe('session_sealed handling', () => { + it('records sessionRounds histogram', () => { + bus.record('session_sealed', 'ragdoll', { rounds: 7 }); + assert.equal(histograms.sessionRounds.length, 1); + assert.equal(histograms.sessionRounds[0].value, 7); + }); + + it('skips sessionRounds when rounds is 0', () => { + bus.record('session_sealed', 'ragdoll', { rounds: 0 }); + assert.equal(histograms.sessionRounds.length, 0); + }); + + it('skips sessionRounds when rounds is missing', () => { + bus.record('session_sealed', 'ragdoll', {}); + assert.equal(histograms.sessionRounds.length, 0); + }); + }); + + describe('cat invocation counting', () => { + it('counts cat activity events', () => { + bus.record('tool_used', 'ragdoll', { trigger: 'manual' }); + assert.equal(counters.catInvocation.length, 1); + assert.equal(counters.catInvocation[0].attrs['agent.id'], 'ragdoll'); + assert.equal(counters.catInvocation[0].attrs.trigger, 'manual'); + }); + + it('filters co-creator from cat invocation count', () => { + bus.record('tool_used', 'co-creator', {}); + assert.equal(counters.catInvocation.length, 0); + }); + + it('defaults trigger to default when not provided', () => { + bus.record('message_sent', 'persian', {}); + assert.equal(counters.catInvocation[0].attrs.trigger, 'default'); + }); + + it('does not count events outside CAT_ACTIVITY_EVENTS', () => { + bus.record('clarification_requested', 'ragdoll', {}); + assert.equal(counters.catInvocation.length, 0); + }); + }); +}); diff --git a/packages/shared/src/types/achievement-definitions.ts b/packages/shared/src/types/achievement-definitions.ts new file mode 100644 index 0000000000..d6922951a6 --- /dev/null +++ b/packages/shared/src/types/achievement-definitions.ts @@ -0,0 +1,225 @@ +/** + * F160 Phase C — Achievement definitions registry + * + * 4 categories: + * individual — bound to a single cat or co-creator + * team — bound to cat combinations / collaboration + * milestone — bound to the Cat Cafe instance + * hidden — surprise achievements, not shown until unlocked + * + * Conditions use AND logic. Empty conditions = manual/event-driven trigger. + */ + +import type { AchievementDefinition } from './journey.js'; + +export const ACHIEVEMENT_DEFINITIONS: readonly AchievementDefinition[] = [ + // ── Individual: Common ───────────────────────────────────────────── + { + id: 'first-task', + label: { zh: '初啼', en: 'First Cry' }, + description: { zh: '首次完成任务', en: 'Complete your first task' }, + category: 'individual', + rarity: 'common', + conditions: [{ type: 'task_count', minCount: 1 }], + icon: 'baby', + }, + { + id: 'centurion', + label: { zh: '百炼', en: 'Centurion' }, + description: { zh: '完成 100 个任务', en: 'Complete 100 tasks' }, + category: 'individual', + rarity: 'common', + conditions: [{ type: 'task_count', minCount: 100 }], + icon: 'flame', + }, + { + id: 'first-review', + label: { zh: '初审', en: 'First Review' }, + description: { zh: '首次完成 code review', en: 'Complete your first code review' }, + category: 'individual', + rarity: 'common', + conditions: [{ type: 'review_count', minCount: 1 }], + icon: 'search', + }, + { + id: 'xp-collector', + label: { zh: '足迹收集者', en: 'Footfall Collector' }, + description: { zh: '累积 1,000 足迹点', en: 'Accumulate 1,000 footfall' }, + category: 'individual', + rarity: 'common', + conditions: [{ type: 'total_footfall', minFootfall: 1000 }], + icon: 'gem', + }, + { + id: 'level-up', + label: { zh: '初级冒险者', en: 'Novice Adventurer' }, + description: { zh: '总等级达到 Lv.2', en: 'Reach overall Lv.2' }, + category: 'individual', + rarity: 'common', + conditions: [{ type: 'overall_level', minLevel: 2 }], + icon: 'arrow-up', + }, + + // ── Individual: Rare ─────────────────────────────────────────────── + { + id: 'gatekeeper', + label: { zh: '守门员', en: 'Gatekeeper' }, + description: { zh: '审查力达到 Lv.5', en: 'Reach Review Lv.5' }, + category: 'individual', + rarity: 'rare', + conditions: [{ type: 'dimension_level', dimension: 'review', minLevel: 5 }], + icon: 'shield', + }, + { + id: 'veteran', + label: { zh: '老兵', en: 'Veteran' }, + description: { zh: '总等级达到 Lv.5', en: 'Reach overall Lv.5' }, + category: 'individual', + rarity: 'rare', + conditions: [{ type: 'overall_level', minLevel: 5 }], + icon: 'medal', + }, + { + id: 'xp-hoarder', + label: { zh: '足迹富翁', en: 'Footfall Hoarder' }, + description: { zh: '累积 10,000 足迹点', en: 'Accumulate 10,000 footfall' }, + category: 'individual', + rarity: 'rare', + conditions: [{ type: 'total_footfall', minFootfall: 10000 }], + icon: 'coins', + }, + { + id: 'title-hunter', + label: { zh: '称号猎人', en: 'Title Hunter' }, + description: { zh: '解锁 5 个称号', en: 'Unlock 5 titles' }, + category: 'individual', + rarity: 'rare', + conditions: [{ type: 'title_count', minCount: 5 }], + icon: 'trophy', + }, + { + id: 'review-master', + label: { zh: '审查大师', en: 'Review Master' }, + description: { zh: '完成 50 次 review', en: 'Complete 50 reviews' }, + category: 'individual', + rarity: 'rare', + conditions: [{ type: 'review_count', minCount: 50 }], + icon: 'eye', + }, + + // ── Individual: Epic ─────────────────────────────────────────────── + { + id: 'polyglot', + label: { zh: '全能战士', en: 'Polyglot' }, + description: { zh: '所有维度达到 Lv.3+', en: 'Reach Lv.3+ in all dimensions' }, + category: 'individual', + rarity: 'epic', + conditions: [ + { type: 'dimension_level', dimension: 'architecture', minLevel: 3 }, + { type: 'dimension_level', dimension: 'review', minLevel: 3 }, + { type: 'dimension_level', dimension: 'aesthetics', minLevel: 3 }, + { type: 'dimension_level', dimension: 'execution', minLevel: 3 }, + { type: 'dimension_level', dimension: 'collaboration', minLevel: 3 }, + { type: 'dimension_level', dimension: 'insight', minLevel: 3 }, + ], + icon: 'hexagon', + }, + { + id: 'tireless', + label: { zh: '日不落', en: 'Tireless' }, + description: { zh: '封存 50 个 session', en: 'Seal 50 sessions' }, + category: 'individual', + rarity: 'epic', + conditions: [{ type: 'session_count', minCount: 50 }], + icon: 'sun', + }, + + // ── Individual: Legendary ────────────────────────────────────────── + { + id: 'transcendent', + label: { zh: '超越者', en: 'Transcendent' }, + description: { zh: '总等级达到 Lv.10', en: 'Reach overall Lv.10' }, + category: 'individual', + rarity: 'legendary', + conditions: [{ type: 'overall_level', minLevel: 10 }], + icon: 'crown', + }, + + // ── Team: Common ─────────────────────────────────────────────────── + { + id: 'first-handshake', + label: { zh: '初次握手', en: 'First Handshake' }, + description: { zh: '建立首个羁绊', en: 'Form your first bond' }, + category: 'team', + rarity: 'common', + conditions: [{ type: 'bond_count', minCount: 1 }], + icon: 'handshake', + }, + { + id: 'social-butterfly', + label: { zh: '社交达人', en: 'Social Butterfly' }, + description: { zh: '与 5 只猫建立羁绊', en: 'Form bonds with 5 cats' }, + category: 'team', + rarity: 'rare', + conditions: [{ type: 'bond_count', minCount: 5 }], + icon: 'users', + }, + { + id: 'soulbound', + label: { zh: '灵魂绑定', en: 'Soulbound' }, + description: { zh: '与一只猫达到灵魂伙伴', en: 'Reach soulmate bond level' }, + category: 'team', + rarity: 'epic', + conditions: [{ type: 'bond_level', minLevel: 'soulmate' }], + icon: 'heart', + }, + + // ── Milestone: Common ────────────────────────────────────────────── + { + id: 'grand-opening', + label: { zh: '开业大吉', en: 'Grand Opening' }, + description: { zh: '团队总足迹点达到 1,000', en: 'Team reaches 1,000 total footfall' }, + category: 'milestone', + rarity: 'common', + conditions: [{ type: 'total_footfall', minFootfall: 1000 }], + icon: 'party', + }, + { + id: 'thousand-reviews', + label: { zh: '千锤百炼', en: 'Battle-Hardened' }, + description: { zh: '团队完成 1,000 次 review', en: 'Team completes 1,000 reviews' }, + category: 'milestone', + rarity: 'epic', + conditions: [{ type: 'review_count', minCount: 1000 }], + icon: 'hammer', + }, + + // ── Hidden ───────────────────────────────────────────────────────── + { + id: 'night-owl', + label: { zh: '夜猫子', en: 'Night Owl' }, + description: { zh: '凌晨 2-5 点完成任务', en: 'Complete a task between 2-5 AM' }, + category: 'hidden', + rarity: 'rare', + conditions: [], // event-driven, checked at task_complete time + icon: 'moon', + }, + { + id: 'time-traveler', + label: { zh: '时间旅行者', en: 'Time Traveler' }, + description: { zh: '引用 3 个月前的讨论佐证决策', en: 'Cite a discussion from 3+ months ago' }, + category: 'hidden', + rarity: 'epic', + conditions: [], // event-driven, checked at evidence_cite time + icon: 'clock', + }, + { + id: 'three-thirty-am', + label: { zh: '凌晨三点半', en: '3:30 AM' }, + description: { zh: '猫猫在铲屎官离线时自主完成协作', en: 'Cats collaborate autonomously while owner is offline' }, + category: 'hidden', + rarity: 'legendary', + conditions: [], // event-driven + icon: 'stars', + }, +] as const; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 54dbc095ff..c7b84a7ebf 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -273,6 +273,64 @@ export type { TriageIntentCardInput, TriageResult, } from './intent-card.js'; +// Journey types (F160 Cat Journey 猫猫足迹) +export { ACHIEVEMENT_DEFINITIONS } from './achievement-definitions.js'; +export type { + AchievementCategory, + AchievementCondition, + AchievementDefinition, + AchievementRarity, + ActivityEvent, + ActivityEventType, + BondLevel, + CatAttributes, + CatBond, + CatGrowthProfile, + CatJourneyProfile, + CatTitle, + DimensionStat, + EvolutionEvent, + EvolutionEventType, + FootfallEvent, + FootfallSource, + GrowthDimension, + GrowthOverview, + HighlightMoment, + InvocationPurpose, + JourneyOverview, + LeadershipDimension, + LeadershipFootfallSource, + LeadershipProfile, + LeadershipStat, + LeadershipTitleCondition, + LeadershipTitleDefinition, + MomentCategory, + MomentCondition, + MomentDefinition, + MomentRarity, + TitleCondition, + TitleDefinition, + TitleRarity, + TraitDimension, + UnlockedAchievement, + UnlockedMoment, + UnlockedTitle, + XpEvent, + XpSource, +} from './journey.js'; +export { + CO_CREATOR_ACTOR_ID, + DIMENSION_LABELS, + GROWTH_DIMENSIONS, + isCoCreatorActor, + LEADERSHIP_DIMENSIONS, + LEADERSHIP_LABELS, + LEADERSHIP_LIVE_DIMS, + LEADERSHIP_SHADOW_DIMS, + TRAIT_DIMENSIONS, +} from './journey.js'; +export { LEADERSHIP_TITLE_DEFINITIONS } from './leadership-title-definitions.js'; +export { TITLE_DEFINITIONS } from './title-definitions.js'; // Leaderboard types (F075 排行榜) export type { Achievement, diff --git a/packages/shared/src/types/journey.ts b/packages/shared/src/types/journey.ts new file mode 100644 index 0000000000..1c140b970f --- /dev/null +++ b/packages/shared/src/types/journey.ts @@ -0,0 +1,378 @@ +/** + * F160 Cat Journey (猫猫足迹) — shared types + * Activity footprint visualization: trait portrait + footfall + moments + * + * Naming pivot (ADR-023): "Growth" → "Journey" + * - XP → 足迹点 / Footfall + * - Level → 历练 / Seasoning + * - Achievement → 珍贵瞬间 / Moments + * - Radar chart → 特质画像 / Traits Portrait + */ + +import type { CatId } from './ids.js'; + +// ── Trait Dimensions (六维特质) ───────────────────────────────────── + +/** The six trait dimensions (formerly GrowthDimension) */ +export type TraitDimension = 'architecture' | 'review' | 'aesthetics' | 'execution' | 'collaboration' | 'insight'; + +export const TRAIT_DIMENSIONS: readonly TraitDimension[] = [ + 'architecture', + 'review', + 'aesthetics', + 'execution', + 'collaboration', + 'insight', +] as const; + +/** Human-readable labels for each trait dimension */ +export const DIMENSION_LABELS: Record = { + architecture: { zh: '架构力', en: 'Architecture' }, + review: { zh: '审查力', en: 'Review' }, + aesthetics: { zh: '审美力', en: 'Aesthetics' }, + execution: { zh: '执行力', en: 'Execution' }, + collaboration: { zh: '协作力', en: 'Collaboration' }, + insight: { zh: '洞察力', en: 'Insight' }, +}; + +/** Footfall and seasoning for a single trait dimension */ +export interface DimensionStat { + readonly dimension: TraitDimension; + /** Accumulated footfall (足迹点) */ + readonly footfall: number; + /** Seasoning tier (历练) */ + readonly level: number; + /** Footfall needed to reach next seasoning tier */ + readonly footfallToNext: number; +} + +/** Six-dimensional trait snapshot for one cat */ +export interface CatAttributes { + readonly catId: string; + readonly stats: Record; + /** Overall seasoning tier (avg of dimension tiers, floored) */ + readonly overallLevel: number; + /** Total footfall across all dimensions */ + readonly totalFootfall: number; + readonly updatedAt: number; +} + +/** Currently active title */ +export interface CatTitle { + readonly id: string; + readonly label: { zh: string; en: string }; + readonly unlockedAt: number; +} + +/** Highlight moment linked to a real session */ +export interface HighlightMoment { + readonly label: string; + readonly sessionId?: string; + readonly threadId?: string; + readonly timestamp: number; +} + +/** Full journey profile for the cat profile card */ +export interface CatJourneyProfile { + readonly catId: string; + readonly displayName: string; + readonly nickname?: string; + readonly attributes: CatAttributes; + readonly currentTitle?: CatTitle; + readonly highlights: readonly HighlightMoment[]; +} + +/** Team overview for the journey hub page */ +export interface JourneyOverview { + readonly profiles: readonly CatJourneyProfile[]; + readonly teamLevel: number; + readonly teamTotalFootfall: number; + readonly fetchedAt: string; +} + +// ── Phase E: Evolution Events (AC-E1) ───────────────────────────── + +/** Types of evolution milestones that generate narrative events */ +export type EvolutionEventType = + | 'level_up' // Dimension or overall level increased + | 'first_dim' // First XP earned in a dimension + | 'achievement_unlocked' // Achievement/moment unlocked + | 'title_unlocked' // Title unlocked + | 'bond_milestone'; // Bond level increased + +/** Recorded narrative event for the growth timeline */ +export interface EvolutionEvent { + readonly type: EvolutionEventType; + readonly catId: string; + readonly narrative: { readonly zh: string; readonly en: string }; + readonly details: Record; + readonly timestamp: number; +} + +/** Granular footfall event for audit trail */ +export interface FootfallEvent { + readonly catId: string; + readonly dimension: TraitDimension; + readonly footfall: number; + readonly source: FootfallSource; + readonly detail?: string; + readonly timestamp: number; +} + +export type FootfallSource = + | 'task_complete' + | 'session_seal' + | 'review_given' + | 'review_received' + | 'tool_use' + | 'tool_use_mcp' + | 'tool_use_skill' + | 'mention_collab' + | 'deep_collab' + | 'discussion' + | 'pr_merged' + | 'bug_caught' + | 'design_feedback' + | 'rich_block_create' + | 'evidence_cite' + | 'cache_efficiency' // Phase E: bonus for high cache hit ratio + | 'ideate_discussion' // Phase E: ideate intent → architecture bonus + | 'error_recovery' // Phase E: retry succeeded after failure + | 'fast_execution'; // Phase E: invocation completed quickly + +// ── Titles ──────────────────────────────────────────────────────── + +/** Condition for unlocking a title */ +export type TitleCondition = + | { readonly type: 'dimension_level'; readonly dimension: TraitDimension; readonly minLevel: number } + | { readonly type: 'overall_level'; readonly minLevel: number } + | { readonly type: 'total_footfall'; readonly minFootfall: number }; + +export type TitleRarity = 'common' | 'rare' | 'epic' | 'legendary'; + +export interface TitleDefinition { + readonly id: string; + readonly label: { readonly zh: string; readonly en: string }; + readonly description: { readonly zh: string; readonly en: string }; + readonly rarity: TitleRarity; + readonly conditions: readonly TitleCondition[]; +} + +export interface UnlockedTitle { + readonly titleId: string; + readonly catId: string; + readonly unlockedAt: number; +} + +// ── Bonds ───────────────────────────────────────────────────────── + +export interface CatBond { + readonly catA: string; + readonly catB: string; + readonly score: number; + readonly interactions: number; + readonly lastInteractionAt: number; +} + +export type BondLevel = 'acquaintance' | 'partner' | 'soulmate'; + +// ── Invocation Purpose ──────────────────────────────────────────── + +export type InvocationPurpose = 'discussion' | 'review'; + +// ── Moments (珍贵瞬间, formerly Achievements) ───────────────────── + +export type MomentCategory = 'individual' | 'team' | 'milestone' | 'hidden'; + +export type MomentRarity = 'common' | 'rare' | 'epic' | 'legendary'; + +export type MomentCondition = + | { readonly type: 'total_footfall'; readonly minFootfall: number } + | { readonly type: 'overall_level'; readonly minLevel: number } + | { readonly type: 'dimension_level'; readonly dimension: TraitDimension; readonly minLevel: number } + | { readonly type: 'title_count'; readonly minCount: number } + | { readonly type: 'bond_count'; readonly minCount: number } + | { readonly type: 'bond_level'; readonly minLevel: BondLevel } + | { readonly type: 'task_count'; readonly minCount: number } + | { readonly type: 'review_count'; readonly minCount: number } + | { readonly type: 'session_count'; readonly minCount: number }; + +export interface MomentDefinition { + readonly id: string; + readonly label: { readonly zh: string; readonly en: string }; + readonly description: { readonly zh: string; readonly en: string }; + readonly category: MomentCategory; + readonly rarity: MomentRarity; + readonly conditions: readonly MomentCondition[]; + readonly icon?: string; +} + +export interface UnlockedMoment { + /** Kept as achievementId for Redis data compat (stored field name) */ + readonly achievementId: string; + readonly memberId: string; + readonly unlockedAt: number; + readonly triggerRef?: string; +} + +// ── Co-creator Identity ─────────────────────────────────────────── + +/** + * Well-known actor ID for the human co-creator (铲屎官). + * Pending F127 registry evolution to support participant types + * (cats + co-creator), this constant centralizes the identity + * to simplify future migration. + */ +export const CO_CREATOR_ACTOR_ID = 'co-creator' as const; + +export function isCoCreatorActor(actorId: string): boolean { + return actorId === CO_CREATOR_ACTOR_ID; +} + +// ── Activity Event Spine (ADR-023) ──────────────────────────────── + +/** Unified activity event — source of truth for all projectors */ +export interface ActivityEvent { + readonly type: ActivityEventType; + /** Cat ID or co-creator actor ID */ + readonly actorId: string; + readonly timestamp: string; + readonly threadId?: string; + readonly metadata: Record; +} + +export type ActivityEventType = + | 'tool_used' + | 'task_completed' + | 'message_sent' + | 'review_submitted' + | 'bug_caught' + | 'multi_mention_dispatched' + | 'multi_mention_completed' + | 'multi_mention_request_completed' + | 'deep_collab_completed' + | 'a2a_handoff_completed' + | 'evidence_cited' + | 'session_sealed' + | 'rich_block_created' + | 'design_feedback_given' + // Phase D6: Leadership event sources (decision + feedback dimensions) + | 'clarification_requested' // Cat asked co-creator for missing info (explicit: AskUserQuestion tool) + | 'decision_confirmed' // Co-creator confirmed direction (explicit: interactive block / implicit: text) + | 'feedback_applied'; // Co-creator feedback adopted in code (future: task sourceMessageId chain) + +// ── Phase D: Co-Creator Leadership (铲屎官六维) ────────────────── + +/** Six leadership dimensions for the co-creator (铲屎官), independent from cat trait dimensions */ +export type LeadershipDimension = + | 'coordination' // 协调力 — 知猫善任,多猫调度 + | 'delegation' // 授权力 — 放手让猫猫自主完成 + | 'exploration' // 开拓力 — 推动边界的有效探索 + | 'guidance' // 引导力 — 给猫猫的指令清晰度 + | 'decision' // 决策力 — 拍板方向的速度和质量 (v1: shadow) + | 'feedback'; // 反馈力 — 纠偏和正向反馈质量 (v1: shadow) + +export const LEADERSHIP_DIMENSIONS: readonly LeadershipDimension[] = [ + 'coordination', + 'delegation', + 'exploration', + 'guidance', + 'decision', + 'feedback', +] as const; + +/** v1 scores 4 dimensions live; decision + feedback are shadow scores (recorded but not displayed) */ +export const LEADERSHIP_LIVE_DIMS: readonly LeadershipDimension[] = [ + 'coordination', + 'delegation', + 'exploration', + 'guidance', +] as const; + +export const LEADERSHIP_SHADOW_DIMS: readonly LeadershipDimension[] = ['decision', 'feedback'] as const; + +export const LEADERSHIP_LABELS: Record = { + coordination: { zh: '协调力', en: 'Coordination' }, + delegation: { zh: '授权力', en: 'Delegation' }, + exploration: { zh: '开拓力', en: 'Exploration' }, + guidance: { zh: '引导力', en: 'Guidance' }, + decision: { zh: '决策力', en: 'Decision' }, + feedback: { zh: '反馈力', en: 'Feedback' }, +}; + +/** Footfall and seasoning for a single leadership dimension */ +export interface LeadershipStat { + readonly dimension: LeadershipDimension; + readonly footfall: number; + readonly level: number; + readonly footfallToNext: number; + /** v1: shadow dimensions are recorded but flagged */ + readonly shadow: boolean; +} + +/** Co-creator leadership profile snapshot */ +export interface LeadershipProfile { + readonly stats: Record; + readonly leadershipLevel: number; + readonly totalFootfall: number; + readonly currentTitle?: CatTitle; + readonly updatedAt: number; +} + +/** Leadership footfall source — events that drive co-creator leadership footfall */ +export type LeadershipFootfallSource = + | 'multi_mention_dispatch' // 协调力: dispatched a multi-mention + | 'multi_mention_success' // 协调力: multi-mention completed successfully + | 'target_diversity' // 协调力: used diverse set of cats + | 'task_no_intervention' // 授权力: task completed without co-creator intervention + | 'deep_collab_initiated' // 授权力: initiated deep collab (3+ cats) + | 'tool_category_breadth' // 开拓力: used tools across categories + | 'new_skill_first_use' // 开拓力: first use of a new skill + | 'feature_initiated' // 开拓力: initiated a new feature discussion + | 'one_shot_completion' // 引导力: cat completed task on first try + | 'low_clarification' // 引导力: session with few clarification rounds + | 'direction_confirmed' // 决策力 (shadow/proxy): inferred from low clarification count + | 'direction_confirmed_explicit' // 决策力 (D6): explicit UI confirm / text confirmation + | 'feedback_applied' // 反馈力 (shadow): feedback led to improvement + | 'clarification_observed'; // 决策力 (D6 shadow): cat asked for clarification — D7 calibration data + +// ── Phase D: Leadership Titles (AC-D4) ─────────────────────────── + +/** Condition for unlocking a leadership title */ +export type LeadershipTitleCondition = + | { readonly type: 'leadership_dim_level'; readonly dimension: LeadershipDimension; readonly minLevel: number } + | { readonly type: 'leadership_level'; readonly minLevel: number } + | { readonly type: 'leadership_total_footfall'; readonly minFootfall: number }; + +export interface LeadershipTitleDefinition { + readonly id: string; + readonly label: { readonly zh: string; readonly en: string }; + readonly description: { readonly zh: string; readonly en: string }; + readonly rarity: TitleRarity; + readonly conditions: readonly LeadershipTitleCondition[]; +} + +// ── Backward-compat aliases (remove after full migration) ───────── + +/** @deprecated Use TraitDimension */ +export type GrowthDimension = TraitDimension; +/** @deprecated Use TRAIT_DIMENSIONS */ +export const GROWTH_DIMENSIONS = TRAIT_DIMENSIONS; +/** @deprecated Use CatJourneyProfile */ +export type CatGrowthProfile = CatJourneyProfile; +/** @deprecated Use JourneyOverview */ +export type GrowthOverview = JourneyOverview; +/** @deprecated Use FootfallEvent */ +export type XpEvent = FootfallEvent; +/** @deprecated Use FootfallSource */ +export type XpSource = FootfallSource; +/** @deprecated Use MomentCategory */ +export type AchievementCategory = MomentCategory; +/** @deprecated Use MomentRarity */ +export type AchievementRarity = MomentRarity; +/** @deprecated Use MomentCondition */ +export type AchievementCondition = MomentCondition; +/** @deprecated Use MomentDefinition */ +export type AchievementDefinition = MomentDefinition; +/** @deprecated Use UnlockedMoment */ +export type UnlockedAchievement = UnlockedMoment; diff --git a/packages/shared/src/types/leadership-title-definitions.ts b/packages/shared/src/types/leadership-title-definitions.ts new file mode 100644 index 0000000000..8f699c0f02 --- /dev/null +++ b/packages/shared/src/types/leadership-title-definitions.ts @@ -0,0 +1,83 @@ +/** + * F160 Phase D (AC-D4) — Leadership title definitions + * + * Title path for the co-creator (铲屎官): + * 初心铲屎官 → 协调新手 → 授权达人 → 开拓先锋 → 团队指挥官 → 猫猫军师 + * + * Only live dimensions (coordination, delegation, exploration, guidance) + * are used in conditions. Shadow dimensions are excluded. + */ + +import type { LeadershipTitleDefinition } from './journey.js'; + +export const LEADERSHIP_TITLE_DEFINITIONS: readonly LeadershipTitleDefinition[] = [ + // ── Common ────────────────────────────────────────────────────── + { + id: 'beginner-scooper', + label: { zh: '初心铲屎官', en: 'Beginner Scooper' }, + description: { zh: '领导力任意维度达到 Lv.1', en: 'Reach Lv.1 in any leadership dimension' }, + rarity: 'common', + conditions: [{ type: 'leadership_level', minLevel: 1 }], + }, + { + id: 'coordination-rookie', + label: { zh: '协调新手', en: 'Coordination Rookie' }, + description: { zh: '协调力达到 Lv.2', en: 'Reach Lv.2 in Coordination' }, + rarity: 'common', + conditions: [{ type: 'leadership_dim_level', dimension: 'coordination', minLevel: 2 }], + }, + + // ── Rare ──────────────────────────────────────────────────────── + { + id: 'delegation-master', + label: { zh: '授权达人', en: 'Delegation Master' }, + description: { zh: '授权力达到 Lv.3', en: 'Reach Lv.3 in Delegation' }, + rarity: 'rare', + conditions: [{ type: 'leadership_dim_level', dimension: 'delegation', minLevel: 3 }], + }, + { + id: 'explorer-pioneer', + label: { zh: '开拓先锋', en: 'Explorer Pioneer' }, + description: { zh: '开拓力达到 Lv.3', en: 'Reach Lv.3 in Exploration' }, + rarity: 'rare', + conditions: [{ type: 'leadership_dim_level', dimension: 'exploration', minLevel: 3 }], + }, + { + id: 'guidance-mentor', + label: { zh: '引导之师', en: 'Guidance Mentor' }, + description: { zh: '引导力达到 Lv.3', en: 'Reach Lv.3 in Guidance' }, + rarity: 'rare', + conditions: [{ type: 'leadership_dim_level', dimension: 'guidance', minLevel: 3 }], + }, + + // ── Epic ──────────────────────────────────────────────────────── + { + id: 'team-commander', + label: { zh: '团队指挥官', en: 'Team Commander' }, + description: { zh: '协调力 Lv.4 + 引导力 Lv.3', en: 'Coordination Lv.4 + Guidance Lv.3' }, + rarity: 'epic', + conditions: [ + { type: 'leadership_dim_level', dimension: 'coordination', minLevel: 4 }, + { type: 'leadership_dim_level', dimension: 'guidance', minLevel: 3 }, + ], + }, + { + id: 'hands-off-leader', + label: { zh: '放手型领导', en: 'Hands-off Leader' }, + description: { zh: '授权力 Lv.4 + 开拓力 Lv.3', en: 'Delegation Lv.4 + Exploration Lv.3' }, + rarity: 'epic', + conditions: [ + { type: 'leadership_dim_level', dimension: 'delegation', minLevel: 4 }, + { type: 'leadership_dim_level', dimension: 'exploration', minLevel: 3 }, + ], + }, + + // ── Legendary ─────────────────────────────────────────────────── + { + id: 'cat-strategist', + label: { zh: '猫猫军师', en: 'Cat Strategist' }, + description: { zh: '领导力总等级达到 Lv.5', en: 'Reach leadership level 5' }, + rarity: 'legendary', + conditions: [{ type: 'leadership_level', minLevel: 5 }], + }, +] as const; diff --git a/packages/shared/src/types/title-definitions.ts b/packages/shared/src/types/title-definitions.ts new file mode 100644 index 0000000000..59faaaf94d --- /dev/null +++ b/packages/shared/src/types/title-definitions.ts @@ -0,0 +1,134 @@ +/** + * F160 Phase B — Title definitions registry + * + * Static list of all titles that can be unlocked via growth attributes. + * Conditions use AND logic — all must be met for unlock. + * + * Rarity tiers: + * common — single dimension Lv.1-2 + * rare — single dimension Lv.3-5 + * epic — multi-dimension combo + * legendary — extreme achievement + */ + +import type { TitleDefinition } from './journey.js'; + +export const TITLE_DEFINITIONS: readonly TitleDefinition[] = [ + // ── Common (single dimension low level) ────────────────────────── + { + id: 'first-step', + label: { zh: '初出茅庐', en: 'First Step' }, + description: { zh: '任意维度达到 Lv.1', en: 'Reach Lv.1 in any dimension' }, + rarity: 'common', + conditions: [{ type: 'overall_level', minLevel: 1 }], + }, + { + id: 'doer', + label: { zh: '实干家', en: 'Doer' }, + description: { zh: '执行力达到 Lv.2', en: 'Reach Lv.2 in Execution' }, + rarity: 'common', + conditions: [{ type: 'dimension_level', dimension: 'execution', minLevel: 2 }], + }, + { + id: 'thinker', + label: { zh: '思考者', en: 'Thinker' }, + description: { zh: '架构力达到 Lv.2', en: 'Reach Lv.2 in Architecture' }, + rarity: 'common', + conditions: [{ type: 'dimension_level', dimension: 'architecture', minLevel: 2 }], + }, + { + id: 'team-player', + label: { zh: '团队之心', en: 'Team Player' }, + description: { zh: '协作力达到 Lv.2', en: 'Reach Lv.2 in Collaboration' }, + rarity: 'common', + conditions: [{ type: 'dimension_level', dimension: 'collaboration', minLevel: 2 }], + }, + + // ── Rare (single dimension mid level) ──────────────────────────── + { + id: 'eagle-eye', + label: { zh: '鹰眼', en: 'Eagle Eye' }, + description: { zh: '审查力达到 Lv.5', en: 'Reach Lv.5 in Review' }, + rarity: 'rare', + conditions: [{ type: 'dimension_level', dimension: 'review', minLevel: 5 }], + }, + { + id: 'artisan', + label: { zh: '匠人', en: 'Artisan' }, + description: { zh: '审美力达到 Lv.3', en: 'Reach Lv.3 in Aesthetics' }, + rarity: 'rare', + conditions: [{ type: 'dimension_level', dimension: 'aesthetics', minLevel: 3 }], + }, + { + id: 'scout', + label: { zh: '侦察兵', en: 'Scout' }, + description: { zh: '洞察力达到 Lv.3', en: 'Reach Lv.3 in Insight' }, + rarity: 'rare', + conditions: [{ type: 'dimension_level', dimension: 'insight', minLevel: 3 }], + }, + { + id: 'veteran', + label: { zh: '老兵', en: 'Veteran' }, + description: { zh: '执行力达到 Lv.5', en: 'Reach Lv.5 in Execution' }, + rarity: 'rare', + conditions: [{ type: 'dimension_level', dimension: 'execution', minLevel: 5 }], + }, + { + id: 'diplomat', + label: { zh: '外交官', en: 'Diplomat' }, + description: { zh: '协作力达到 Lv.4', en: 'Reach Lv.4 in Collaboration' }, + rarity: 'rare', + conditions: [{ type: 'dimension_level', dimension: 'collaboration', minLevel: 4 }], + }, + + // ── Epic (multi-dimension combo) ───────────────────────────────── + { + id: 'chief-architect', + label: { zh: '首席架构师', en: 'Chief Architect' }, + description: { zh: '架构力 Lv.4 + 协作力 Lv.3', en: 'Architecture Lv.4 + Collaboration Lv.3' }, + rarity: 'epic', + conditions: [ + { type: 'dimension_level', dimension: 'architecture', minLevel: 4 }, + { type: 'dimension_level', dimension: 'collaboration', minLevel: 3 }, + ], + }, + { + id: 'full-stack', + label: { zh: '全栈战士', en: 'Full Stack' }, + description: { zh: '执行力 Lv.4 + 审美力 Lv.3', en: 'Execution Lv.4 + Aesthetics Lv.3' }, + rarity: 'epic', + conditions: [ + { type: 'dimension_level', dimension: 'execution', minLevel: 4 }, + { type: 'dimension_level', dimension: 'aesthetics', minLevel: 3 }, + ], + }, + { + id: 'sentinel', + label: { zh: '守门员', en: 'Sentinel' }, + description: { zh: '审查力 Lv.4 + 洞察力 Lv.3', en: 'Review Lv.4 + Insight Lv.3' }, + rarity: 'epic', + conditions: [ + { type: 'dimension_level', dimension: 'review', minLevel: 4 }, + { type: 'dimension_level', dimension: 'insight', minLevel: 3 }, + ], + }, + + // ── Legendary (extreme achievement) ────────────────────────────── + { + id: 'prophet', + label: { zh: '预言家', en: 'Prophet' }, + description: { zh: '洞察力 Lv.5 + 审查力 Lv.4', en: 'Insight Lv.5 + Review Lv.4' }, + rarity: 'legendary', + conditions: [ + { type: 'dimension_level', dimension: 'insight', minLevel: 5 }, + { type: 'dimension_level', dimension: 'review', minLevel: 4 }, + ], + }, + { + id: 'polymath', + label: { zh: '全才', en: 'Polymath' }, + description: { zh: '总等级达到 Lv.5', en: 'Reach overall Lv.5' }, + rarity: 'legendary', + conditions: [{ type: 'overall_level', minLevel: 5 }], + }, +] as const; diff --git a/packages/web/src/app/achievement-export/[memberId]/page.tsx b/packages/web/src/app/achievement-export/[memberId]/page.tsx new file mode 100644 index 0000000000..e356b52041 --- /dev/null +++ b/packages/web/src/app/achievement-export/[memberId]/page.tsx @@ -0,0 +1,115 @@ +'use client'; + +/** + * F157 AC-C4: Achievement Wall Export Page + * + * Standalone page rendered for Puppeteer screenshot. + * ImageExporter navigates here, waits for data-export-ready="true", then captures. + * Designed for tight 480px viewport — card fills width. + * + * URL: /achievement-export/:memberId?export=true&userId=... + */ + +import type { AchievementRarity, UnlockedAchievement } from '@cat-cafe/shared'; +import { useParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/utils/api-client'; + +interface AchievementWallEntry { + id: string; + label: { zh: string; en: string }; + description: { zh: string; en: string }; + category: string; + rarity: AchievementRarity; + unlocked: UnlockedAchievement | null; +} + +interface WallResponse { + memberId: string; + achievements: AchievementWallEntry[]; + totalUnlocked: number; + totalDefined: number; +} + +const RARITY_STYLES: Record = { + common: { bg: 'bg-gray-50', border: 'border-gray-200', text: 'text-gray-600' }, + rare: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-700' }, + epic: { bg: 'bg-purple-50', border: 'border-purple-300', text: 'text-purple-700' }, + legendary: { bg: 'bg-amber-50', border: 'border-amber-400', text: 'text-amber-700' }, +}; + +const RARITY_LABELS: Record = { + common: '普通', + rare: '稀有', + epic: '史诗', + legendary: '传说', +}; + +export default function AchievementExportPage() { + const { memberId } = useParams<{ memberId: string }>(); + const [wall, setWall] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!memberId) return; + apiFetch(`/api/achievements/${memberId}/wall`) + .then(async (res) => { + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + setError(body.error ?? `Failed to load (${res.status})`); + return; + } + setWall((await res.json()) as WallResponse); + }) + .catch(() => setError('Network error')); + }, [memberId]); + + const ready = wall !== null; + const unlocked = wall?.achievements.filter((a) => a.unlocked) ?? []; + + return ( +
+ {error ? ( +
{error}
+ ) : wall ? ( + <> +
+

成就墙

+ + 已解锁 {wall.totalUnlocked} / {wall.totalDefined} + +
+
+ {unlocked.map((entry) => { + const style = RARITY_STYLES[entry.rarity]; + return ( +
+
+ {'\u2B50'} +
+
+ {entry.label.zh} + + {RARITY_LABELS[entry.rarity]} + +
+

{entry.description.zh}

+ {entry.unlocked && ( +

+ {new Date(entry.unlocked.unlockedAt).toLocaleDateString('zh-CN')} 解锁 +

+ )} +
+
+
+ ); + })} +
+
Clowder AI · Achievement Wall
+ + ) : ( +
Loading...
+ )} +
+ ); +} diff --git a/packages/web/src/app/growth-export/[catId]/page.tsx b/packages/web/src/app/growth-export/[catId]/page.tsx new file mode 100644 index 0000000000..5ac20a113a --- /dev/null +++ b/packages/web/src/app/growth-export/[catId]/page.tsx @@ -0,0 +1,55 @@ +'use client'; + +/** + * F157 AC-A3: Journey Card Export Page + * + * Standalone page that renders a single CatProfileCard for Puppeteer screenshot. + * ImageExporter navigates here, waits for data-export-ready="true", then captures. + * Designed for tight 480px viewport — no centering, card fills width. + * + * URL: /growth-export/:catId?export=true&userId=... + */ + +import type { CatGrowthProfile } from '@cat-cafe/shared'; +import { useParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { CatProfileCard } from '@/components/journey/CatProfileCard'; +import { apiFetch } from '@/utils/api-client'; + +export default function GrowthExportPage() { + const { catId } = useParams<{ catId: string }>(); + const [profile, setProfile] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!catId) return; + apiFetch(`/api/journey/${catId}`) + .then(async (res) => { + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + setError(body.error ?? `Failed to load profile (${res.status})`); + return; + } + setProfile((await res.json()) as CatGrowthProfile); + }) + .catch(() => setError('Network error')); + }, [catId]); + + const ready = profile !== null; + + return ( +
+ {error ? ( +
{error}
+ ) : profile ? ( + <> + + {/* Brand footer for shared images */} +
Clowder AI · Cat Journey
+ + ) : ( +
Loading...
+ )} +
+ ); +} diff --git a/packages/web/src/components/journey/AchievementCard.tsx b/packages/web/src/components/journey/AchievementCard.tsx new file mode 100644 index 0000000000..2ce35896e6 --- /dev/null +++ b/packages/web/src/components/journey/AchievementCard.tsx @@ -0,0 +1,77 @@ +'use client'; + +import type { AchievementRarity, UnlockedAchievement } from '@cat-cafe/shared'; + +const RARITY_STYLES: Record = { + common: { bg: 'bg-gray-50', border: 'border-gray-200', text: 'text-gray-600', glow: '' }, + rare: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-700', glow: '' }, + epic: { bg: 'bg-purple-50', border: 'border-purple-300', text: 'text-purple-700', glow: 'shadow-purple-200/50' }, + legendary: { bg: 'bg-amber-50', border: 'border-amber-400', text: 'text-amber-700', glow: 'shadow-amber-200/50' }, +}; + +const RARITY_LABELS: Record = { + common: '普通', + rare: '稀有', + epic: '史诗', + legendary: '传说', +}; + +const CATEGORY_LABELS: Record = { + individual: '个人', + team: '团队', + milestone: '里程碑', + hidden: '隐藏', +}; + +interface Props { + definition: { + id: string; + label: { zh: string; en: string }; + description: { zh: string; en: string }; + category: string; + rarity: AchievementRarity; + icon?: string; + }; + unlock: UnlockedAchievement | null; +} + +export function AchievementCard({ definition, unlock }: Props) { + const isUnlocked = !!unlock; + const style = RARITY_STYLES[definition.rarity]; + const isHidden = definition.category === 'hidden'; + + return ( +
+
+ {isUnlocked ? '\u2B50' : '\u2606'} +
+
+ + {isUnlocked || !isHidden ? definition.label.zh : '???'} + + + {RARITY_LABELS[definition.rarity]} + + + {CATEGORY_LABELS[definition.category] ?? definition.category} + +
+

+ {isUnlocked || !isHidden ? definition.description.zh : '??????'} +

+ {isUnlocked && unlock && ( +

+ {new Date(unlock.unlockedAt).toLocaleDateString('zh-CN')} 解锁 +

+ )} +
+
+
+ ); +} diff --git a/packages/web/src/components/journey/AchievementWall.tsx b/packages/web/src/components/journey/AchievementWall.tsx new file mode 100644 index 0000000000..2f33f0d1a2 --- /dev/null +++ b/packages/web/src/components/journey/AchievementWall.tsx @@ -0,0 +1,140 @@ +'use client'; + +/** + * F157 Phase C AC-C3: Achievement wall — shows all achievements with unlock status. + * Fetches data per member from /api/achievements/:memberId/wall. + */ + +import type { AchievementRarity, UnlockedAchievement } from '@cat-cafe/shared'; +import { useCallback, useEffect, useState } from 'react'; +import { DownloadIcon } from '@/components/icons/DownloadIcon'; +import { apiFetch } from '@/utils/api-client'; +import { AchievementCard } from './AchievementCard'; + +interface AchievementWallEntry { + id: string; + label: { zh: string; en: string }; + description: { zh: string; en: string }; + category: string; + rarity: AchievementRarity; + icon?: string; + unlocked: UnlockedAchievement | null; +} + +interface WallResponse { + memberId: string; + achievements: AchievementWallEntry[]; + totalUnlocked: number; + totalDefined: number; +} + +const CATEGORY_ORDER = ['individual', 'team', 'milestone', 'hidden']; +const CATEGORY_LABELS: Record = { + individual: '个人瞬间', + team: '团队瞬间', + milestone: '里程碑', + hidden: '隐藏瞬间', +}; + +interface Props { + memberId: string; +} + +export function AchievementWall({ memberId }: Props) { + const [wall, setWall] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + const fetchWall = useCallback(async () => { + setLoading(true); + setError(false); + try { + const res = await apiFetch(`/api/achievements/${memberId}/wall`); + if (res.ok) { + setWall((await res.json()) as WallResponse); + } else { + setError(true); + } + } catch { + setError(true); + } finally { + setLoading(false); + } + }, [memberId]); + + useEffect(() => { + fetchWall(); + }, [fetchWall]); + + const [exporting, setExporting] = useState(false); + + const handleExport = useCallback(async () => { + setExporting(true); + try { + const res = await apiFetch(`/api/achievements/${memberId}/export-image`, { method: 'POST' }); + if (!res.ok) throw new Error('导出失败'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `achievements-${memberId}.png`; + a.click(); + URL.revokeObjectURL(url); + } catch { + /* silent — export is best-effort */ + } finally { + setExporting(false); + } + }, [memberId]); + + if (loading) return
加载中...
; + if (error) return
珍贵瞬间数据加载失败
; + if (!wall) return null; + + // Group by category + const grouped = new Map(); + for (const cat of CATEGORY_ORDER) { + grouped.set( + cat, + wall.achievements.filter((a) => a.category === cat), + ); + } + + return ( +
+
+

珍贵瞬间

+
+ + 已解锁 {wall.totalUnlocked} / {wall.totalDefined} + + {wall.totalUnlocked > 0 && ( + + )} +
+
+ + {CATEGORY_ORDER.map((cat) => { + const items = grouped.get(cat); + if (!items || items.length === 0) return null; + return ( +
+

{CATEGORY_LABELS[cat] ?? cat}

+
+ {items.map((entry) => ( + + ))} +
+
+ ); + })} +
+ ); +} diff --git a/packages/web/src/components/journey/BondDisplay.tsx b/packages/web/src/components/journey/BondDisplay.tsx new file mode 100644 index 0000000000..5441e4a730 --- /dev/null +++ b/packages/web/src/components/journey/BondDisplay.tsx @@ -0,0 +1,88 @@ +'use client'; + +/** + * F157 Phase B AC-B2: Bond relationships between cats. + * Shows bond level, score, and interaction count. + */ + +import type { BondLevel, CatBond } from '@cat-cafe/shared'; +import { useCatData } from '@/hooks/useCatData'; + +const BOND_LABELS: Record = { + acquaintance: { zh: '初识', icon: '🤝', color: 'text-gray-500' }, + partner: { zh: '搭档', icon: '💪', color: 'text-blue-600' }, + soulmate: { zh: '灵魂伙伴', icon: '💜', color: 'text-purple-600' }, +}; + +interface Props { + catId: string; + bonds: (CatBond & { level: BondLevel })[]; +} + +export function BondDisplay({ catId, bonds }: Props) { + const { getCatById } = useCatData(); + + if (bonds.length === 0) { + return ( +
+ 暂无羁绊记录 — 多多协作就会产生羁绊 +
+ ); + } + + return ( +
+ {bonds.map((bond) => { + const otherId = bond.catA === catId ? bond.catB : bond.catA; + const otherCat = getCatById(otherId); + const bondStyle = BOND_LABELS[bond.level]; + const primaryColor = otherCat?.color?.primary ?? '#9B7EBD'; + + return ( +
+ {/* Cat avatar */} + {otherCat?.avatar ? ( + + ) : ( +
+ {(otherCat?.displayName ?? otherId).charAt(0)} +
+ )} + + {/* Name + bond level */} +
+
+ + {otherCat?.nickname ?? otherCat?.displayName ?? otherId} + + + {bondStyle.icon} {bondStyle.zh} + +
+
+ {bond.interactions} 次协作 · 羁绊值 {bond.score} +
+
+ + {/* Bond strength bar */} +
+
+
+
+
+
+ ); + })} +
+ ); +} diff --git a/packages/web/src/components/journey/CatProfileCard.tsx b/packages/web/src/components/journey/CatProfileCard.tsx new file mode 100644 index 0000000000..676a9be64c --- /dev/null +++ b/packages/web/src/components/journey/CatProfileCard.tsx @@ -0,0 +1,93 @@ +'use client'; + +import type { CatGrowthProfile, GrowthDimension } from '@cat-cafe/shared'; +import { useCatData } from '@/hooks/useCatData'; +import { GrowthRadarChart } from './GrowthRadarChart'; + +const DIM_LABELS: Record = { + architecture: '架构力', + review: '审查力', + aesthetics: '审美力', + execution: '执行力', + collaboration: '协作力', + insight: '洞察力', +}; + +const DIMENSIONS: GrowthDimension[] = ['architecture', 'review', 'aesthetics', 'execution', 'collaboration', 'insight']; + +interface Props { + profile: CatGrowthProfile; + onClick?: () => void; +} + +/** Compact summary card — click to open detail modal. */ +export function CatProfileCard({ profile, onClick }: Props) { + const { getCatById } = useCatData(); + const catData = getCatById(profile.catId); + const primaryColor = catData?.color?.primary ?? '#9B7EBD'; + const { attributes } = profile; + const { stats, overallLevel, totalFootfall } = attributes; + + // Find top 2 dimensions by footfall + const ranked = DIMENSIONS.map((d) => ({ dim: d, footfall: stats[d]?.footfall ?? 0 })) + .filter((d) => d.footfall > 0) + .sort((a, b) => b.footfall - a.footfall) + .slice(0, 2); + + return ( + + ); +} diff --git a/packages/web/src/components/journey/CoCreatorCard.tsx b/packages/web/src/components/journey/CoCreatorCard.tsx new file mode 100644 index 0000000000..18051cb824 --- /dev/null +++ b/packages/web/src/components/journey/CoCreatorCard.tsx @@ -0,0 +1,176 @@ +'use client'; + +/** + * F157 AC-C6: Co-Creator (铲屎官) journey card — distinct from cat cards. + * Shows human-specific metrics: contribution style, cat interaction overview. + */ + +import type { CatGrowthProfile, GrowthDimension } from '@cat-cafe/shared'; +import { useCallback, useEffect, useState } from 'react'; +import { useCatData } from '@/hooks/useCatData'; +import { apiFetch } from '@/utils/api-client'; +import { GrowthRadarChart } from './GrowthRadarChart'; + +interface BondEntry { + catA: string; + catB: string; + score: number; + interactions: number; + level: 'acquaintance' | 'partner' | 'soulmate'; +} + +const DIM_LABELS: Record = { + architecture: '架构力', + review: '审查力', + aesthetics: '审美力', + execution: '执行力', + collaboration: '协作力', + insight: '洞察力', +}; + +const STYLE_PROFILES: { dims: GrowthDimension[]; label: string; icon: string }[] = [ + { dims: ['architecture', 'insight'], label: '战略型', icon: '\uD83C\uDFAF' }, + { dims: ['execution', 'review'], label: '实干型', icon: '\u26A1' }, + { dims: ['collaboration', 'aesthetics'], label: '赋能型', icon: '\uD83E\uDD1D' }, +]; + +const BOND_LABELS: Record = { + acquaintance: '初识', + partner: '搭档', + soulmate: '心有灵犀', +}; + +interface Props { + profile: CatGrowthProfile; + onClick?: () => void; +} + +export function CoCreatorCard({ profile, onClick }: Props) { + const { cats } = useCatData(); + const { attributes } = profile; + const { stats, overallLevel, totalFootfall } = attributes; + const [bonds, setBonds] = useState([]); + + useEffect(() => { + apiFetch(`/api/journey/${profile.catId}/bonds`) + .then(async (res) => { + if (res.ok) { + const data = (await res.json()) as { bonds: BondEntry[] }; + setBonds(data.bonds); + } + }) + .catch(() => {}); + }, [profile.catId]); + + // Determine contribution style from top dimensions + const dimRanked = (Object.keys(stats) as GrowthDimension[]) + .map((d) => ({ dim: d, footfall: stats[d]?.footfall ?? 0 })) + .sort((a, b) => b.footfall - a.footfall); + const topDims = new Set(dimRanked.slice(0, 2).map((d) => d.dim)); + + const styleMatch = STYLE_PROFILES.find((s) => s.dims.some((d) => topDims.has(d))); + const styleLabel = styleMatch?.label ?? '探索中'; + const styleIcon = styleMatch?.icon ?? '\uD83C\uDF1F'; + + const getCatName = useCallback( + (catId: string) => { + const cat = cats.find((c) => c.id === catId); + return cat?.nickname ?? cat?.displayName ?? catId; + }, + [cats], + ); + + const getCatColor = useCallback( + (catId: string) => { + const cat = cats.find((c) => c.id === catId); + return cat?.color?.primary ?? '#9B7EBD'; + }, + [cats], + ); + + // Top 3 bonds + const topBonds = bonds.slice(0, 3); + + return ( + + ); +} diff --git a/packages/web/src/components/journey/EvolutionTimeline.tsx b/packages/web/src/components/journey/EvolutionTimeline.tsx new file mode 100644 index 0000000000..0e37f60305 --- /dev/null +++ b/packages/web/src/components/journey/EvolutionTimeline.tsx @@ -0,0 +1,132 @@ +'use client'; + +/** + * F157 AC-E2: Evolution Timeline — milestone narrative events for a cat. + * Displays level-ups, first dimensions, title/achievement unlocks in a + * vertical timeline. Expandable section, lazy-loaded on first open. + */ + +import type { EvolutionEvent, EvolutionEventType } from '@cat-cafe/shared'; +import { useCallback, useEffect, useState } from 'react'; +import { apiFetch } from '@/utils/api-client'; + +/** Icon per event type — plain text emoji keeps bundle small. */ +const TYPE_ICON: Record = { + level_up: '\u2B06\uFE0F', // arrow up + first_dim: '\u2728', // sparkles + achievement_unlocked: '\uD83C\uDFC6', // trophy + title_unlocked: '\uD83D\uDC51', // crown + bond_milestone: '\uD83E\uDD1D', // handshake +}; + +const TYPE_LABEL: Record = { + level_up: '升级', + first_dim: '新维度', + achievement_unlocked: '瞬间', + title_unlocked: '称号', + bond_milestone: '羁绊', +}; + +function relativeTime(ts: number): string { + const diff = Date.now() - ts; + if (diff < 60_000) return '刚刚'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}分钟前`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}小时前`; + return `${Math.floor(diff / 86_400_000)}天前`; +} + +interface Props { + catId: string; + color?: string; +} + +export function EvolutionTimeline({ catId, color = '#9B7EBD' }: Props) { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [fetchError, setFetchError] = useState(false); + const [fetched, setFetched] = useState(false); + const [expanded, setExpanded] = useState(false); + + const fetchEvents = useCallback(async () => { + setLoading(true); + setFetchError(false); + let ok = false; + try { + const res = await apiFetch(`/api/journey/${catId}/evolution?limit=30`); + if (res.ok) { + const data = (await res.json()) as { events: EvolutionEvent[] }; + setEvents(data.events); + ok = true; + } else { + setFetchError(true); + } + } catch { + setFetchError(true); + } finally { + setLoading(false); + if (ok) setFetched(true); + } + }, [catId]); + + useEffect(() => { + if (expanded && !fetched) fetchEvents(); + }, [expanded, fetched, fetchEvents]); + + return ( +
+ + + {expanded && ( +
+ {loading ? ( +
加载中...
+ ) : fetchError ? ( +
加载失败,请稍后重试
+ ) : events.length === 0 ? ( +
暂无里程碑记录
+ ) : ( +
+ {events.map((ev, i) => ( +
+ {/* Dot on the timeline line */} +
+
+ {TYPE_ICON[ev.type] ?? '\u2022'} +
+

{ev.narrative.zh}

+
+ + {TYPE_LABEL[ev.type] ?? ev.type} + + {relativeTime(ev.timestamp)} +
+
+
+
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/packages/web/src/components/journey/GrowthDetailModal.tsx b/packages/web/src/components/journey/GrowthDetailModal.tsx new file mode 100644 index 0000000000..8959104eed --- /dev/null +++ b/packages/web/src/components/journey/GrowthDetailModal.tsx @@ -0,0 +1,221 @@ +'use client'; + +import type { CatGrowthProfile, GrowthDimension } from '@cat-cafe/shared'; +import { useCallback, useEffect, useState } from 'react'; +import { DownloadIcon } from '@/components/icons/DownloadIcon'; +import { useCatData } from '@/hooks/useCatData'; +import { apiFetch } from '@/utils/api-client'; +import { AchievementWall } from './AchievementWall'; +import { EvolutionTimeline } from './EvolutionTimeline'; +import { GrowthRadarChart } from './GrowthRadarChart'; +import { SkillTreePanel } from './SkillTreePanel'; +import { XpAuditLog } from './XpAuditLog'; + +const DIM_LABELS: Record = { + architecture: '架构力', + review: '审查力', + aesthetics: '审美力', + execution: '执行力', + collaboration: '协作力', + insight: '洞察力', +}; + +const DIMENSIONS: GrowthDimension[] = ['architecture', 'review', 'aesthetics', 'execution', 'collaboration', 'insight']; + +interface Props { + profile: CatGrowthProfile; + onClose: () => void; +} + +export function GrowthDetailModal({ profile, onClose }: Props) { + const { getCatById } = useCatData(); + const catData = getCatById(profile.catId); + const primaryColor = catData?.color?.primary ?? '#9B7EBD'; + const { attributes } = profile; + const { stats, overallLevel, totalFootfall } = attributes; + + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(null); + + // Escape to close + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [onClose]); + + const handleExport = useCallback(async () => { + setExporting(true); + setExportError(null); + try { + const res = await apiFetch(`/api/journey/${profile.catId}/export-image`, { method: 'POST' }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { message?: string; error?: string }; + throw new Error(body.message || body.error || '导出失败'); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${profile.nickname ?? profile.displayName}-journey.png`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + setExportError(err instanceof Error ? err.message : '导出失败'); + setTimeout(() => setExportError(null), 4000); + } finally { + setExporting(false); + } + }, [profile.catId, profile.nickname, profile.displayName]); + + return ( +
+
e.stopPropagation()} + > + {/* Sticky header */} +
+ {catData?.avatar ? ( + + ) : ( +
+ {profile.displayName.charAt(0)} +
+ )} +
+
+ {profile.nickname ?? profile.displayName} + + {/* Lv. maps to 历练 (Seasoning) */} + Lv.{overallLevel} + +
+ + {profile.currentTitle?.label.zh ?? profile.displayName} + {' · '} + {totalFootfall.toLocaleString()} 足迹点 + +
+ + +
+ + {/* Scrollable body */} +
+ {/* Radar + dimension bars — side by side on desktop */} +
+
+ +
+
+ {DIMENSIONS.map((d) => { + const s = stats[d]; + if (!s) return null; + const progress = + s.footfallToNext > 0 + ? (s.footfall - s.level * s.level * 100) / (s.footfallToNext + s.footfall - s.level * s.level * 100) + : 1; + return ( +
+ {DIM_LABELS[d]} + + Lv.{s.level} + +
+
+
+ {s.footfall.toLocaleString()} 足迹点 +
+ ); + })} +
+
+ + {/* Evolution timeline (AC-E2) */} +
+ +
+ + {/* XP audit log */} +
+ +
+ + {/* Titles + bonds */} +
+ +
+ + {/* Phase C: Moments (珍贵瞬间) wall */} +
+ +
+
+
+
+ ); +} + +function ExportButton({ + exporting, + error, + onClick, +}: { + exporting: boolean; + error: string | null; + onClick: () => void; +}) { + return ( +
+ + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/packages/web/src/components/journey/GrowthRadarChart.tsx b/packages/web/src/components/journey/GrowthRadarChart.tsx new file mode 100644 index 0000000000..007dea2032 --- /dev/null +++ b/packages/web/src/components/journey/GrowthRadarChart.tsx @@ -0,0 +1,100 @@ +'use client'; + +import type { DimensionStat, GrowthDimension } from '@cat-cafe/shared'; + +const DIMENSION_LABELS: Record = { + architecture: '架构力', + review: '审查力', + aesthetics: '审美力', + execution: '执行力', + collaboration: '协作力', + insight: '洞察力', +}; + +const DIMENSIONS: GrowthDimension[] = ['architecture', 'review', 'aesthetics', 'execution', 'collaboration', 'insight']; + +interface Props { + stats: Record; + size?: number; + color?: string; +} + +/** Pure SVG traits portrait (特质画像) radar chart — no external dependency. */ +export function GrowthRadarChart({ stats, size = 200, color = '#9B7EBD' }: Props) { + const cx = size / 2; + const cy = size / 2; + const radius = size * 0.38; + const angleStep = (2 * Math.PI) / DIMENSIONS.length; + const labelOffset = radius + 22; + + // Max level for normalization (capped at 10 for visual balance) + const maxLevel = 10; + + /** Convert (dimension index, value 0–1) to SVG coordinates. */ + function point(i: number, ratio: number): [number, number] { + const angle = angleStep * i - Math.PI / 2; + return [cx + radius * ratio * Math.cos(angle), cy + radius * ratio * Math.sin(angle)]; + } + + // Grid rings at 25%, 50%, 75%, 100% + const rings = [0.25, 0.5, 0.75, 1]; + + // Data polygon + const dataPoints = DIMENSIONS.map((d, i) => { + const level = stats[d]?.level ?? 0; + return point(i, Math.min(level / maxLevel, 1)); + }); + return ( + + {/* Grid rings */} + {rings.map((r) => ( + point(i, r).join(',')).join(' ')} + fill="none" + stroke="#e5e0d8" + strokeWidth={r === 1 ? 1.5 : 0.8} + /> + ))} + + {/* Axis lines */} + {DIMENSIONS.map((_, i) => { + const [ex, ey] = point(i, 1); + return ; + })} + + {/* Data fill + border */} + p.join(',')).join(' ')} + fill={`${color}25`} + stroke={color} + strokeWidth={2} + /> + + {/* Data dots */} + {dataPoints.map(([x, y], i) => ( + + ))} + + {/* Labels */} + {DIMENSIONS.map((d, i) => { + const angle = angleStep * i - Math.PI / 2; + const lx = cx + labelOffset * Math.cos(angle); + const ly = cy + labelOffset * Math.sin(angle); + const level = stats[d]?.level ?? 0; + return ( + + {DIMENSION_LABELS[d]} Lv.{level} + + ); + })} + + ); +} diff --git a/packages/web/src/components/journey/HubGrowthTab.tsx b/packages/web/src/components/journey/HubGrowthTab.tsx new file mode 100644 index 0000000000..9c0f2d7b64 --- /dev/null +++ b/packages/web/src/components/journey/HubGrowthTab.tsx @@ -0,0 +1,108 @@ +'use client'; + +import type { CatGrowthProfile, GrowthOverview } from '@cat-cafe/shared'; +import { useCallback, useEffect, useState } from 'react'; +import { apiFetch } from '@/utils/api-client'; +import { CatProfileCard } from './CatProfileCard'; +import { CoCreatorCard } from './CoCreatorCard'; +import { GrowthDetailModal } from './GrowthDetailModal'; +import { LeadershipPanel } from './LeadershipPanel'; + +const CO_CREATOR_ID = 'co-creator'; + +export function HubGrowthTab() { + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedProfile, setSelectedProfile] = useState(null); + + const fetchOverview = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await apiFetch('/api/journey/overview'); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setError((body as { error?: string }).error ?? '加载失败'); + return; + } + setOverview((await res.json()) as GrowthOverview); + } catch { + setError('网络错误'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchOverview(); + }, [fetchOverview]); + + const coCreator = overview?.profiles.find((p) => p.catId === CO_CREATOR_ID); + const catProfiles = overview?.profiles.filter((p) => p.catId !== CO_CREATOR_ID) ?? []; + + return ( +
+ {/* Header */} +
+
+

猫猫足迹

+

真实协作数据结晶 · 六维特质 · AI Agent 历练之旅

+
+ {overview ? ( +
+ + Lv.{overview.teamLevel} + +

团队历练 · {overview.teamTotalFootfall.toLocaleString()} 足迹点

+
+ ) : null} +
+ + {loading && !overview ? ( +
加载中...
+ ) : null} + {error ?
{error}
: null} + + {overview ? ( + <> + {/* Leadership panel — co-creator leadership dimensions */} +
+

铲屎官领导力

+ +
+ + {/* Co-Creator section — distinct from cats */} + {coCreator && ( +
+

铲屎官

+ setSelectedProfile(coCreator)} /> +
+ )} + + {/* Cat grid — more breathing room */} + {catProfiles.length > 0 && ( +
+

猫猫团队

+
+ {catProfiles.map((profile) => ( + setSelectedProfile(profile)} /> + ))} +
+
+ )} + + ) : null} + + {overview && overview.profiles.length === 0 ? ( +
+

还没有猫猫足迹数据

+

猫猫完成任务、review 代码后会自动积累足迹点

+
+ ) : null} + + {/* Detail modal */} + {selectedProfile && setSelectedProfile(null)} />} +
+ ); +} diff --git a/packages/web/src/components/journey/LeadershipPanel.tsx b/packages/web/src/components/journey/LeadershipPanel.tsx new file mode 100644 index 0000000000..02cc294036 --- /dev/null +++ b/packages/web/src/components/journey/LeadershipPanel.tsx @@ -0,0 +1,227 @@ +'use client'; + +/** + * F157 AC-D5: Co-Creator Leadership Panel (铲屎官六维 Mission Control) + * + * Layout: + * Top — Leadership Level + current title + * Middle — Six-dim radar chart + 4 live KPI cards + * Bottom — Leadership timeline (expandable) + */ + +import type { LeadershipDimension, LeadershipProfile, LeadershipStat } from '@cat-cafe/shared'; +import { LEADERSHIP_LIVE_DIMS } from '@cat-cafe/shared'; +import { useCallback, useEffect, useState } from 'react'; +import { apiFetch } from '@/utils/api-client'; +import { LeadershipRadarChart } from './LeadershipRadarChart'; + +const DIM_LABELS: Record = { + coordination: '协调力', + delegation: '授权力', + exploration: '开拓力', + guidance: '引导力', + decision: '决策力', + feedback: '反馈力', +}; + +const SOURCE_LABELS: Record = { + multi_mention_dispatch: '多猫调度', + multi_mention_success: '调度成功', + target_diversity: '目标多样', + task_no_intervention: '自主完成', + deep_collab_initiated: '深度协作', + tool_category_breadth: '工具广度', + new_skill_first_use: '新技能首用', + feature_initiated: '发起新特性', + one_shot_completion: '一次到位', + low_clarification: '指令清晰', + direction_confirmed: '快速拍板', + feedback_applied: '反馈生效', +}; + +interface AuditEvent { + dimension: LeadershipDimension; + footfall: number; + source: string; + timestamp: number; +} + +function relativeTime(ts: number): string { + const diff = Date.now() - ts; + if (diff < 60_000) return '刚刚'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}分钟前`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}小时前`; + return `${Math.floor(diff / 86_400_000)}天前`; +} + +function KpiCard({ stat }: { stat: LeadershipStat }) { + const pct = stat.footfallToNext > 0 ? Math.min(stat.footfall / (stat.footfall + stat.footfallToNext), 1) : 1; + return ( +
+
+ {DIM_LABELS[stat.dimension]} + + Lv.{stat.level} + +
+
+
+
+

{stat.footfall.toLocaleString()} 足迹点

+
+ ); +} + +export function LeadershipPanel() { + const [profile, setProfile] = useState(null); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [timelineOpen, setTimelineOpen] = useState(false); + const [timelineFetched, setTimelineFetched] = useState(false); + const [timelineLoading, setTimelineLoading] = useState(false); + const [timelineError, setTimelineError] = useState(false); + + const fetchProfile = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await apiFetch('/api/journey/leadership'); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setError((body as { error?: string }).error ?? '加载失败'); + return; + } + setProfile((await res.json()) as LeadershipProfile); + } catch { + setError('网络错误'); + } finally { + setLoading(false); + } + }, []); + + const fetchTimeline = useCallback(async () => { + setTimelineLoading(true); + setTimelineError(false); + let ok = false; + try { + const res = await apiFetch('/api/journey/leadership/events?limit=30'); + if (res.ok) { + const data = (await res.json()) as { events: AuditEvent[] }; + setEvents(data.events); + ok = true; + } else { + setTimelineError(true); + } + } catch { + setTimelineError(true); + } finally { + setTimelineLoading(false); + if (ok) setTimelineFetched(true); + } + }, []); + + useEffect(() => { + fetchProfile(); + }, [fetchProfile]); + + useEffect(() => { + if (timelineOpen && !timelineFetched) fetchTimeline(); + }, [timelineOpen, timelineFetched, fetchTimeline]); + + if (loading && !profile) { + return
加载领导力数据...
; + } + if (error) { + return
{error}
; + } + if (!profile) return null; + + const titleLabel = profile.currentTitle?.label.zh ?? '铲屎官'; + + return ( +
+ {/* ── Top: Level + Title ─────────────────────── */} +
+
+ {'\u2B50'} +
+
+
+ 铲屎官领导力 + + Lv.{profile.leadershipLevel} + +
+

+ {titleLabel} · {profile.totalFootfall.toLocaleString()} 足迹点 +

+
+
+ + {/* ── Middle: Radar + KPIs ───────────────────── */} +
+
+ +
+
+ {LEADERSHIP_LIVE_DIMS.map((dim) => { + const stat = profile.stats[dim]; + return stat ? : null; + })} +
+
+ + {/* ── Bottom: Timeline ───────────────────────── */} +
+ + {timelineOpen && ( +
+ {timelineLoading ? ( +
加载中...
+ ) : timelineError ? ( +
加载失败,请稍后重试
+ ) : events.length === 0 ? ( +
暂无领导时刻记录
+ ) : ( +
+ {events.map((ev, i) => ( +
+ + +{ev.footfall} + + {DIM_LABELS[ev.dimension] ?? ev.dimension} + {SOURCE_LABELS[ev.source] ?? ev.source} + {relativeTime(ev.timestamp)} +
+ ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/packages/web/src/components/journey/LeadershipRadarChart.tsx b/packages/web/src/components/journey/LeadershipRadarChart.tsx new file mode 100644 index 0000000000..f48501debc --- /dev/null +++ b/packages/web/src/components/journey/LeadershipRadarChart.tsx @@ -0,0 +1,106 @@ +'use client'; + +import type { LeadershipDimension, LeadershipStat } from '@cat-cafe/shared'; +import { LEADERSHIP_LIVE_DIMS, LEADERSHIP_SHADOW_DIMS } from '@cat-cafe/shared'; + +const ALL_DIMS: LeadershipDimension[] = [...LEADERSHIP_LIVE_DIMS, ...LEADERSHIP_SHADOW_DIMS]; + +const DIM_LABELS: Record = { + coordination: '协调力', + delegation: '授权力', + exploration: '开拓力', + guidance: '引导力', + decision: '决策力', + feedback: '反馈力', +}; + +const shadowSet = new Set(LEADERSHIP_SHADOW_DIMS); + +interface Props { + stats: Record; + size?: number; + color?: string; +} + +/** Pure SVG leadership radar (六维领导力画像) — shadow dims rendered with dashed stroke. */ +export function LeadershipRadarChart({ stats, size = 200, color = '#D4A574' }: Props) { + const cx = size / 2; + const cy = size / 2; + const radius = size * 0.38; + const angleStep = (2 * Math.PI) / ALL_DIMS.length; + const labelOffset = radius + 22; + const maxLevel = 10; + + function point(i: number, ratio: number): [number, number] { + const angle = angleStep * i - Math.PI / 2; + return [cx + radius * ratio * Math.cos(angle), cy + radius * ratio * Math.sin(angle)]; + } + + const rings = [0.25, 0.5, 0.75, 1]; + + const dataPoints = ALL_DIMS.map((d, i) => { + const level = stats[d]?.level ?? 0; + return point(i, Math.min(level / maxLevel, 1)); + }); + + return ( + + {rings.map((r) => ( + point(i, r).join(',')).join(' ')} + fill="none" + stroke="#e5e0d8" + strokeWidth={r === 1 ? 1.5 : 0.8} + /> + ))} + + {ALL_DIMS.map((d, i) => { + const [ex, ey] = point(i, 1); + return ( + + ); + })} + + p.join(',')).join(' ')} + fill={`${color}25`} + stroke={color} + strokeWidth={2} + /> + + {dataPoints.map(([x, y], i) => ( + + ))} + + {ALL_DIMS.map((d, i) => { + const angle = angleStep * i - Math.PI / 2; + const lx = cx + labelOffset * Math.cos(angle); + const ly = cy + labelOffset * Math.sin(angle); + const level = stats[d]?.level ?? 0; + const isShadow = shadowSet.has(d); + return ( + + {DIM_LABELS[d]} Lv.{level} + + ); + })} + + ); +} diff --git a/packages/web/src/components/journey/SkillTreePanel.tsx b/packages/web/src/components/journey/SkillTreePanel.tsx new file mode 100644 index 0000000000..c9e6a18ab1 --- /dev/null +++ b/packages/web/src/components/journey/SkillTreePanel.tsx @@ -0,0 +1,142 @@ +'use client'; + +/** + * F157 Phase B AC-B3: Skill tree panel — shows title (珍贵瞬间/Moments) unlock paths + * and bond relationships for a single cat. + */ + +import type { + BondLevel, + CatBond, + CatGrowthProfile, + GrowthDimension, + TitleRarity, + UnlockedTitle, +} from '@cat-cafe/shared'; +import { TITLE_DEFINITIONS } from '@cat-cafe/shared'; +import { useCallback, useEffect, useState } from 'react'; +import { apiFetch } from '@/utils/api-client'; +import { BondDisplay } from './BondDisplay'; +import { TitleCard } from './TitleCard'; + +const RARITY_ORDER: TitleRarity[] = ['common', 'rare', 'epic', 'legendary']; + +const RARITY_SECTION_LABELS: Record = { + common: '普通称号', + rare: '稀有称号', + epic: '史诗称号', + legendary: '传说称号', +}; + +interface Props { + profile: CatGrowthProfile; +} + +export function SkillTreePanel({ profile }: Props) { + const [unlocked, setUnlocked] = useState([]); + const [bonds, setBonds] = useState<(CatBond & { level: BondLevel })[]>([]); + const [loading, setLoading] = useState(true); + const [titleError, setTitleError] = useState(false); + const [bondError, setBondError] = useState(false); + + const fetchData = useCallback(async () => { + setLoading(true); + setTitleError(false); + setBondError(false); + const [titlesRes, bondsRes] = await Promise.allSettled([ + apiFetch(`/api/journey/${profile.catId}/titles`), + apiFetch(`/api/journey/${profile.catId}/bonds`), + ]); + if (titlesRes.status === 'fulfilled' && titlesRes.value.ok) { + const data = (await titlesRes.value.json()) as { unlocked: UnlockedTitle[] }; + setUnlocked(data.unlocked); + } else { + setTitleError(true); + } + if (bondsRes.status === 'fulfilled' && bondsRes.value.ok) { + const data = (await bondsRes.value.json()) as { bonds: (CatBond & { level: BondLevel })[] }; + setBonds(data.bonds); + } else { + setBondError(true); + } + setLoading(false); + }, [profile.catId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const { stats, overallLevel, totalFootfall } = profile.attributes; + + // Build dimension level map for condition display + const dimensionLevels: Partial> = {}; + for (const [dim, stat] of Object.entries(stats)) { + dimensionLevels[dim as GrowthDimension] = stat.level; + } + + // Group titles by rarity + const grouped = new Map(); + for (const rarity of RARITY_ORDER) { + grouped.set( + rarity, + TITLE_DEFINITIONS.filter((d) => d.rarity === rarity), + ); + } + + if (loading) { + return
加载中...
; + } + + return ( +
+ {/* Title progression */} +
+
+

称号路径

+ {!titleError && ( + + 已解锁 {unlocked.length} / {TITLE_DEFINITIONS.length} + + )} +
+ {titleError ? ( +
称号数据加载失败,请稍后重试
+ ) : ( +
+ {RARITY_ORDER.map((rarity) => { + const defs = grouped.get(rarity); + if (!defs || defs.length === 0) return null; + return ( +
+

{RARITY_SECTION_LABELS[rarity]}

+
+ {defs.map((def) => ( + u.titleId === def.id)} + dimensionLevels={dimensionLevels} + overallLevel={overallLevel} + totalFootfall={totalFootfall} + /> + ))} +
+
+ ); + })} +
+ )} +
+ + {/* Bond relationships */} +
+

羁绊关系

+ {bondError ? ( +
羁绊数据加载失败,请稍后重试
+ ) : ( + + )} +
+
+ ); +} diff --git a/packages/web/src/components/journey/TitleCard.tsx b/packages/web/src/components/journey/TitleCard.tsx new file mode 100644 index 0000000000..83fa4a430f --- /dev/null +++ b/packages/web/src/components/journey/TitleCard.tsx @@ -0,0 +1,119 @@ +'use client'; + +/** + * F157 Phase B: Individual title card — shows locked/unlocked state, + * conditions, and rarity glow. + */ + +import type { GrowthDimension, TitleDefinition, TitleRarity, UnlockedTitle } from '@cat-cafe/shared'; +import { DIMENSION_LABELS } from '@cat-cafe/shared'; + +const RARITY_COLORS: Record = { + common: { bg: 'bg-gray-50', border: 'border-gray-200', text: 'text-gray-600' }, + rare: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-700' }, + epic: { bg: 'bg-purple-50', border: 'border-purple-300', text: 'text-purple-700' }, + legendary: { bg: 'bg-amber-50', border: 'border-amber-400', text: 'text-amber-700' }, +}; + +const RARITY_LABELS: Record = { + common: '普通', + rare: '稀有', + epic: '史诗', + legendary: '传说', +}; + +interface Props { + definition: TitleDefinition; + unlock?: UnlockedTitle; + /** Current dimension levels for progress display */ + dimensionLevels?: Partial>; + overallLevel?: number; + totalFootfall?: number; +} + +export function TitleCard({ definition, unlock, dimensionLevels, overallLevel, totalFootfall }: Props) { + const isUnlocked = !!unlock; + const style = RARITY_COLORS[definition.rarity]; + + return ( +
+
+
+
+ + {isUnlocked ? definition.label.zh : '???'} + + + {RARITY_LABELS[definition.rarity]} + +
+

+ {isUnlocked ? definition.description.zh : definition.description.zh} +

+
+ {isUnlocked ? ( + + ) : ( + + )} +
+ + {/* Condition progress */} +
+ {definition.conditions.map((cond, i) => { + let label = ''; + let current = 0; + let target = 0; + + if (cond.type === 'dimension_level') { + const dimLabel = DIMENSION_LABELS[cond.dimension]?.zh ?? cond.dimension; + label = `${dimLabel} Lv.${cond.minLevel}`; + current = dimensionLevels?.[cond.dimension] ?? 0; + target = cond.minLevel; + } else if (cond.type === 'overall_level') { + label = `总历练 Lv.${cond.minLevel}`; + current = overallLevel ?? 0; + target = cond.minLevel; + } else if (cond.type === 'total_footfall') { + label = `总足迹点 ${cond.minFootfall}`; + current = totalFootfall ?? 0; + target = cond.minFootfall; + } + + const met = current >= target; + const progress = target > 0 ? Math.min(current / target, 1) : 0; + + return ( +
+ {met ? '✓' : '○'} + {label} + {!met && ( +
+
+
+
+ + {current}/{target} + +
+ )} +
+ ); + })} +
+ + {isUnlocked && unlock && ( +
+ {new Date(unlock.unlockedAt).toLocaleDateString('zh-CN')} 解锁 +
+ )} +
+ ); +} diff --git a/packages/web/src/components/journey/XpAuditLog.tsx b/packages/web/src/components/journey/XpAuditLog.tsx new file mode 100644 index 0000000000..b284fcca37 --- /dev/null +++ b/packages/web/src/components/journey/XpAuditLog.tsx @@ -0,0 +1,133 @@ +'use client'; + +/** + * F157 AC-A5: Footfall Audit Log — shows recent footfall (足迹点) events for a cat. + * Renders as an expandable section within the profile card. + */ + +import type { FootfallEvent, GrowthDimension } from '@cat-cafe/shared'; +import { useCallback, useEffect, useState } from 'react'; +import { apiFetch } from '@/utils/api-client'; + +const DIM_LABELS: Record = { + architecture: '架构力', + review: '审查力', + aesthetics: '审美力', + execution: '执行力', + collaboration: '协作力', + insight: '洞察力', +}; + +const SOURCE_LABELS: Record = { + task_complete: '完成任务', + session_seal: '会话封存', + review_given: 'Review', + review_received: '收到 Review', + tool_use: '工具调用', + mention_collab: '@协作', + discussion: '讨论', + pr_merged: 'PR 合并', + bug_caught: '发现 Bug', + design_feedback: '设计反馈', + rich_block_create: '创建卡片', + evidence_cite: '引用证据', +}; + +function relativeTime(ts: number): string { + const diff = Date.now() - ts; + if (diff < 60_000) return '刚刚'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}分钟前`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}小时前`; + return `${Math.floor(diff / 86_400_000)}天前`; +} + +interface Props { + catId: string; + color?: string; + /** When true, expand and fetch immediately (used inside detail modal). */ + defaultOpen?: boolean; +} + +export function XpAuditLog({ catId, color = '#9B7EBD', defaultOpen = false }: Props) { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [fetchError, setFetchError] = useState(false); + const [fetched, setFetched] = useState(false); + const [expanded, setExpanded] = useState(defaultOpen); + + const fetchEvents = useCallback(async () => { + setLoading(true); + setFetchError(false); + let ok = false; + try { + const res = await apiFetch(`/api/journey/${catId}/events?limit=30`); + if (res.ok) { + const data = (await res.json()) as { events: FootfallEvent[] }; + setEvents(data.events); + ok = true; + } else { + setFetchError(true); + } + } catch { + setFetchError(true); + } finally { + setLoading(false); + // Only mark as fetched on success — error state allows retry on re-expand + if (ok) setFetched(true); + } + }, [catId]); + + useEffect(() => { + if (expanded && !fetched) fetchEvents(); + }, [expanded, fetched, fetchEvents]); + + return ( +
+ + + {expanded && ( +
+ {loading ? ( +
加载中...
+ ) : fetchError ? ( +
加载失败,请稍后重试
+ ) : events.length === 0 ? ( +
暂无足迹记录
+ ) : ( +
+ {events.map((ev, i) => ( +
+ + +{ev.footfall} + + {DIM_LABELS[ev.dimension] ?? ev.dimension} + {SOURCE_LABELS[ev.source] ?? ev.source} + {relativeTime(ev.timestamp)} +
+ ))} +
+ )} +
+ )} +
+ ); +}