feat: wire bark battle platform loop #21
File diff suppressed because it is too large
Load Diff
+110
@@ -0,0 +1,110 @@
|
||||
# Genarrative Domain Context
|
||||
|
||||
Genarrative 是一个 AI 原生互动内容与小游戏平台,当前上下文记录团队在玩法、作品、运行态和平台闭环中使用的领域语言。
|
||||
|
||||
## Language
|
||||
|
||||
### Bark Battle
|
||||
|
||||
**汪汪声浪大作战**:
|
||||
浏览器 2D 声控对战玩法,玩家通过麦克风响度触发声浪并推动能量条。
|
||||
_Avoid_: 狗叫识别游戏、声纹识别玩法
|
||||
|
||||
**有效声浪触发**:
|
||||
玩家麦克风采样点的归一化响度在冷却结束后达到或超过有效阈值时产生的一次计分输入。
|
||||
_Avoid_: 有效叫声持续时长、狗叫识别结果、等待回落后的叫声
|
||||
|
||||
**有效阈值**:
|
||||
用于判定麦克风采样是否产生有效声浪触发的响度门槛。
|
||||
_Avoid_: 狗叫识别阈值、语义识别阈值
|
||||
|
||||
**声浪冷却**:
|
||||
两次有效声浪触发之间必须满足的最小时间间隔。
|
||||
_Avoid_: 叫声持续时长、回落等待时间
|
||||
|
||||
**能量条**:
|
||||
表示玩家与对手当前声浪优势的连续对抗刻度。
|
||||
_Avoid_: 血条、分数条
|
||||
|
||||
**单局结果**:
|
||||
一局 Bark Battle 结束后形成的胜负、平局和派生统计摘要。
|
||||
_Avoid_: 原始音频记录、语音内容
|
||||
|
||||
**Bark Battle 平台作品闭环**:
|
||||
Bark Battle 从创作配置、发布作品、正式运行态到单局结果记录和作品统计的完整平台流程。
|
||||
_Avoid_: 孤立 demo、只做表现深化
|
||||
|
||||
**轻创作配置作品**:
|
||||
创作者只配置展示与难度预设字段、但不能直接配置公平性敏感规则的 Bark Battle 作品。
|
||||
_Avoid_: 完整规则编辑器、固定官方关卡
|
||||
|
||||
**难度预设**:
|
||||
Bark Battle 作品中用于选择 AI 对手行为强度的发布态配置值。
|
||||
_Avoid_: 阈值配置、分数公式配置
|
||||
|
||||
**排行榜分榜**:
|
||||
排行榜按作品、难度预设和规则集版本拆分后的独立排名空间,只收录后端裁决为玩家胜利的单局结果。
|
||||
_Avoid_: 全难度混排、跨规则版本混排、失败刷分榜
|
||||
|
||||
**后端裁决结果**:
|
||||
后端根据 start run 记录和 finish 派生指标校验后生成的正式单局结果。
|
||||
_Avoid_: 前端最终分数、客户端胜负裁决
|
||||
|
||||
**派生指标**:
|
||||
前端从本地 runtime 汇总出的不可还原原始音频的单局统计值。
|
||||
_Avoid_: 原始音频、可还原语音内容
|
||||
|
||||
**作品统计投影**:
|
||||
按作品聚合的 Bark Battle 游玩开始、完成结果、反作弊标记和最佳/平均表现摘要。
|
||||
_Avoid_: 只从排行榜反推、原始音频分析、留存分析
|
||||
|
||||
**个人历史成绩**:
|
||||
玩家本人可查看的 Bark Battle 最近完成记录和个人最佳摘要。
|
||||
_Avoid_: 公开失败记录、完整无限历史、好友对比
|
||||
|
||||
**正式作品入口闭环**:
|
||||
Bark Battle 作品从创作入口、作品详情、广场/作品卡片、我的作品到正式 runtime 路由的可发现、可进入流程。
|
||||
_Avoid_: 内部试玩入口、独立活动专区
|
||||
|
||||
**轻配置编辑流程**:
|
||||
Bark Battle 创作者用单页轻配置表单和预览卡片完成草稿保存与发布的创作流程。
|
||||
_Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
|
||||
|
||||
**Phase 2 实施顺序**:
|
||||
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
|
||||
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||
|
||||
## Relationships
|
||||
|
||||
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
|
||||
- 每个 **有效声浪触发** 必须达到 **有效阈值** 并满足 **声浪冷却**。
|
||||
- **有效声浪触发** 推动 **能量条**。
|
||||
- **能量条** 在倒计时结束时产生一个 **单局结果**。
|
||||
- **单局结果** 可以被后端记录为派生摘要,但不包含原始麦克风音频。
|
||||
- **Bark Battle 平台作品闭环** 包含发布态作品配置、**正式作品入口闭环**、run start / finish、个人历史成绩、**作品统计投影** 和最小排行榜。
|
||||
- Phase 2 的 Bark Battle 作品是 **轻创作配置作品**,通过 **轻配置编辑流程** 创建;配置范围限制为标题、描述、主题/背景预设、狗狗皮肤预设和排行榜开关,其中 **难度预设** 只影响 AI 对手行为。
|
||||
- **排行榜分榜** 由 `workId + difficultyPreset + rulesetVersion` 唯一确定,只收录 `serverResult = player_win` 的单局结果。
|
||||
- **单局结果** 的正式胜负、分数与排行榜成绩来自 **后端裁决结果**;前端只提交 **派生指标**,客户端结果仅用于 debug/对账。
|
||||
- **作品统计投影** 计入成功 start run 的 playStartCount、后端接受 finish 的 finishCount、胜/平/负、flagged、leaderboard 以及最佳/平均能量表现。
|
||||
- **个人历史成绩** 由最近记录列表和个人最佳摘要组成,只允许本人查看;排行榜只公开入榜胜利成绩。
|
||||
- **正式作品入口闭环** 必须覆盖创作入口、作品详情 CTA、广场/作品卡片、我的作品/个人作品架、稳定作品 ID runtime 路由和 `work_play_start` 埋点。
|
||||
- **Phase 2 实施顺序** 固定为:契约与领域规则 → SpacetimeDB 表/reducer 与 api-server BFF → 最小前端纵切 → 投影与列表体验 → 收口验证。
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** “第二阶段排行榜要按玩家狗叫持续时间排序吗?”
|
||||
> **Domain expert:** “不按持续时间;Bark Battle 的计分输入是有效声浪触发,排行榜只能基于触发次数、峰值、能量条结果等派生摘要。”
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- “有效叫声”曾同时指代持续时长合规的声音片段和瞬时响度触发;已解析为 **有效声浪触发**,不再要求 `minBarkDurationMs` / `maxBarkDurationMs`,也不等待响度回落。
|
||||
- “第二阶段”曾可能指玩法表现深化或平台接入;已解析为 **Bark Battle 平台作品闭环**,优先补正式 play type、作品配置、发布、正式 runtime、结果持久化、历史成绩、作品统计和最小排行榜。
|
||||
- “创作者可配置作品”曾可能指完整规则编辑器;已解析为 **轻创作配置作品**,Phase 2 不允许创作者直接配置单局时长、有效阈值、声浪冷却、AI 细粒度参数、分数公式或反作弊阈值。
|
||||
- “难度预设”曾可能影响阈值、冷却或计分;已解析为只影响 AI 对手行为,排行榜按 `workId + difficultyPreset + rulesetVersion` 分榜。
|
||||
- “单局结果”曾可能由前端直接决定;已解析为必须由 **后端裁决结果** 决定,前端只提交触发次数、音量、能量、连击、时长等 **派生指标**。
|
||||
- “排行榜成绩”曾可能收录胜/平/负或按触发次数排序;已解析为只收录玩家胜利局,并以 `finalEnergy` 优先、`triggerCount` / `maxVolume` / 标准局时长接近度 / `finishedAt` 作为后续排序因子。
|
||||
- “作品统计”曾可能只从排行榜反推;已解析为独立 **作品统计投影**,失败、平局和 flagged finish 都可进入统计,但 rejected finish 不进入完成统计。
|
||||
- “个人历史成绩”曾可能指完整无限历史或公开记录;已解析为仅本人可见的最近记录列表 + 个人最佳摘要,不公开失败、平局或 flagged 历史。
|
||||
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
|
||||
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
|
||||
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- 需求来源:用户提供的视频 `C:\Users\DSK\Videos\一款双方比狗叫的游戏 - 1.一款双方比狗叫的游戏(Av116504192360177,P1).mp4`,并已在 `.hermes/plans/2026-05-11_144229-bark-battle-2d-game-bdd-ddd-tdd-plan.md` 中完成抽帧分析和玩法方案整理。
|
||||
- 玩法定位:浏览器 2D 声控狗叫对战小游戏,暂定中文名 `汪汪声浪大作战`,英文代号与 play type ID 建议为 `bark-battle`。
|
||||
- 核心玩法:双方狗狗在 30 秒限时内通过麦克风输入“狗叫声”进行声浪拔河;系统依据声音强度、有效叫声次数和叫声节奏计算推动力,实时推动顶部红蓝能量条;倒计时结束后按能量条位置判定胜负或平局。
|
||||
- 核心玩法:双方狗狗在 30 秒限时内通过麦克风输入“狗叫声”进行声浪拔河;系统依据声音强度、有效声浪触发次数和声浪节奏计算推动力,实时推动顶部红蓝能量条;倒计时结束后按能量条位置判定胜负或平局。
|
||||
- 文档目的:为产品、测试、前端、后端在编码前统一可验证验收口径;本文只定义 PRD/BDD 级行为与测试映射,不实现工程代码。
|
||||
|
||||
## 角色与目标
|
||||
@@ -20,14 +20,14 @@
|
||||
### 用户目标
|
||||
|
||||
- 玩家可以在开局前完成麦克风授权和环境噪音校准。
|
||||
- 玩家发出有效狗叫时,能看到叫声计数、狗狗动画、拟声词/冲击波以及能量条变化。
|
||||
- 低于阈值的背景噪音不会被误计为有效叫声。
|
||||
- 玩家产生有效声浪触发时,能看到声浪计数、狗狗动画、拟声词/冲击波以及能量条变化。
|
||||
- 低于阈值的背景噪音不会被误计为有效声浪触发。
|
||||
- 单局在 30 秒后给出明确胜负、平局和关键数据。
|
||||
- 移动端和不支持麦克风的环境不会进入不可操作状态。
|
||||
|
||||
### 非目标
|
||||
|
||||
- MVP 不要求识别“是否真实狗叫”,不引入机器学习声纹/物种分类;有效输入以音量阈值、峰值间隔、持续时间和校准结果为准。
|
||||
- MVP 不要求识别“是否真实狗叫”,不引入机器学习声纹/物种分类;有效输入以音量阈值、峰值冷却间隔和校准结果为准。
|
||||
- MVP 不要求实时联机对战;可先按“玩家 vs AI 对手”完成单机浏览器 runtime。
|
||||
- MVP 不要求成绩持久化、作品发布、作品架、广场和排行榜;若后续接入 Genarrative 作品闭环,需要另补玩法类型集成 PRD/技术文档。
|
||||
- MVP 不要求在 UI 中长期展示大段规则说明;游戏界面应保持倒计时、能量条、狗狗、麦克风状态和结算信息为主。
|
||||
@@ -38,10 +38,10 @@
|
||||
- 单局时长:默认 30 秒,从正式进入 `playing` 阶段开始计时。
|
||||
- 能量条:使用 `-100` 到 `100` 的连续值表示,负数偏对手侧,正数偏玩家侧,`0` 为中线。
|
||||
- 平局阈值:倒计时结束时,若能量条绝对值小于或等于 `drawThreshold`,判定平局;具体数值由实现配置,但测试需可注入固定阈值。
|
||||
- 有效叫声:一次有效叫声至少满足:音量超过校准后的有效阈值、与上一次有效峰值间隔不小于 `minBarkGapMs`、持续时长在 `minBarkDurationMs` 到 `maxBarkDurationMs` 之间。
|
||||
- 背景噪音:校准阶段采集到的环境声用于计算动态阈值;低于阈值的输入不得增加叫声次数,也不得让能量条出现可见推进。
|
||||
- 推动力:玩家推动力由音量分数、有效叫声频率和连击加成组成;能量条按玩家推动力与对手推动力差值移动,并被限制在 `-100` 到 `100`。
|
||||
- UI 反馈:有效叫声应触发可观察反馈,包括玩家侧狗狗张嘴/吠叫动画、拟声词或冲击波;反馈不应遮挡倒计时和顶部能量条。
|
||||
- 有效声浪触发:一次有效声浪触发满足:当前麦克风采样点的归一化响度达到或超过校准后的有效阈值,且与上一次有效声浪触发间隔不小于 `minBarkGapMs`;不再要求持续高响度时长达标,也不等待响度回落。
|
||||
- 背景噪音:校准阶段采集到的环境声用于计算动态阈值;低于阈值的输入不得增加声浪触发次数,也不得让能量条出现可见推进。
|
||||
- 推动力:玩家推动力由音量分数、有效声浪触发频率和连击加成组成;能量条按玩家推动力与对手推动力差值移动,并被限制在 `-100` 到 `100`。
|
||||
- UI 反馈:有效声浪触发应触发可观察反馈,包括玩家侧狗狗张嘴/吠叫动画、拟声词或冲击波;反馈不应遮挡倒计时和顶部能量条。
|
||||
|
||||
## 中文 Gherkin 场景
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
场景: 校准完成后进入开局倒计时
|
||||
假如玩家已允许麦克风权限
|
||||
而且系统已采集足够的环境噪音样本
|
||||
当校准计算出有效叫声阈值
|
||||
当校准计算出有效声浪阈值
|
||||
那么系统应进入开局倒计时阶段
|
||||
而且倒计时结束后应进入 30 秒对战阶段
|
||||
而且初始能量条应位于中线
|
||||
@@ -84,19 +84,19 @@
|
||||
功能: 环境噪音校准
|
||||
为了减少背景噪音误触发
|
||||
作为浏览器玩家
|
||||
我希望游戏在开局前根据当前环境设置有效叫声阈值
|
||||
我希望游戏在开局前根据当前环境设置有效声浪阈值
|
||||
|
||||
场景: 安静环境生成低但非零的有效阈值
|
||||
假如校准阶段采集到的环境噪音 RMS 稳定低于默认噪音基线
|
||||
当系统完成校准
|
||||
那么有效叫声阈值应高于环境噪音平均值
|
||||
那么有效声浪阈值应高于环境噪音平均值
|
||||
而且阈值不应低于系统配置的最小阈值
|
||||
|
||||
场景: 嘈杂环境生成更高的有效阈值
|
||||
假如校准阶段采集到的环境噪音 RMS 高于默认噪音基线
|
||||
当系统完成校准
|
||||
那么有效叫声阈值应随环境噪音上调
|
||||
而且低于该阈值的后续输入不应计为有效叫声
|
||||
那么有效声浪阈值应随环境噪音上调
|
||||
而且低于该阈值的后续输入不应计为有效声浪触发
|
||||
|
||||
场景: 校准期间无法获得有效音频样本
|
||||
假如麦克风授权成功但音频样本持续为空或不可读
|
||||
@@ -106,36 +106,36 @@
|
||||
而且不应直接开始对战
|
||||
```
|
||||
|
||||
### 功能: 有效叫声计数
|
||||
### 功能: 有效声浪触发计数
|
||||
|
||||
```gherkin
|
||||
功能: 有效叫声计数
|
||||
为了把玩家的狗叫行为转换为可计分输入
|
||||
功能: 有效声浪触发计数
|
||||
为了把玩家的声控行为转换为可计分输入
|
||||
作为玩家
|
||||
我希望每次符合规则的短促叫声只被计数一次
|
||||
我希望每次超过阈值且满足冷却的声浪触发只被计数一次
|
||||
|
||||
背景:
|
||||
假如游戏处于 30 秒 playing 阶段
|
||||
而且系统已完成环境噪音校准
|
||||
|
||||
场景: 单次超过阈值且间隔足够的叫声计数加一
|
||||
假如玩家当前叫声次数为 0
|
||||
而且上一次有效叫声时间早于 minBarkGapMs
|
||||
当麦克风输入出现一次超过有效阈值且持续时长合规的峰值
|
||||
那么玩家叫声次数应变为 1
|
||||
场景: 单次超过阈值且间隔足够的声浪触发计数加一
|
||||
假如玩家当前声浪触发次数为 0
|
||||
而且上一次有效声浪触发时间早于 minBarkGapMs
|
||||
当某个麦克风采样点达到或超过有效阈值且满足声浪冷却
|
||||
那么玩家声浪触发次数应变为 1
|
||||
而且玩家侧应出现一次吠叫动画反馈
|
||||
而且画面应出现一次拟声词或冲击波反馈
|
||||
|
||||
场景: 持续噪音不会被无限计数
|
||||
假如玩家当前叫声次数为 1
|
||||
当麦克风输入持续超过阈值但没有新的峰值间隔
|
||||
那么玩家叫声次数不应在每个 tick 中持续增加
|
||||
而且系统最多只应记录当前连续声音段内的一次有效叫声
|
||||
场景: 持续高响度输入只按冷却节奏计数
|
||||
假如玩家当前声浪触发次数为 1
|
||||
当麦克风输入持续超过阈值但仍处于声浪冷却内
|
||||
那么玩家声浪触发次数不应在每个 tick 中持续增加
|
||||
而且系统只应在冷却结束后的采样点再次达阈值时记录下一次有效声浪触发
|
||||
|
||||
场景: 间隔过短的连续峰值不重复计数
|
||||
假如玩家刚刚产生一次有效叫声
|
||||
当麦克风输入在 minBarkGapMs 内再次出现峰值
|
||||
那么玩家叫声次数不应增加
|
||||
假如玩家刚刚产生一次有效声浪触发
|
||||
当麦克风输入在 minBarkGapMs 内再次达到有效阈值
|
||||
那么玩家声浪触发次数不应增加
|
||||
而且连击或推动力不应因该峰值重复加成
|
||||
```
|
||||
|
||||
@@ -145,23 +145,23 @@
|
||||
功能: 声浪推动能量条
|
||||
为了复刻双方比狗叫的核心体验
|
||||
作为玩家
|
||||
我希望更响、更连续的有效叫声能把顶部能量条推向自己一侧
|
||||
我希望更响、更高频的有效声浪触发能把顶部能量条推向自己一侧
|
||||
|
||||
背景:
|
||||
假如游戏处于 30 秒 playing 阶段
|
||||
而且能量条当前位于中线
|
||||
|
||||
场景: 玩家推动力高于对手时能量条向玩家侧移动
|
||||
假如玩家在短时间窗口内产生多次有效叫声
|
||||
假如玩家在短时间窗口内产生多次有效声浪触发
|
||||
而且玩家推动力高于对手推动力
|
||||
当系统推进一个 simulation tick
|
||||
那么能量条数值应向玩家侧增加
|
||||
而且顶部红蓝能量条的玩家侧占比应变大
|
||||
|
||||
场景: 连续大声叫声触发更强反馈
|
||||
假如玩家连续产生多次高于强叫声阈值的有效叫声
|
||||
场景: 连续强声浪触发触发更强反馈
|
||||
假如玩家连续产生多次高于强声浪阈值的有效声浪触发
|
||||
当系统计算玩家连击加成
|
||||
那么玩家侧推动力应高于单次普通叫声推动力
|
||||
那么玩家侧推动力应高于单次普通声浪触发推动力
|
||||
而且玩家侧声浪或冲击波反馈应比普通叫声更明显
|
||||
但是反馈不应遮挡倒计时和能量条
|
||||
|
||||
@@ -185,31 +185,30 @@
|
||||
功能: 背景噪音过滤
|
||||
为了避免环境声替玩家自动得分
|
||||
作为玩家
|
||||
我希望低于阈值或不合规的声音不会被当作有效狗叫
|
||||
我希望低于阈值或处于冷却内的声音不会被当作有效声浪触发
|
||||
|
||||
背景:
|
||||
假如游戏处于 30 秒 playing 阶段
|
||||
而且系统已完成环境噪音校准
|
||||
|
||||
场景: 低于阈值的背景噪音不计数
|
||||
当麦克风只接收到低于有效叫声阈值的背景噪音
|
||||
那么玩家叫声次数不应增加
|
||||
当麦克风只接收到低于有效声浪阈值的背景噪音
|
||||
那么玩家声浪触发次数不应增加
|
||||
而且玩家侧不应播放吠叫动画
|
||||
而且能量条不应因为该背景噪音出现可见推进
|
||||
|
||||
场景: 过短脉冲不计为有效叫声
|
||||
假如麦克风输入峰值超过有效阈值
|
||||
但是持续时长短于 minBarkDurationMs
|
||||
当系统完成该声音段判定
|
||||
那么玩家叫声次数不应增加
|
||||
场景: 冷却内重复达阈值不计数
|
||||
假如玩家刚刚产生一次有效声浪触发
|
||||
当麦克风输入在 minBarkGapMs 内再次达到有效声浪阈值
|
||||
那么玩家声浪触发次数不应增加
|
||||
而且不应触发连击加成
|
||||
|
||||
场景: 过长持续声被削弱为单段输入
|
||||
假如麦克风输入持续超过有效阈值
|
||||
但是持续时长长于 maxBarkDurationMs
|
||||
当系统完成该声音段判定
|
||||
那么系统不应按多个叫声重复计数
|
||||
而且该声音段的推动力应按持续噪音削弱规则处理
|
||||
场景: 持续高响度输入只按冷却节奏产生触发
|
||||
假如麦克风输入持续达到或超过有效声浪阈值
|
||||
当声浪冷却尚未结束
|
||||
那么系统不应在每个 tick 中重复计数
|
||||
当声浪冷却结束且当前采样仍达到有效声浪阈值
|
||||
那么系统可以记录下一次有效声浪触发
|
||||
```
|
||||
|
||||
### 功能: 倒计时与胜负结算
|
||||
@@ -227,20 +226,20 @@
|
||||
当系统时间从 30 秒推进到 0 秒
|
||||
那么界面应显示倒计时归零
|
||||
而且系统应进入 finished 结算阶段
|
||||
而且归零后的麦克风输入不应再改变本局能量条和叫声次数
|
||||
而且归零后的麦克风输入不应再改变本局能量条和声浪触发次数
|
||||
|
||||
场景: 玩家侧占优时判定玩家胜利
|
||||
假如倒计时归零时能量条数值大于 drawThreshold
|
||||
当系统进入结算阶段
|
||||
那么系统应判定玩家胜利
|
||||
而且结算面板应展示玩家叫声次数、最大音量和声浪评分
|
||||
而且结算面板应展示玩家声浪触发次数、最大音量和声浪评分
|
||||
而且应提供再来一局入口
|
||||
|
||||
场景: 对手侧占优时判定玩家失败
|
||||
假如倒计时归零时能量条数值小于 -drawThreshold
|
||||
当系统进入结算阶段
|
||||
那么系统应判定对手胜利
|
||||
而且结算面板应展示玩家叫声次数、最大音量和声浪评分
|
||||
而且结算面板应展示玩家声浪触发次数、最大音量和声浪评分
|
||||
而且应提供再来一局入口
|
||||
|
||||
场景: 能量条接近平衡时判定平局
|
||||
@@ -265,7 +264,7 @@
|
||||
当玩家选择再来一局
|
||||
那么系统应重置剩余时间为 30 秒
|
||||
而且能量条应回到中线
|
||||
而且玩家叫声次数、最大音量、连击和胜负结果应清零
|
||||
而且玩家声浪触发次数、最大音量、连击和胜负结果应清零
|
||||
而且系统应重新进入校准或开局倒计时流程
|
||||
|
||||
场景: 结算后返回玩法入口
|
||||
@@ -353,7 +352,7 @@
|
||||
假如玩家在 playing 阶段刷新页面
|
||||
当页面重新加载 bark-battle
|
||||
那么系统应重新进入权限检查或授权准备状态
|
||||
而且不应沿用刷新前的剩余时间、能量条和叫声次数作为新局结果
|
||||
而且不应沿用刷新前的剩余时间、能量条和声浪触发次数作为新局结果
|
||||
```
|
||||
|
||||
## 测试映射
|
||||
@@ -367,15 +366,15 @@
|
||||
| 嘈杂环境生成更高的有效阈值 | unit | `src/games/bark-battle/domain/BarkNoiseCalibration.test.ts` | planned |
|
||||
| 校准期间无法获得有效音频样本 | application/component | `src/games/bark-battle/application/BarkBattleController.test.ts`, `src/games/bark-battle/ui/BarkBattlePermissionPanel.test.tsx` | planned |
|
||||
| 单次超过阈值且间隔足够的叫声计数加一 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||
| 持续噪音不会被无限计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||
| 持续高响度输入只按冷却节奏计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||
| 间隔过短的连续峰值不重复计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||
| 玩家推动力高于对手时能量条向玩家侧移动 | unit | `src/games/bark-battle/domain/EnergyTugOfWar.test.ts` | planned |
|
||||
| 连续大声叫声触发更强反馈 | unit/integration/component | `src/games/bark-battle/domain/BarkBattleScoring.test.ts`, `src/games/bark-battle/ui/BarkBattleHud.test.tsx` | planned |
|
||||
| 连续强声浪触发触发更强反馈 | unit/integration/component | `src/games/bark-battle/domain/BarkBattleScoring.test.ts`, `src/games/bark-battle/ui/BarkBattleHud.test.tsx` | planned |
|
||||
| 能量条到达边界后不会越界 | unit | `src/games/bark-battle/domain/EnergyTugOfWar.test.ts` | planned |
|
||||
| 对手推动力高于玩家时能量条向对手侧移动 | unit | `src/games/bark-battle/domain/EnergyTugOfWar.test.ts` | planned |
|
||||
| 低于阈值的背景噪音不计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||
| 过短脉冲不计为有效叫声 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||
| 过长持续声被削弱为单段输入 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||
| 冷却内重复达阈值不计数 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||
| 持续高响度输入只按冷却节奏产生触发 | unit | `src/games/bark-battle/domain/BarkDetector.test.ts` | planned |
|
||||
| 倒计时每秒递减并在归零时停止对战输入 | unit/application | `src/games/bark-battle/domain/BarkBattleSession.test.ts`, `src/games/bark-battle/application/BarkBattleController.test.ts` | planned |
|
||||
| 玩家侧占优时判定玩家胜利 | unit/component | `src/games/bark-battle/domain/BarkBattleSession.test.ts`, `src/games/bark-battle/ui/BarkBattleResultPanel.test.tsx` | planned |
|
||||
| 对手侧占优时判定玩家失败 | unit/component | `src/games/bark-battle/domain/BarkBattleSession.test.ts`, `src/games/bark-battle/ui/BarkBattleResultPanel.test.tsx` | planned |
|
||||
@@ -394,8 +393,8 @@
|
||||
## 验收清单
|
||||
|
||||
- [ ] 权限允许、拒绝、非安全上下文、API 不支持、麦克风未找到/不可读、AudioContext 被拦截、校准超时或样本不可读均有明确状态,且不会误进入 playing。
|
||||
- [ ] 校准阶段会影响有效叫声阈值,低噪音不会增加叫声计数。
|
||||
- [ ] 有效叫声计数具备阈值、峰值间隔、持续时长约束。
|
||||
- [ ] 校准阶段会影响有效声浪阈值,低噪音不会增加叫声计数。
|
||||
- [ ] 有效声浪触发计数具备阈值与声浪冷却约束。
|
||||
- [ ] 能量条根据双方推动力差值双向移动,并限制在 `-100` 到 `100`。
|
||||
- [ ] 30 秒归零后停止本局输入影响,并按玩家胜利、对手胜利、平局三类结果结算。
|
||||
- [ ] 移动端核心元素可见,非关键设置收起,不在主画面堆叠长规则说明。
|
||||
@@ -405,8 +404,8 @@
|
||||
## 开放问题
|
||||
|
||||
1. MVP 是否确认只做“玩家 vs AI”,还是第一版需要双人同屏或联机对战?
|
||||
2. `drawThreshold`、`minBarkGapMs`、`minBarkDurationMs`、`maxBarkDurationMs` 的首版默认值由产品/调参阶段确认,还是先采用开发可配置默认值?
|
||||
2. `drawThreshold`、`minBarkGapMs`、有效声浪阈值 的首版默认值由产品/调参阶段确认,还是先采用开发可配置默认值?
|
||||
3. 是否允许无麦克风设备提供键盘/点击备用输入?若允许,需要另补非声控模式场景;若不允许,当前降级只提供返回入口。
|
||||
4. 是否需要在结算中记录或上报成绩、最高音量、叫声次数和声浪评分?若需要,需补埋点/后端持久化场景。
|
||||
4. 是否需要在结算中记录或上报成绩、最高音量、声浪触发次数和声浪评分?若需要,需补埋点/后端持久化场景。
|
||||
5. bark-battle 是否作为 Genarrative 正式 play type 接入创作入口、作品发布和广场,还是先作为独立 runtime 原型验证?
|
||||
6. 狗狗、背景、拟声词和冲击波素材来源是临时占位、AI 生成,还是复用项目现有素材管线?
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
`bark-battle` / “汪汪声浪大作战”是一个浏览器 2D 声控狗叫对战玩法。玩家通过麦克风发出狗叫声,浏览器 runtime 根据音量峰值、有效叫声次数与节奏推动顶部红蓝能量条;每局默认 30 秒;结束后按能量条偏向判定胜负或平局。
|
||||
`bark-battle` / “汪汪声浪大作战”是一个浏览器 2D 声控狗叫对战玩法。玩家通过麦克风发出狗叫声,浏览器 runtime 根据音量峰值、有效声浪触发次数与节奏推动顶部红蓝能量条;每局默认 30 秒;结束后按能量条偏向判定胜负或平局。
|
||||
|
||||
现有前端方案 `docs/technical/BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md` 已覆盖 Phaser / TypeScript / Vite / Web Audio / DOM HUD 的 runtime 落地方式,并明确不覆盖后端表结构、成绩持久化、作品发布、广场接入与实时多人协议。因此需要单独补充后端 DDD 技术方案,避免前端 runtime 在接入平台作品、正式游玩埋点、成绩、排行榜和发布闭环时承接不属于表现层的业务真相。
|
||||
|
||||
@@ -44,26 +44,80 @@ MVP 明确不做:
|
||||
|
||||
## 2. 玩法接入级别建议
|
||||
|
||||
### 2.1 推荐首版闭环
|
||||
### 2.1 第二阶段范围:平台作品闭环
|
||||
|
||||
建议先支持“本地 runtime + 可发布配置化作品 + 单局结果记录 / 可选排行榜”的闭环:
|
||||
第二阶段已明确为“Bark Battle 平台作品闭环”,不是单纯玩法表现深化。目标是让 bark-battle 成为 Genarrative 的正式 play type,并完成从轻创作配置、发布、正式 runtime、run start / finish、单局结果持久化、个人历史成绩、作品统计到最小排行榜的闭环。
|
||||
|
||||
1. 创作者创建 bark-battle 草稿,配置标题、描述、狗狗主题、背景、难度、单局时长、音量阈值、AI 对手参数和排行榜开关。
|
||||
Phase 2 的作品配置边界是“轻创作配置作品”:创作者可以配置标题、描述、主题/背景预设、狗狗皮肤预设、难度预设和排行榜开关;不得直接配置单局时长、有效声浪阈值、`minBarkGapMs`、AI 对手细粒度参数、分数公式或反作弊阈值。难度预设只影响 AI 对手行为强度,不影响有效阈值、声浪冷却、单局时长或分数公式;排行榜必须按 `workId + difficultyPreset + rulesetVersion` 分榜,避免不同难度和不同规则版本混排。
|
||||
|
||||
建议先支持“本地 runtime + 可发布配置化作品 + 单局结果记录 + 个人历史成绩 / 作品统计 / 最小排行榜”的闭环:
|
||||
|
||||
1. 创作者从玩法选择进入 bark-battle 后创建草稿,通过单页轻配置表单 + 预览卡片配置标题、描述、主题/背景预设、狗狗皮肤预设、难度预设和排行榜开关。
|
||||
2. 发布为稳定作品 ID,`playTypeId = "bark-battle"`。
|
||||
3. 玩家从作品页或广场进入 runtime,前端获取发布态 runtime config。
|
||||
3. 玩家可从作品详情页 CTA、广场/作品卡片、我的作品/个人作品架进入正式 runtime,前端使用稳定作品 ID 获取发布态 runtime config。
|
||||
4. 玩家授权麦克风后在本地完成 30 秒声控对战。
|
||||
5. 前端提交单局 finish 请求,只上传派生指标,例如峰值、有效叫声次数、节奏命中、最终能量、客户端结果摘要等。
|
||||
6. 后端校验 work、config version、run token、时长、分数范围和权限后,生成服务端认可的 run result / score summary。
|
||||
7. 若作品开启排行榜,则写入可投影的 leaderboard 记录。
|
||||
8. 正式作品级游玩埋点统一写 `work_play_start`,其中 `scope_kind=work`,`scope_id=稳定作品 ID`,metadata 包含 `playType`、`workId`、`sourceRoute`、`userId`。
|
||||
5. 前端提交单局 finish 请求,只上传派生指标,例如峰值、有效声浪触发次数、节奏命中、最终能量、客户端结果摘要等。
|
||||
6. 后端校验 work、config version、ruleset version、difficulty preset、run token、时长、派生指标范围和权限后,生成服务端裁决的 run result / score summary。
|
||||
7. 写入个人历史成绩与最小作品统计投影。
|
||||
8. 若作品开启排行榜且后端裁决 `serverResult = player_win`,则写入可投影的 leaderboard 记录;排行榜首版只做最小排序与展示,不引入赛季、段位或复杂反作弊,并按 `workId + difficultyPreset + rulesetVersion` 分榜。
|
||||
9. 正式作品级游玩埋点统一写 `work_play_start`,其中 `scope_kind=work`,`scope_id=稳定作品 ID`,metadata 包含 `playType`、`workId`、`sourceRoute`、`userId`。
|
||||
|
||||
|
||||
### 2.2.1 难度预设与排行榜分榜
|
||||
|
||||
Phase 2 只允许三个难度预设:`easy`、`normal`、`hard`。难度预设只能影响 AI 对手推动力曲线和 AI 声浪节奏;不得影响单局时长、有效声浪阈值、`minBarkGapMs`、分数公式或反作弊阈值。排行榜记录和查询必须带上 `difficultyPreset` 与 `rulesetVersion`,以 `workId + difficultyPreset + rulesetVersion` 作为分榜维度。
|
||||
|
||||
### 2.2.2 单局结果后端裁决
|
||||
|
||||
Phase 2 不信任前端提交的最终胜负和正式分数。前端 `finish` 只提交不可还原原始音频的派生指标:`runId`、`workId`、`configVersion`、`rulesetVersion`、`difficultyPreset`、`clientStartedAt`、`clientFinishedAt`、`durationMs`、`triggerCount`、`maxVolume`、`averageVolume`、`finalEnergy`、`comboMax`、`clientResult`,以及可选的 `sampleDigest`。其中 `clientResult` 只用于 debug/对账,不进入正式结果或排行榜。
|
||||
|
||||
后端必须校验 run 由 start 创建且未 finish、run token 匹配、work/config/ruleset/difficulty 与 start 时一致、duration 处于合理窗口、triggerCount 不超过 `durationMs / minBarkGapMs + tolerance`、音量/能量/连击字段在合法范围内。后端生成 `serverResult`、`scoreSummary`、`leaderboardScore` 和 `antiCheatFlags`,排行榜只使用后端裁决后的胜利局成绩。
|
||||
|
||||
### 2.2.3 排行榜排序口径
|
||||
|
||||
Phase 2 排行榜只收录 `serverResult = player_win` 且未被反作弊规则拒绝的单局结果;平局和失败仍进入个人历史成绩与作品统计,但不进入排行榜。`leaderboardScore` 由后端规则集生成,排序优先级为:`finalEnergy` 降序、`triggerCount` 降序、`maxVolume` 降序、`durationMs` 越接近标准局时长越优、`finishedAt` 越早越优。
|
||||
|
||||
### 2.2.4 作品统计投影口径
|
||||
|
||||
Phase 2 的作品统计是最小后端投影,不从排行榜反推。`playStartCount` 在 start run 成功时计入一次,并对齐 `work_play_start` 埋点;`finishCount` 在 finish 被后端接受时计入一次,包含胜利、平局和失败。`accepted_with_flags` 可以计入 `finishCount`,但必须同时计入 `flaggedCount`;未 start 成功、run token 不合法、重复 finish、被后端 rejected 的结果不计入 `finishCount`。
|
||||
|
||||
作品统计字段首版包含:`playStartCount`、`finishCount`、`winCount`、`drawCount`、`lossCount`、`flaggedCount`、`leaderboardEntryCount`、`bestLeaderboardScore`、`bestFinalEnergy`、`averageFinalEnergy`、`updatedAt`。Phase 2 不做 DAU/留存、按小时曲线、原始音频分析或每玩家每天聚合统计。
|
||||
|
||||
### 2.2.5 个人历史成绩口径
|
||||
|
||||
Phase 2 的个人历史成绩由“最近记录列表 + 个人最佳摘要”组成,并且只允许本人查询。后端可以保存每次被接受的 finish 记录,但首版查询接口只暴露默认最近 20 条记录,可按 `workId` 和 `difficultyPreset` 过滤;最近记录包含胜利、平局、失败和是否 flagged,但不展示详细反作弊原因。
|
||||
|
||||
个人最佳摘要按 `userId + workId + difficultyPreset + rulesetVersion` 聚合,字段包含 `bestLeaderboardScore`、`bestFinalEnergy`、`bestTriggerCount`、`bestMaxVolume`、`winCount`、`finishCount`、`lastPlayedAt`。失败、平局和 flagged 历史不对其他玩家公开;排行榜只展示公开入榜的胜利成绩。Phase 2 不做无限滚动完整历史、每日/每周曲线、好友对比或普通玩家可见的详细反作弊说明。
|
||||
|
||||
### 2.2.6 正式作品入口闭环
|
||||
|
||||
Phase 2 必须接入 Bark Battle 正式作品入口闭环,但不新增独立专区、活动页、挑战分享页、好友邀请或多人房间入口。入口范围包括:创作入口/玩法选择中出现 `bark-battle`,进入单页轻配置表单 + 预览卡片;作品详情页 CTA 点击“开始游玩”进入正式 runtime;广场/作品卡片可以展示、打开详情并开始游玩;我的作品/个人作品架能看到作者发布的 Bark Battle 作品;runtime 路由使用稳定作品 ID 并从后端发布态 config 拉取配置。
|
||||
|
||||
正式 run start 成功后必须写 `work_play_start`,其中 `scope_kind=work`、`scope_id=稳定作品 ID`,metadata 至少包含 `playType=bark-battle`、`workId`、`sourceRoute`、`userId`。内部试玩入口可以作为开发调试保留,但不得作为 Phase 2 正式入口。
|
||||
|
||||
### 2.2.7 轻配置编辑流程
|
||||
|
||||
Phase 2 的创作编辑形态是“单页轻配置表单 + 预览卡片”,不是多步骤向导、拖拽编辑器或完整规则编辑器。表单字段包含:标题(必填)、简介(选填)、主题/背景预设(必填枚举)、狗狗皮肤预设(必填枚举)、难度预设(必填,默认 `normal`)、排行榜开关(默认开启)。
|
||||
|
||||
交互流程:创作者从玩法选择进入后生成草稿;在同一页编辑轻配置并查看预览卡片;支持保存草稿和发布;发布成功后跳转作品详情;可从我的作品再次编辑草稿或基于已发布作品创建新版本。Phase 2 不做 AI 生成配置、多步骤 wizard、规则参数编辑、复杂封面编辑、runtime 内嵌预览或大段玩法说明文案。
|
||||
|
||||
### 2.2 后续增强路径
|
||||
|
||||
后续再考虑多人实时:
|
||||
第二阶段之后再考虑:
|
||||
|
||||
- Phase 2:排行榜、挑战分享、个人历史成绩、作品统计面板。
|
||||
- Phase 2.1:挑战分享、作品统计面板细化、排行榜体验优化。
|
||||
- Phase 3:异步影子对手 / ghost replay,但仍不保存原始音频,只保存低维派生曲线或聚合指标。
|
||||
- Phase 4:实时多人对战协议,需要独立同步模型、房间服务、延迟补偿、断线恢复与更严格反作弊;不应混入 MVP。
|
||||
- Phase 4:实时多人对战协议,需要独立同步模型、房间服务、延迟补偿、断线恢复与更严格反作弊;不应混入第二阶段平台作品闭环。
|
||||
|
||||
## 2.3 Phase 2 技术实施顺序
|
||||
|
||||
Phase 2 按“契约和领域规则先行,然后最小纵切,再扩展投影”的顺序实施,避免前端 mock 堆积、后端孤岛或排行榜 UI 先行。
|
||||
|
||||
1. 契约与领域规则:补 `shared-contracts` DTO、`module-bark-battle` 纯领域规则、`rulesetVersion` / `difficultyPreset` / score adjudication,并先写单测。
|
||||
2. SpacetimeDB 表与 reducer + api-server BFF:落草稿/config/发布态 config、runtime run start / finish、score record、leaderboard entry、work stats projection、personal summary projection、`migration.rs` 与绑定生成。
|
||||
3. 最小前端纵切:接创作入口、单页轻配置表单、发布到稳定 workId、作品详情 CTA、runtime 拉 config、start / finish 串通、结算展示 `serverResult`。
|
||||
4. 投影与列表体验:接排行榜、个人历史最近记录 + 最佳摘要、作品统计、我的作品/广场卡片适配。
|
||||
5. 收口验证:把 BDD 场景落到测试,执行编码检查、后端 `/healthz` + API smoke、前端人工验收路径,并更新 README/文档。
|
||||
|
||||
## 3. DDD 分层设计
|
||||
|
||||
@@ -90,7 +144,7 @@ frontend/runtime
|
||||
- 定义配置版本兼容规则。
|
||||
- 计算提交结果的派生分数区间与胜负判定是否自洽。
|
||||
- 计算 `ScoreSummary`、排行榜排序分数、统计指标。
|
||||
- 定义反作弊基础规则:时长范围、有效叫声次数上限、峰值范围、能量范围、提交窗口、run 状态机。
|
||||
- 定义反作弊基础规则:时长范围、有效声浪触发次数上限、峰值范围、能量范围、提交窗口、run 状态机。
|
||||
|
||||
不职责:
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
BARK_BATTLE_DIFFICULTY_PRESETS,
|
||||
type BarkBattleDraftConfig,
|
||||
type BarkBattleFinishResponse,
|
||||
type BarkBattlePersonalBestSummary,
|
||||
type BarkBattleWorkStats,
|
||||
} from './barkBattle';
|
||||
|
||||
describe('Bark Battle shared contracts', () => {
|
||||
test('default draft config fixture uses normal difficulty and camelCase fields', () => {
|
||||
const draft: BarkBattleDraftConfig = {
|
||||
draftId: 'draft-bark-1',
|
||||
title: '汪汪声浪挑战',
|
||||
description: '轻配置草稿',
|
||||
themePreset: 'city-park',
|
||||
playerDogSkinPreset: 'corgi',
|
||||
opponentDogSkinPreset: 'husky',
|
||||
difficultyPreset: 'normal',
|
||||
leaderboardEnabled: true,
|
||||
updatedAt: '2026-05-13T03:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(BARK_BATTLE_DIFFICULTY_PRESETS).toEqual(['easy', 'normal', 'hard']);
|
||||
expect(draft.difficultyPreset).toBe('normal');
|
||||
expect(Object.keys(draft)).toEqual([
|
||||
'draftId',
|
||||
'title',
|
||||
'description',
|
||||
'themePreset',
|
||||
'playerDogSkinPreset',
|
||||
'opponentDogSkinPreset',
|
||||
'difficultyPreset',
|
||||
'leaderboardEnabled',
|
||||
'updatedAt',
|
||||
]);
|
||||
});
|
||||
|
||||
test('finish accepted player_win fixture exposes backend adjudication result', () => {
|
||||
const response: BarkBattleFinishResponse = {
|
||||
status: 'accepted',
|
||||
runId: 'run-bark-1',
|
||||
workId: 'work-bark-1',
|
||||
configVersion: 3,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
difficultyPreset: 'hard',
|
||||
serverResult: 'player_win',
|
||||
scoreSummary: {
|
||||
finalEnergy: 87,
|
||||
triggerCount: 42,
|
||||
maxVolume: 0.96,
|
||||
averageVolume: 0.61,
|
||||
comboMax: 9,
|
||||
durationMs: 30000,
|
||||
},
|
||||
leaderboardScore: 870429630,
|
||||
antiCheatFlags: [],
|
||||
updatedAt: '2026-05-13T03:00:30.000Z',
|
||||
};
|
||||
|
||||
expect(response.status).toBe('accepted');
|
||||
expect(response.serverResult).toBe('player_win');
|
||||
expect(response.scoreSummary.finalEnergy).toBe(87);
|
||||
expect(response.antiCheatFlags).toEqual([]);
|
||||
});
|
||||
|
||||
test('work stats fixture tracks starts, finishes, result counts, flags and energy summary', () => {
|
||||
const stats: BarkBattleWorkStats = {
|
||||
workId: 'work-bark-1',
|
||||
configVersion: 3,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
difficultyPreset: 'normal',
|
||||
playStartCount: 18,
|
||||
finishCount: 15,
|
||||
winCount: 8,
|
||||
drawCount: 2,
|
||||
lossCount: 5,
|
||||
flaggedCount: 1,
|
||||
leaderboardEntryCount: 7,
|
||||
bestLeaderboardScore: 930389410,
|
||||
bestFinalEnergy: 93,
|
||||
averageFinalEnergy: 41.25,
|
||||
updatedAt: '2026-05-13T04:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(stats.playStartCount).toBe(18);
|
||||
expect(stats.finishCount).toBe(15);
|
||||
expect(stats.winCount + stats.drawCount + stats.lossCount).toBe(15);
|
||||
expect(stats.flaggedCount).toBe(1);
|
||||
expect(stats.bestFinalEnergy).toBeGreaterThan(stats.averageFinalEnergy);
|
||||
});
|
||||
|
||||
test('optional score fields may be omitted instead of serialized as null', () => {
|
||||
const finishWithoutLeaderboard: BarkBattleFinishResponse = {
|
||||
status: 'accepted',
|
||||
runId: 'run-bark-no-rank',
|
||||
workId: 'work-bark-1',
|
||||
configVersion: 3,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
difficultyPreset: 'normal',
|
||||
serverResult: 'draw',
|
||||
scoreSummary: {
|
||||
finalEnergy: 50,
|
||||
triggerCount: 12,
|
||||
maxVolume: 0.7,
|
||||
averageVolume: 0.5,
|
||||
comboMax: 3,
|
||||
durationMs: 30000,
|
||||
},
|
||||
antiCheatFlags: [],
|
||||
updatedAt: '2026-05-13T03:00:30.000Z',
|
||||
};
|
||||
const personalBestWithoutWin: BarkBattlePersonalBestSummary = {
|
||||
workId: 'work-bark-1',
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
difficultyPreset: 'normal',
|
||||
winCount: 0,
|
||||
drawCount: 1,
|
||||
lossCount: 2,
|
||||
finishCount: 3,
|
||||
updatedAt: '2026-05-13T04:00:00.000Z',
|
||||
};
|
||||
|
||||
expect('leaderboardScore' in finishWithoutLeaderboard).toBe(false);
|
||||
expect('bestLeaderboardScore' in personalBestWithoutWin).toBe(false);
|
||||
expect('bestFinalEnergy' in personalBestWithoutWin).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
export const BARK_BATTLE_DIFFICULTY_PRESETS = [
|
||||
'easy',
|
||||
'normal',
|
||||
'hard',
|
||||
] as const;
|
||||
|
||||
export type BarkBattleDifficultyPreset =
|
||||
(typeof BARK_BATTLE_DIFFICULTY_PRESETS)[number];
|
||||
|
||||
export type BarkBattleServerResult = 'player_win' | 'opponent_win' | 'draw';
|
||||
|
||||
export type BarkBattleFinishStatus =
|
||||
| 'accepted'
|
||||
| 'accepted_with_flags'
|
||||
| 'rejected';
|
||||
|
||||
export type BarkBattlePlayTypeId = 'bark-battle';
|
||||
|
||||
export interface BarkBattleConfigEditorPayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
themePreset: string;
|
||||
playerDogSkinPreset: string;
|
||||
opponentDogSkinPreset: string;
|
||||
difficultyPreset: BarkBattleDifficultyPreset;
|
||||
leaderboardEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface BarkBattleDraftCreateRequest extends BarkBattleConfigEditorPayload {}
|
||||
|
||||
export interface BarkBattleWorkPublishRequest {
|
||||
draftId: string;
|
||||
workId?: string;
|
||||
publishedSnapshot?: BarkBattleConfigEditorPayload;
|
||||
}
|
||||
|
||||
export interface BarkBattleDraftConfig extends BarkBattleConfigEditorPayload {
|
||||
draftId: string;
|
||||
workId?: string;
|
||||
configVersion?: number;
|
||||
rulesetVersion?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BarkBattlePublishedConfig {
|
||||
workId: string;
|
||||
draftId?: string | null;
|
||||
configVersion: number;
|
||||
rulesetVersion: string;
|
||||
playTypeId: BarkBattlePlayTypeId;
|
||||
title: string;
|
||||
description?: string;
|
||||
themePreset: string;
|
||||
playerDogSkinPreset: string;
|
||||
opponentDogSkinPreset: string;
|
||||
difficultyPreset: BarkBattleDifficultyPreset;
|
||||
leaderboardEnabled: boolean;
|
||||
updatedAt: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
export interface BarkBattleRuntimeConfig {
|
||||
workId: string;
|
||||
configVersion: number;
|
||||
rulesetVersion: string;
|
||||
playTypeId: BarkBattlePlayTypeId;
|
||||
durationMs: number;
|
||||
energyMin: number;
|
||||
energyMax: number;
|
||||
drawThreshold: number;
|
||||
minBarkGapMs: number;
|
||||
difficultyPreset: BarkBattleDifficultyPreset;
|
||||
themePreset: string;
|
||||
playerDogSkinPreset: string;
|
||||
opponentDogSkinPreset: string;
|
||||
leaderboardEnabled: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BarkBattleRunStartRequest {
|
||||
workId: string;
|
||||
configVersion?: number;
|
||||
sourceRoute?: string;
|
||||
clientRuntimeVersion?: string;
|
||||
}
|
||||
|
||||
export interface BarkBattleRunStartResponse {
|
||||
runId: string;
|
||||
runToken: string;
|
||||
workId: string;
|
||||
configVersion: number;
|
||||
rulesetVersion: string;
|
||||
difficultyPreset: BarkBattleDifficultyPreset;
|
||||
runtimeConfig: BarkBattleRuntimeConfig;
|
||||
serverStartedAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface BarkBattleDerivedMetrics {
|
||||
triggerCount: number;
|
||||
maxVolume: number;
|
||||
averageVolume: number;
|
||||
finalEnergy: number;
|
||||
comboMax: number;
|
||||
}
|
||||
|
||||
export interface BarkBattleRunFinishRequest {
|
||||
runId: string;
|
||||
runToken: string;
|
||||
workId: string;
|
||||
configVersion: number;
|
||||
rulesetVersion: string;
|
||||
difficultyPreset: BarkBattleDifficultyPreset;
|
||||
clientStartedAt: string;
|
||||
clientFinishedAt: string;
|
||||
durationMs: number;
|
||||
derivedMetrics: BarkBattleDerivedMetrics;
|
||||
clientResult?: BarkBattleServerResult;
|
||||
sampleDigest?: string;
|
||||
clientRuntimeVersion?: string;
|
||||
}
|
||||
|
||||
export interface BarkBattleScoreSummary extends BarkBattleDerivedMetrics {
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface BarkBattleFinishResponse {
|
||||
status: BarkBattleFinishStatus;
|
||||
runId: string;
|
||||
workId: string;
|
||||
configVersion: number;
|
||||
rulesetVersion: string;
|
||||
difficultyPreset: BarkBattleDifficultyPreset;
|
||||
serverResult: BarkBattleServerResult;
|
||||
scoreSummary: BarkBattleScoreSummary;
|
||||
leaderboardScore?: number;
|
||||
antiCheatFlags: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BarkBattleLeaderboardEntry {
|
||||
rank: number;
|
||||
runId: string;
|
||||
workId: string;
|
||||
configVersion: number;
|
||||
rulesetVersion: string;
|
||||
difficultyPreset: BarkBattleDifficultyPreset;
|
||||
displayName: string;
|
||||
serverResult: BarkBattleServerResult;
|
||||
scoreSummary: BarkBattleScoreSummary;
|
||||
leaderboardScore: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BarkBattleLeaderboardResponse {
|
||||
workId: string;
|
||||
configVersion?: number;
|
||||
rulesetVersion: string;
|
||||
difficultyPreset: BarkBattleDifficultyPreset;
|
||||
entries: BarkBattleLeaderboardEntry[];
|
||||
viewerBest?: BarkBattleLeaderboardEntry | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BarkBattlePersonalHistoryItem {
|
||||
runId: string;
|
||||
workId: string;
|
||||
configVersion: number;
|
||||
rulesetVersion: string;
|
||||
difficultyPreset: BarkBattleDifficultyPreset;
|
||||
serverResult: BarkBattleServerResult;
|
||||
scoreSummary: BarkBattleScoreSummary;
|
||||
leaderboardScore?: number;
|
||||
antiCheatFlags: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BarkBattlePersonalBestSummary {
|
||||
workId: string;
|
||||
configVersion?: number;
|
||||
rulesetVersion: string;
|
||||
difficultyPreset: BarkBattleDifficultyPreset;
|
||||
bestLeaderboardScore?: number;
|
||||
bestFinalEnergy?: number;
|
||||
bestTriggerCount?: number;
|
||||
bestMaxVolume?: number;
|
||||
winCount: number;
|
||||
drawCount: number;
|
||||
lossCount: number;
|
||||
finishCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BarkBattlePersonalHistoryResponse {
|
||||
workId?: string;
|
||||
difficultyPreset?: BarkBattleDifficultyPreset;
|
||||
items: BarkBattlePersonalHistoryItem[];
|
||||
bestSummary?: BarkBattlePersonalBestSummary | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BarkBattleWorkStats {
|
||||
workId: string;
|
||||
configVersion?: number;
|
||||
rulesetVersion: string;
|
||||
difficultyPreset: BarkBattleDifficultyPreset;
|
||||
playStartCount: number;
|
||||
finishCount: number;
|
||||
winCount: number;
|
||||
drawCount: number;
|
||||
lossCount: number;
|
||||
flaggedCount: number;
|
||||
leaderboardEntryCount: number;
|
||||
bestLeaderboardScore?: number;
|
||||
bestFinalEnergy?: number;
|
||||
averageFinalEnergy?: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export type * from './creationAudio';
|
||||
export type * from './hyper3d';
|
||||
export type * from './puzzleCreativeTemplate';
|
||||
export type * from './visualNovel';
|
||||
export type * from './barkBattle';
|
||||
|
||||
Generated
+11
@@ -90,6 +90,7 @@ dependencies = [
|
||||
"module-ai",
|
||||
"module-assets",
|
||||
"module-auth",
|
||||
"module-bark-battle",
|
||||
"module-big-fish",
|
||||
"module-combat",
|
||||
"module-creative-agent",
|
||||
@@ -1766,6 +1767,14 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-bark-battle"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-big-fish"
|
||||
version = "0.1.0"
|
||||
@@ -3137,6 +3146,7 @@ dependencies = [
|
||||
"log",
|
||||
"module-ai",
|
||||
"module-assets",
|
||||
"module-bark-battle",
|
||||
"module-big-fish",
|
||||
"module-combat",
|
||||
"module-custom-world",
|
||||
@@ -3152,6 +3162,7 @@ dependencies = [
|
||||
"module-story",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
"spacetimedb-lib",
|
||||
|
||||
@@ -11,6 +11,7 @@ members = [
|
||||
"crates/module-ai",
|
||||
"crates/module-assets",
|
||||
"crates/module-auth",
|
||||
"crates/module-bark-battle",
|
||||
"crates/module-big-fish",
|
||||
"crates/module-combat",
|
||||
"crates/module-creative-agent",
|
||||
@@ -50,6 +51,7 @@ license = "UNLICENSED"
|
||||
module-ai = { path = "crates/module-ai", default-features = false }
|
||||
module-assets = { path = "crates/module-assets", default-features = false }
|
||||
module-auth = { path = "crates/module-auth", default-features = false }
|
||||
module-bark-battle = { path = "crates/module-bark-battle", default-features = false }
|
||||
module-big-fish = { path = "crates/module-big-fish", default-features = false }
|
||||
module-combat = { path = "crates/module-combat", default-features = false }
|
||||
module-creative-agent = { path = "crates/module-creative-agent", default-features = false }
|
||||
|
||||
@@ -17,6 +17,7 @@ module-ai = { workspace = true }
|
||||
module-assets = { workspace = true, features = ["server-service"] }
|
||||
module-auth = { workspace = true }
|
||||
module-big-fish = { workspace = true }
|
||||
module-bark-battle = { workspace = true }
|
||||
module-combat = { workspace = true }
|
||||
module-creative-agent = { workspace = true }
|
||||
module-custom-world = { workspace = true }
|
||||
|
||||
@@ -34,6 +34,10 @@ use crate::{
|
||||
auth_me::auth_me,
|
||||
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
|
||||
auth_sessions::{auth_sessions, revoke_auth_session},
|
||||
bark_battle::{
|
||||
create_bark_battle_draft, finish_bark_battle_run, get_bark_battle_run,
|
||||
get_bark_battle_runtime_config, publish_bark_battle_work, start_bark_battle_run,
|
||||
},
|
||||
big_fish::{
|
||||
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
|
||||
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
|
||||
@@ -1064,6 +1068,48 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/bark-battle/drafts",
|
||||
post(create_bark_battle_draft).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/bark-battle/works/publish",
|
||||
post(publish_bark_battle_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/works/{work_id}/config",
|
||||
get(get_bark_battle_runtime_config).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/works/{work_id}/runs",
|
||||
post(start_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/runs/{run_id}",
|
||||
get(get_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/runs/{run_id}/finish",
|
||||
post(finish_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/square-hole/sessions",
|
||||
post(create_square_hole_agent_session).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,9 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
||||
if normalized.starts_with("/api/runtime/match3d") {
|
||||
return Some("match3d");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/bark-battle") {
|
||||
return Some("bark-battle");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/square-hole") {
|
||||
return Some("square-hole");
|
||||
}
|
||||
@@ -149,6 +152,10 @@ mod tests {
|
||||
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
|
||||
Some("visual-novel"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
|
||||
Some("bark-battle"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/creation/edutainment/baby-object-match/assets"),
|
||||
Some("baby-object-match"),
|
||||
|
||||
@@ -13,6 +13,7 @@ mod auth_payload;
|
||||
mod auth_public_user;
|
||||
mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod bark_battle;
|
||||
mod big_fish;
|
||||
mod big_fish_agent_turn;
|
||||
mod big_fish_draft_compiler;
|
||||
|
||||
@@ -3614,9 +3614,8 @@ async fn ensure_match3d_background_music_asset(
|
||||
));
|
||||
};
|
||||
|
||||
let title = require_match3d_background_music_title(plan).map_err(|error| {
|
||||
match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error)
|
||||
})?;
|
||||
let title = require_match3d_background_music_title(plan)
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
let style = normalize_match3d_audio_style(plan.style.as_str());
|
||||
match generate_match3d_background_music_asset(state, owner_user_id, profile_id, &title, &style)
|
||||
.await
|
||||
@@ -6556,12 +6555,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn match3d_background_music_title_is_required_for_auto_draft() {
|
||||
let missing = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
|
||||
title: " ,。 ".to_string(),
|
||||
style: "轻快, 休闲".to_string(),
|
||||
prompt: String::new(),
|
||||
})
|
||||
.expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名");
|
||||
let missing =
|
||||
require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
|
||||
title: " ,。 ".to_string(),
|
||||
style: "轻快, 休闲".to_string(),
|
||||
prompt: String::new(),
|
||||
})
|
||||
.expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名");
|
||||
|
||||
assert!(missing.body_text().contains("背景音乐"));
|
||||
|
||||
|
||||
@@ -3444,10 +3444,12 @@ async fn generate_puzzle_background_music_required(
|
||||
) -> Result<CreationAudioAsset, AppError> {
|
||||
let normalized_title = title.trim();
|
||||
if normalized_title.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成",
|
||||
})));
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成",
|
||||
})),
|
||||
);
|
||||
}
|
||||
generate_background_music_asset_for_creation(
|
||||
state,
|
||||
@@ -3517,11 +3519,13 @@ fn ensure_puzzle_initial_level_assets_ready(
|
||||
missing.push("UI背景图");
|
||||
}
|
||||
|
||||
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("、")),
|
||||
"missingAssets": missing,
|
||||
})))
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("、")),
|
||||
"missingAssets": missing,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn find_puzzle_level_for_initial_asset_check<'a>(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "module-bark-battle"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,162 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const BARK_BATTLE_RULESET_VERSION_V1: &str = "bark-battle-ruleset-v1";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DifficultyPreset {
|
||||
Easy,
|
||||
Normal,
|
||||
Hard,
|
||||
}
|
||||
|
||||
impl DifficultyPreset {
|
||||
pub fn ai_preset_key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Easy => "bark-battle-ai-easy",
|
||||
Self::Normal => "bark-battle-ai-normal",
|
||||
Self::Hard => "bark-battle-ai-hard",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RulesetThresholdsSignature {
|
||||
pub standard_duration_ms: u64,
|
||||
pub min_duration_ms: u64,
|
||||
pub max_duration_ms: u64,
|
||||
pub min_bark_gap_ms: u64,
|
||||
pub trigger_count_tolerance: u32,
|
||||
pub min_volume: f32,
|
||||
pub max_volume: f32,
|
||||
pub min_average_volume: f32,
|
||||
pub max_average_volume: f32,
|
||||
pub min_final_energy: f32,
|
||||
pub max_final_energy: f32,
|
||||
pub min_combo: u32,
|
||||
pub max_combo: u32,
|
||||
pub draw_threshold_energy: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BarkBattleRuleset {
|
||||
pub version: &'static str,
|
||||
pub difficulty: DifficultyPreset,
|
||||
pub ai_preset_key: &'static str,
|
||||
pub standard_duration_ms: u64,
|
||||
pub min_duration_ms: u64,
|
||||
pub max_duration_ms: u64,
|
||||
pub min_bark_gap_ms: u64,
|
||||
pub trigger_count_tolerance: u32,
|
||||
pub min_volume: f32,
|
||||
pub max_volume: f32,
|
||||
pub min_average_volume: f32,
|
||||
pub max_average_volume: f32,
|
||||
pub min_final_energy: f32,
|
||||
pub max_final_energy: f32,
|
||||
pub min_combo: u32,
|
||||
pub max_combo: u32,
|
||||
pub draw_threshold_energy: u32,
|
||||
}
|
||||
|
||||
impl BarkBattleRuleset {
|
||||
pub fn v1() -> Self {
|
||||
Self::for_difficulty(DifficultyPreset::Normal)
|
||||
}
|
||||
|
||||
pub fn for_difficulty(difficulty: DifficultyPreset) -> Self {
|
||||
Self {
|
||||
version: BARK_BATTLE_RULESET_VERSION_V1,
|
||||
difficulty,
|
||||
ai_preset_key: difficulty.ai_preset_key(),
|
||||
standard_duration_ms: 30_000,
|
||||
min_duration_ms: 28_000,
|
||||
max_duration_ms: 35_000,
|
||||
min_bark_gap_ms: 250,
|
||||
trigger_count_tolerance: 2,
|
||||
min_volume: 0.0,
|
||||
max_volume: 1.0,
|
||||
min_average_volume: 0.0,
|
||||
max_average_volume: 1.0,
|
||||
min_final_energy: 0.0,
|
||||
max_final_energy: 100.0,
|
||||
min_combo: 0,
|
||||
max_combo: 999,
|
||||
draw_threshold_energy: 3,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn thresholds_signature(&self) -> RulesetThresholdsSignature {
|
||||
RulesetThresholdsSignature {
|
||||
standard_duration_ms: self.standard_duration_ms,
|
||||
min_duration_ms: self.min_duration_ms,
|
||||
max_duration_ms: self.max_duration_ms,
|
||||
min_bark_gap_ms: self.min_bark_gap_ms,
|
||||
trigger_count_tolerance: self.trigger_count_tolerance,
|
||||
min_volume: self.min_volume,
|
||||
max_volume: self.max_volume,
|
||||
min_average_volume: self.min_average_volume,
|
||||
max_average_volume: self.max_average_volume,
|
||||
min_final_energy: self.min_final_energy,
|
||||
max_final_energy: self.max_final_energy,
|
||||
min_combo: self.min_combo,
|
||||
max_combo: self.max_combo,
|
||||
draw_threshold_energy: self.draw_threshold_energy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BarkBattleFinishMetrics {
|
||||
pub duration_ms: u64,
|
||||
pub trigger_count: u64,
|
||||
/// 归一化音量,合法范围为 0.0..=1.0。
|
||||
pub max_volume: f32,
|
||||
pub average_volume: f32,
|
||||
pub final_energy: f32,
|
||||
pub max_combo: u32,
|
||||
pub finished_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FinishValidationDecision {
|
||||
Accepted,
|
||||
AcceptedWithFlags,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AntiCheatFlag {
|
||||
DurationTooShort,
|
||||
DurationTooLong,
|
||||
TriggerCountTooHigh,
|
||||
MaxVolumeOutOfRange,
|
||||
AverageVolumeOutOfRange,
|
||||
FinalEnergyOutOfRange,
|
||||
MaxComboOutOfRange,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FinishValidation {
|
||||
pub decision: FinishValidationDecision,
|
||||
pub anti_cheat_flags: Vec<AntiCheatFlag>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BattleResult {
|
||||
PlayerWin,
|
||||
OpponentWin,
|
||||
Draw,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BarkBattleLeaderboardScore {
|
||||
pub final_energy_millis: u32,
|
||||
pub trigger_count: u64,
|
||||
pub max_volume_millis: u32,
|
||||
pub duration_closeness_ms: u64,
|
||||
pub finished_at_micros: i64,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod domain;
|
||||
pub mod scoring;
|
||||
|
||||
pub use domain::*;
|
||||
pub use scoring::*;
|
||||
@@ -0,0 +1,316 @@
|
||||
use crate::domain::*;
|
||||
|
||||
pub fn validate_finish_metrics(
|
||||
ruleset: &BarkBattleRuleset,
|
||||
metrics: &BarkBattleFinishMetrics,
|
||||
) -> FinishValidation {
|
||||
let mut flags = Vec::new();
|
||||
let mut rejected = false;
|
||||
|
||||
if metrics.duration_ms < ruleset.min_duration_ms {
|
||||
flags.push(AntiCheatFlag::DurationTooShort);
|
||||
rejected = true;
|
||||
}
|
||||
if metrics.duration_ms > ruleset.max_duration_ms {
|
||||
flags.push(AntiCheatFlag::DurationTooLong);
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
let max_trigger_count =
|
||||
metrics.duration_ms / ruleset.min_bark_gap_ms + u64::from(ruleset.trigger_count_tolerance);
|
||||
if metrics.trigger_count > max_trigger_count {
|
||||
flags.push(AntiCheatFlag::TriggerCountTooHigh);
|
||||
}
|
||||
|
||||
if !is_in_range(metrics.max_volume, ruleset.min_volume, ruleset.max_volume) {
|
||||
flags.push(AntiCheatFlag::MaxVolumeOutOfRange);
|
||||
rejected = true;
|
||||
}
|
||||
if !is_in_range(
|
||||
metrics.average_volume,
|
||||
ruleset.min_average_volume,
|
||||
ruleset.max_average_volume,
|
||||
) {
|
||||
flags.push(AntiCheatFlag::AverageVolumeOutOfRange);
|
||||
rejected = true;
|
||||
}
|
||||
if !is_in_range(
|
||||
metrics.final_energy,
|
||||
ruleset.min_final_energy,
|
||||
ruleset.max_final_energy,
|
||||
) {
|
||||
flags.push(AntiCheatFlag::FinalEnergyOutOfRange);
|
||||
rejected = true;
|
||||
}
|
||||
if metrics.max_combo < ruleset.min_combo || metrics.max_combo > ruleset.max_combo {
|
||||
flags.push(AntiCheatFlag::MaxComboOutOfRange);
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
let decision = if rejected {
|
||||
FinishValidationDecision::Rejected
|
||||
} else if flags.is_empty() {
|
||||
FinishValidationDecision::Accepted
|
||||
} else {
|
||||
FinishValidationDecision::AcceptedWithFlags
|
||||
};
|
||||
|
||||
FinishValidation {
|
||||
decision,
|
||||
anti_cheat_flags: flags,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn adjudicate_result(
|
||||
ruleset: &BarkBattleRuleset,
|
||||
player_final_energy: f32,
|
||||
opponent_final_energy: f32,
|
||||
) -> BattleResult {
|
||||
let delta = player_final_energy - opponent_final_energy;
|
||||
if delta.abs() <= ruleset.draw_threshold_energy as f32 {
|
||||
BattleResult::Draw
|
||||
} else if delta > 0.0 {
|
||||
BattleResult::PlayerWin
|
||||
} else {
|
||||
BattleResult::OpponentWin
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_leaderboard_score(
|
||||
ruleset: &BarkBattleRuleset,
|
||||
metrics: &BarkBattleFinishMetrics,
|
||||
validation: &FinishValidation,
|
||||
result: BattleResult,
|
||||
) -> Option<BarkBattleLeaderboardScore> {
|
||||
if result != BattleResult::PlayerWin
|
||||
|| validation.decision == FinishValidationDecision::Rejected
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(BarkBattleLeaderboardScore {
|
||||
final_energy_millis: to_millis(metrics.final_energy),
|
||||
trigger_count: metrics.trigger_count,
|
||||
max_volume_millis: to_millis(metrics.max_volume),
|
||||
duration_closeness_ms: metrics.duration_ms.abs_diff(ruleset.standard_duration_ms),
|
||||
finished_at_micros: metrics.finished_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_in_range(value: f32, min: f32, max: f32) -> bool {
|
||||
value.is_finite() && value >= min && value <= max
|
||||
}
|
||||
|
||||
fn to_millis(value: f32) -> u32 {
|
||||
(value * 1_000.0).round().clamp(0.0, u32::MAX as f32) as u32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::*;
|
||||
|
||||
fn metrics(duration_ms: u64) -> BarkBattleFinishMetrics {
|
||||
BarkBattleFinishMetrics {
|
||||
duration_ms,
|
||||
trigger_count: 10,
|
||||
max_volume: 0.8,
|
||||
average_volume: 0.6,
|
||||
final_energy: 60.0,
|
||||
max_combo: 5,
|
||||
finished_at_micros: 1_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_uses_contract_snake_case_for_domain_enums() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(DifficultyPreset::Easy).expect("serialize difficulty"),
|
||||
serde_json::json!("easy")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(FinishValidationDecision::AcceptedWithFlags)
|
||||
.expect("serialize decision"),
|
||||
serde_json::json!("accepted_with_flags")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(AntiCheatFlag::AverageVolumeOutOfRange)
|
||||
.expect("serialize anti-cheat flag"),
|
||||
serde_json::json!("average_volume_out_of_range")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(BattleResult::PlayerWin).expect("serialize battle result"),
|
||||
serde_json::json!("player_win")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_duration_inside_28s_to_35s_window() {
|
||||
let ruleset = BarkBattleRuleset::v1();
|
||||
|
||||
assert_eq!(
|
||||
validate_finish_metrics(&ruleset, &metrics(28_000)).decision,
|
||||
FinishValidationDecision::Accepted
|
||||
);
|
||||
assert_eq!(
|
||||
validate_finish_metrics(&ruleset, &metrics(35_000)).decision,
|
||||
FinishValidationDecision::Accepted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_or_flags_extreme_duration() {
|
||||
let ruleset = BarkBattleRuleset::v1();
|
||||
|
||||
assert_ne!(
|
||||
validate_finish_metrics(&ruleset, &metrics(1_000)).decision,
|
||||
FinishValidationDecision::Accepted
|
||||
);
|
||||
assert_ne!(
|
||||
validate_finish_metrics(&ruleset, &metrics(300_000)).decision,
|
||||
FinishValidationDecision::Accepted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_trigger_count_above_physical_limit_with_tolerance() {
|
||||
let ruleset = BarkBattleRuleset::v1();
|
||||
let mut input = metrics(30_000);
|
||||
input.trigger_count = input.duration_ms / ruleset.min_bark_gap_ms
|
||||
+ u64::from(ruleset.trigger_count_tolerance)
|
||||
+ 1;
|
||||
|
||||
let validation = validate_finish_metrics(&ruleset, &input);
|
||||
|
||||
assert_eq!(
|
||||
validation.decision,
|
||||
FinishValidationDecision::AcceptedWithFlags
|
||||
);
|
||||
assert!(
|
||||
validation
|
||||
.anti_cheat_flags
|
||||
.contains(&AntiCheatFlag::TriggerCountTooHigh)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_final_energy_outside_range() {
|
||||
let ruleset = BarkBattleRuleset::v1();
|
||||
let mut input = metrics(30_000);
|
||||
input.final_energy = ruleset.max_final_energy + 0.1;
|
||||
|
||||
let validation = validate_finish_metrics(&ruleset, &input);
|
||||
|
||||
assert_eq!(validation.decision, FinishValidationDecision::Rejected);
|
||||
assert!(
|
||||
validation
|
||||
.anti_cheat_flags
|
||||
.contains(&AntiCheatFlag::FinalEnergyOutOfRange)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaderboard_score_only_for_player_win_and_not_rejected() {
|
||||
let ruleset = BarkBattleRuleset::v1();
|
||||
let input = metrics(30_000);
|
||||
let validation = validate_finish_metrics(&ruleset, &input);
|
||||
|
||||
assert!(
|
||||
compute_leaderboard_score(&ruleset, &input, &validation, BattleResult::PlayerWin)
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
compute_leaderboard_score(&ruleset, &input, &validation, BattleResult::Draw).is_none()
|
||||
);
|
||||
assert!(
|
||||
compute_leaderboard_score(&ruleset, &input, &validation, BattleResult::OpponentWin)
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let rejected = FinishValidation {
|
||||
decision: FinishValidationDecision::Rejected,
|
||||
anti_cheat_flags: vec![AntiCheatFlag::DurationTooShort],
|
||||
};
|
||||
assert!(
|
||||
compute_leaderboard_score(&ruleset, &input, &rejected, BattleResult::PlayerWin)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjudicates_draw_threshold_boundaries() {
|
||||
let ruleset = BarkBattleRuleset::v1();
|
||||
assert_eq!(adjudicate_result(&ruleset, 53.0, 50.0), BattleResult::Draw);
|
||||
assert_eq!(
|
||||
adjudicate_result(&ruleset, 53.1, 50.0),
|
||||
BattleResult::PlayerWin
|
||||
);
|
||||
assert_eq!(
|
||||
adjudicate_result(&ruleset, 46.9, 50.0),
|
||||
BattleResult::OpponentWin
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_inclusive_metric_boundaries_and_rejects_non_finite() {
|
||||
let ruleset = BarkBattleRuleset::v1();
|
||||
let mut input = metrics(30_000);
|
||||
input.trigger_count = input.duration_ms / ruleset.min_bark_gap_ms
|
||||
+ u64::from(ruleset.trigger_count_tolerance);
|
||||
input.max_volume = ruleset.min_volume;
|
||||
input.final_energy = ruleset.max_final_energy;
|
||||
input.max_combo = ruleset.max_combo;
|
||||
assert_eq!(
|
||||
validate_finish_metrics(&ruleset, &input).decision,
|
||||
FinishValidationDecision::Accepted
|
||||
);
|
||||
|
||||
input.max_volume = f32::NAN;
|
||||
assert_eq!(
|
||||
validate_finish_metrics(&ruleset, &input).decision,
|
||||
FinishValidationDecision::Rejected
|
||||
);
|
||||
input.max_volume = 0.8;
|
||||
input.average_volume = ruleset.max_average_volume + 0.1;
|
||||
let validation = validate_finish_metrics(&ruleset, &input);
|
||||
assert_eq!(validation.decision, FinishValidationDecision::Rejected);
|
||||
assert!(
|
||||
validation
|
||||
.anti_cheat_flags
|
||||
.contains(&AntiCheatFlag::AverageVolumeOutOfRange)
|
||||
);
|
||||
input.average_volume = 0.6;
|
||||
input.final_energy = f32::INFINITY;
|
||||
assert_eq!(
|
||||
validate_finish_metrics(&ruleset, &input).decision,
|
||||
FinishValidationDecision::Rejected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaderboard_score_allows_flagged_but_accepted_player_wins() {
|
||||
let ruleset = BarkBattleRuleset::v1();
|
||||
let input = metrics(30_000);
|
||||
let validation = FinishValidation {
|
||||
decision: FinishValidationDecision::AcceptedWithFlags,
|
||||
anti_cheat_flags: vec![AntiCheatFlag::TriggerCountTooHigh],
|
||||
};
|
||||
|
||||
assert!(
|
||||
compute_leaderboard_score(&ruleset, &input, &validation, BattleResult::PlayerWin)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn difficulty_changes_only_ai_preset_key() {
|
||||
let easy = BarkBattleRuleset::for_difficulty(DifficultyPreset::Easy);
|
||||
let normal = BarkBattleRuleset::for_difficulty(DifficultyPreset::Normal);
|
||||
let hard = BarkBattleRuleset::for_difficulty(DifficultyPreset::Hard);
|
||||
|
||||
assert_eq!(easy.thresholds_signature(), normal.thresholds_signature());
|
||||
assert_eq!(normal.thresholds_signature(), hard.thresholds_signature());
|
||||
assert_eq!(easy.ai_preset_key, "bark-battle-ai-easy");
|
||||
assert_eq!(normal.ai_preset_key, "bark-battle-ai-normal");
|
||||
assert_eq!(hard.ai_preset_key, "bark-battle-ai-hard");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BarkBattleDifficultyPreset {
|
||||
Easy,
|
||||
Normal,
|
||||
Hard,
|
||||
}
|
||||
|
||||
impl Default for BarkBattleDifficultyPreset {
|
||||
fn default() -> Self {
|
||||
Self::Normal
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BarkBattleServerResult {
|
||||
PlayerWin,
|
||||
OpponentWin,
|
||||
Draw,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BarkBattleFinishStatus {
|
||||
Accepted,
|
||||
AcceptedWithFlags,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleConfigEditorPayload {
|
||||
pub title: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub theme_preset: String,
|
||||
pub player_dog_skin_preset: String,
|
||||
pub opponent_dog_skin_preset: String,
|
||||
#[serde(default)]
|
||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||
pub leaderboard_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleDraftCreateRequest {
|
||||
pub title: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub theme_preset: String,
|
||||
pub player_dog_skin_preset: String,
|
||||
pub opponent_dog_skin_preset: String,
|
||||
#[serde(default)]
|
||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||
pub leaderboard_enabled: bool,
|
||||
}
|
||||
|
||||
impl From<BarkBattleDraftCreateRequest> for BarkBattleConfigEditorPayload {
|
||||
fn from(value: BarkBattleDraftCreateRequest) -> Self {
|
||||
Self {
|
||||
title: value.title,
|
||||
description: value.description,
|
||||
theme_preset: value.theme_preset,
|
||||
player_dog_skin_preset: value.player_dog_skin_preset,
|
||||
opponent_dog_skin_preset: value.opponent_dog_skin_preset,
|
||||
difficulty_preset: value.difficulty_preset,
|
||||
leaderboard_enabled: value.leaderboard_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleWorkPublishRequest {
|
||||
pub draft_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub work_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub published_snapshot: Option<BarkBattleConfigEditorPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleDraftConfig {
|
||||
pub draft_id: String,
|
||||
pub title: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub theme_preset: String,
|
||||
pub player_dog_skin_preset: String,
|
||||
pub opponent_dog_skin_preset: String,
|
||||
#[serde(default)]
|
||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||
pub leaderboard_enabled: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl Default for BarkBattleDraftConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
draft_id: String::new(),
|
||||
title: String::new(),
|
||||
description: None,
|
||||
theme_preset: String::new(),
|
||||
player_dog_skin_preset: String::new(),
|
||||
opponent_dog_skin_preset: String::new(),
|
||||
difficulty_preset: BarkBattleDifficultyPreset::Normal,
|
||||
leaderboard_enabled: true,
|
||||
updated_at: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattlePublishedConfig {
|
||||
pub work_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub draft_id: Option<String>,
|
||||
pub config_version: u32,
|
||||
pub ruleset_version: String,
|
||||
pub play_type_id: String,
|
||||
pub title: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub theme_preset: String,
|
||||
pub player_dog_skin_preset: String,
|
||||
pub opponent_dog_skin_preset: String,
|
||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||
pub leaderboard_enabled: bool,
|
||||
pub updated_at: String,
|
||||
pub published_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleRuntimeConfig {
|
||||
pub work_id: String,
|
||||
pub config_version: u32,
|
||||
pub ruleset_version: String,
|
||||
pub play_type_id: String,
|
||||
pub duration_ms: u64,
|
||||
pub energy_min: f32,
|
||||
pub energy_max: f32,
|
||||
pub draw_threshold: f32,
|
||||
pub min_bark_gap_ms: u64,
|
||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||
pub theme_preset: String,
|
||||
pub player_dog_skin_preset: String,
|
||||
pub opponent_dog_skin_preset: String,
|
||||
pub leaderboard_enabled: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleRunStartRequest {
|
||||
pub work_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config_version: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_route: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client_runtime_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleRunStartResponse {
|
||||
pub run_id: String,
|
||||
pub run_token: String,
|
||||
pub work_id: String,
|
||||
pub config_version: u32,
|
||||
pub ruleset_version: String,
|
||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||
pub runtime_config: BarkBattleRuntimeConfig,
|
||||
pub server_started_at: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleDerivedMetrics {
|
||||
pub trigger_count: u32,
|
||||
pub max_volume: f32,
|
||||
pub average_volume: f32,
|
||||
pub final_energy: f32,
|
||||
pub combo_max: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleRunFinishRequest {
|
||||
pub work_id: String,
|
||||
pub run_id: String,
|
||||
pub run_token: String,
|
||||
pub config_version: u32,
|
||||
pub ruleset_version: String,
|
||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||
pub client_started_at: String,
|
||||
pub client_finished_at: String,
|
||||
pub duration_ms: u64,
|
||||
pub derived_metrics: BarkBattleDerivedMetrics,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client_result: Option<BarkBattleServerResult>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sample_digest: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client_runtime_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleScoreSummary {
|
||||
pub duration_ms: u64,
|
||||
pub trigger_count: u32,
|
||||
pub max_volume: f32,
|
||||
pub average_volume: f32,
|
||||
pub final_energy: f32,
|
||||
pub combo_max: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleRunFinishResponse {
|
||||
pub status: BarkBattleFinishStatus,
|
||||
pub run_id: String,
|
||||
pub work_id: String,
|
||||
pub config_version: u32,
|
||||
pub ruleset_version: String,
|
||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||
pub server_result: BarkBattleServerResult,
|
||||
pub score_summary: BarkBattleScoreSummary,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub leaderboard_score: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub anti_cheat_flags: Vec<String>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleLeaderboardEntry {
|
||||
pub rank: u32,
|
||||
pub run_id: String,
|
||||
pub work_id: String,
|
||||
pub config_version: u32,
|
||||
pub ruleset_version: String,
|
||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||
pub display_name: String,
|
||||
pub server_result: BarkBattleServerResult,
|
||||
pub score_summary: BarkBattleScoreSummary,
|
||||
pub leaderboard_score: u64,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleLeaderboardResponse {
|
||||
pub work_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config_version: Option<u32>,
|
||||
pub ruleset_version: String,
|
||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||
#[serde(default)]
|
||||
pub entries: Vec<BarkBattleLeaderboardEntry>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub viewer_best: Option<BarkBattleLeaderboardEntry>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattlePersonalHistoryItem {
|
||||
pub run_id: String,
|
||||
pub work_id: String,
|
||||
pub config_version: u32,
|
||||
pub ruleset_version: String,
|
||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||
pub server_result: BarkBattleServerResult,
|
||||
pub score_summary: BarkBattleScoreSummary,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub leaderboard_score: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub anti_cheat_flags: Vec<String>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattlePersonalBestSummary {
|
||||
pub work_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config_version: Option<u32>,
|
||||
pub ruleset_version: String,
|
||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub best_leaderboard_score: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub best_final_energy: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub best_trigger_count: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub best_max_volume: Option<f32>,
|
||||
pub win_count: u64,
|
||||
pub draw_count: u64,
|
||||
pub loss_count: u64,
|
||||
pub finish_count: u64,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattlePersonalHistoryResponse {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub work_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub difficulty_preset: Option<BarkBattleDifficultyPreset>,
|
||||
#[serde(default)]
|
||||
pub items: Vec<BarkBattlePersonalHistoryItem>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub best_summary: Option<BarkBattlePersonalBestSummary>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarkBattleWorkStats {
|
||||
pub work_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config_version: Option<u32>,
|
||||
pub ruleset_version: String,
|
||||
pub difficulty_preset: BarkBattleDifficultyPreset,
|
||||
pub play_start_count: u64,
|
||||
pub finish_count: u64,
|
||||
pub win_count: u64,
|
||||
pub draw_count: u64,
|
||||
pub loss_count: u64,
|
||||
pub flagged_count: u64,
|
||||
pub leaderboard_entry_count: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub best_leaderboard_score: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub best_final_energy: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub average_final_energy: Option<f32>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn draft_config_defaults_to_normal_difficulty() {
|
||||
let config = BarkBattleDraftConfig::default();
|
||||
|
||||
assert_eq!(config.difficulty_preset, BarkBattleDifficultyPreset::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_requests_carry_config_and_ruleset_identity() {
|
||||
let start = BarkBattleRunStartRequest {
|
||||
work_id: "work-1".to_string(),
|
||||
config_version: Some(3),
|
||||
source_route: Some("gallery".to_string()),
|
||||
client_runtime_version: Some("runtime-v1".to_string()),
|
||||
};
|
||||
let start_payload = serde_json::to_value(start).expect("start request should serialize");
|
||||
assert_eq!(start_payload["workId"], json!("work-1"));
|
||||
assert_eq!(start_payload["configVersion"], json!(3));
|
||||
assert_eq!(start_payload["sourceRoute"], json!("gallery"));
|
||||
|
||||
let finish = BarkBattleRunFinishRequest {
|
||||
work_id: "work-1".to_string(),
|
||||
run_id: "run-1".to_string(),
|
||||
run_token: "token-1".to_string(),
|
||||
config_version: 3,
|
||||
ruleset_version: "bark-battle-ruleset-v1".to_string(),
|
||||
difficulty_preset: BarkBattleDifficultyPreset::Hard,
|
||||
client_started_at: "2026-05-13T11:00:00Z".to_string(),
|
||||
client_finished_at: "2026-05-13T11:00:30Z".to_string(),
|
||||
duration_ms: 30_000,
|
||||
derived_metrics: BarkBattleDerivedMetrics {
|
||||
trigger_count: 12,
|
||||
max_volume: 0.95,
|
||||
average_volume: 0.62,
|
||||
final_energy: 88.5,
|
||||
combo_max: 7,
|
||||
},
|
||||
client_result: Some(BarkBattleServerResult::PlayerWin),
|
||||
sample_digest: Some("digest-1".to_string()),
|
||||
client_runtime_version: None,
|
||||
};
|
||||
let finish_payload = serde_json::to_value(finish).expect("finish request should serialize");
|
||||
assert_eq!(finish_payload["configVersion"], json!(3));
|
||||
assert_eq!(
|
||||
finish_payload["rulesetVersion"],
|
||||
json!("bark-battle-ruleset-v1")
|
||||
);
|
||||
assert_eq!(finish_payload["difficultyPreset"], json!("hard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_fields_are_omitted_when_absent() {
|
||||
let draft = BarkBattleDraftConfig::default();
|
||||
let payload = serde_json::to_value(draft).expect("draft should serialize");
|
||||
assert!(!payload.as_object().unwrap().contains_key("description"));
|
||||
|
||||
let response = BarkBattlePersonalHistoryResponse {
|
||||
work_id: None,
|
||||
difficulty_preset: None,
|
||||
items: Vec::new(),
|
||||
best_summary: None,
|
||||
updated_at: "2026-05-13T11:00:00Z".to_string(),
|
||||
};
|
||||
let payload = serde_json::to_value(response).expect("history response should serialize");
|
||||
assert!(!payload.as_object().unwrap().contains_key("workId"));
|
||||
assert!(
|
||||
!payload
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("difficultyPreset")
|
||||
);
|
||||
assert!(!payload.as_object().unwrap().contains_key("bestSummary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_response_serializes_player_win_and_accepted() {
|
||||
let response = BarkBattleRunFinishResponse {
|
||||
status: BarkBattleFinishStatus::Accepted,
|
||||
run_id: "run-1".to_string(),
|
||||
work_id: "work-1".to_string(),
|
||||
config_version: 3,
|
||||
ruleset_version: "bark-battle-ruleset-v1".to_string(),
|
||||
difficulty_preset: BarkBattleDifficultyPreset::Normal,
|
||||
server_result: BarkBattleServerResult::PlayerWin,
|
||||
score_summary: BarkBattleScoreSummary {
|
||||
duration_ms: 30_000,
|
||||
trigger_count: 12,
|
||||
max_volume: 0.95,
|
||||
average_volume: 0.62,
|
||||
final_energy: 88.5,
|
||||
combo_max: 7,
|
||||
},
|
||||
leaderboard_score: Some(98_765),
|
||||
anti_cheat_flags: Vec::new(),
|
||||
updated_at: "2026-05-13T11:00:00Z".to_string(),
|
||||
};
|
||||
|
||||
let payload = serde_json::to_value(response).expect("finish response should serialize");
|
||||
|
||||
assert_eq!(payload["runId"], json!("run-1"));
|
||||
assert_eq!(payload["status"], json!("accepted"));
|
||||
assert_eq!(payload["serverResult"], json!("player_win"));
|
||||
assert_eq!(payload["leaderboardScore"], json!(98_765));
|
||||
assert_eq!(payload["scoreSummary"]["finalEnergy"], json!(88.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_stats_fields_are_constructible() {
|
||||
let stats = BarkBattleWorkStats {
|
||||
work_id: "work-1".to_string(),
|
||||
config_version: Some(3),
|
||||
ruleset_version: "bark-battle-ruleset-v1".to_string(),
|
||||
difficulty_preset: BarkBattleDifficultyPreset::Normal,
|
||||
play_start_count: 10,
|
||||
finish_count: 9,
|
||||
win_count: 5,
|
||||
draw_count: 2,
|
||||
loss_count: 2,
|
||||
flagged_count: 1,
|
||||
leaderboard_entry_count: 4,
|
||||
best_leaderboard_score: Some(98_765),
|
||||
best_final_energy: Some(97.5),
|
||||
average_final_energy: Some(73.25),
|
||||
updated_at: "2026-05-13T11:00:00Z".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(stats.work_id, "work-1");
|
||||
assert_eq!(stats.play_start_count, 10);
|
||||
assert_eq!(stats.finish_count, 9);
|
||||
assert_eq!(stats.win_count, 5);
|
||||
assert_eq!(stats.draw_count, 2);
|
||||
assert_eq!(stats.loss_count, 2);
|
||||
assert_eq!(stats.flagged_count, 1);
|
||||
assert_eq!(stats.leaderboard_entry_count, 4);
|
||||
assert_eq!(stats.best_leaderboard_score, Some(98_765));
|
||||
assert_eq!(stats.best_final_energy, Some(97.5));
|
||||
assert_eq!(stats.average_final_energy, Some(73.25));
|
||||
assert_eq!(stats.updated_at, "2026-05-13T11:00:00Z");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod api;
|
||||
#[cfg(feature = "oss-contracts")]
|
||||
pub mod assets;
|
||||
pub mod auth;
|
||||
pub mod bark_battle;
|
||||
pub mod big_fish;
|
||||
pub mod big_fish_works;
|
||||
pub mod creation_agent_document_input;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
use super::*;
|
||||
|
||||
pub type BarkBattleDraftCreateRecordInput = BarkBattleDraftCreateInput;
|
||||
pub type BarkBattleDraftConfigUpsertRecordInput = BarkBattleDraftConfigUpsertInput;
|
||||
pub type BarkBattleWorkPublishRecordInput = BarkBattleWorkPublishInput;
|
||||
pub type BarkBattleRunStartRecordInput = BarkBattleRunStartInput;
|
||||
pub type BarkBattleRunFinishRecordInput = BarkBattleRunFinishInput;
|
||||
|
||||
impl SpacetimeClient {
|
||||
pub async fn create_bark_battle_draft(
|
||||
&self,
|
||||
input: BarkBattleDraftCreateRecordInput,
|
||||
) -> Result<BarkBattleDraftConfigRecord, SpacetimeClientError> {
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.create_bark_battle_draft_then(input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_bark_battle_draft_config_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_bark_battle_draft_config(
|
||||
&self,
|
||||
input: BarkBattleDraftConfigUpsertRecordInput,
|
||||
) -> Result<BarkBattleDraftConfigRecord, SpacetimeClientError> {
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.update_bark_battle_draft_config_then(input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_bark_battle_draft_config_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn publish_bark_battle_work(
|
||||
&self,
|
||||
input: BarkBattleWorkPublishRecordInput,
|
||||
) -> Result<BarkBattleRuntimeConfigRecord, SpacetimeClientError> {
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.publish_bark_battle_work_then(input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_bark_battle_runtime_config_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_bark_battle_runtime_config(
|
||||
&self,
|
||||
work_id: String,
|
||||
owner_user_id: Option<String>,
|
||||
) -> Result<BarkBattleRuntimeConfigRecord, SpacetimeClientError> {
|
||||
let input = BarkBattleRuntimeConfigGetInput {
|
||||
work_id,
|
||||
owner_user_id,
|
||||
};
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.get_bark_battle_runtime_config_then(input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_bark_battle_runtime_config_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn start_bark_battle_run(
|
||||
&self,
|
||||
input: BarkBattleRunStartRecordInput,
|
||||
) -> Result<BarkBattleRunRecord, SpacetimeClientError> {
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.start_bark_battle_run_then(input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_bark_battle_run_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn finish_bark_battle_run(
|
||||
&self,
|
||||
input: BarkBattleRunFinishRecordInput,
|
||||
) -> Result<BarkBattleRunRecord, SpacetimeClientError> {
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.finish_bark_battle_run_then(input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_bark_battle_run_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_bark_battle_run(
|
||||
&self,
|
||||
run_id: String,
|
||||
owner_user_id: String,
|
||||
) -> Result<BarkBattleRunRecord, SpacetimeClientError> {
|
||||
let input = BarkBattleRunGetInput {
|
||||
run_id,
|
||||
owner_user_id,
|
||||
};
|
||||
self.call_after_connect(move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.get_bark_battle_run_then(input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
|
||||
.and_then(map_bark_battle_run_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,12 @@ mod mapper;
|
||||
use mapper::*;
|
||||
pub use mapper::{
|
||||
AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord,
|
||||
AiTextChunkRecord, BattleStateRecord, BigFishAgentMessageRecord, BigFishAnchorItemRecord,
|
||||
BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput,
|
||||
BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput,
|
||||
BigFishGameDraftRecord, BigFishInputSubmitRecordInput, BigFishLevelBlueprintRecord,
|
||||
BigFishLikeReportRecordInput, BigFishMessageFinalizeRecordInput,
|
||||
AiTextChunkRecord, BarkBattleDraftConfigRecord, BarkBattleRunRecord,
|
||||
BarkBattleRuntimeConfigRecord, BattleStateRecord, BigFishAgentMessageRecord,
|
||||
BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAssetCoverageRecord,
|
||||
BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord,
|
||||
BigFishDraftCompileRecordInput, BigFishGameDraftRecord, BigFishInputSubmitRecordInput,
|
||||
BigFishLevelBlueprintRecord, BigFishLikeReportRecordInput, BigFishMessageFinalizeRecordInput,
|
||||
BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRunStartRecordInput,
|
||||
BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord,
|
||||
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record,
|
||||
@@ -74,6 +75,12 @@ pub use mapper::{
|
||||
pub mod ai;
|
||||
pub mod assets;
|
||||
pub mod auth;
|
||||
pub mod bark_battle;
|
||||
pub use bark_battle::{
|
||||
BarkBattleDraftConfigUpsertRecordInput, BarkBattleDraftCreateRecordInput,
|
||||
BarkBattleRunFinishRecordInput, BarkBattleRunStartRecordInput,
|
||||
BarkBattleWorkPublishRecordInput,
|
||||
};
|
||||
pub mod big_fish;
|
||||
pub mod combat;
|
||||
pub mod custom_world;
|
||||
|
||||
@@ -732,6 +732,42 @@ pub(crate) fn map_asset_history_list_result(
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub type BarkBattleDraftConfigRecord = serde_json::Value;
|
||||
pub type BarkBattleRuntimeConfigRecord = serde_json::Value;
|
||||
pub type BarkBattleRunRecord = serde_json::Value;
|
||||
|
||||
pub(crate) fn map_bark_battle_draft_config_procedure_result(
|
||||
result: BarkBattleProcedureResult,
|
||||
) -> Result<BarkBattleDraftConfigRecord, SpacetimeClientError> {
|
||||
parse_bark_battle_row_json(result, "Bark Battle draft config")
|
||||
}
|
||||
|
||||
pub(crate) fn map_bark_battle_runtime_config_procedure_result(
|
||||
result: BarkBattleProcedureResult,
|
||||
) -> Result<BarkBattleRuntimeConfigRecord, SpacetimeClientError> {
|
||||
parse_bark_battle_row_json(result, "Bark Battle runtime config")
|
||||
}
|
||||
|
||||
pub(crate) fn map_bark_battle_run_procedure_result(
|
||||
result: BarkBattleProcedureResult,
|
||||
) -> Result<BarkBattleRunRecord, SpacetimeClientError> {
|
||||
parse_bark_battle_row_json(result, "Bark Battle run")
|
||||
}
|
||||
|
||||
fn parse_bark_battle_row_json<T: serde::de::DeserializeOwned>(
|
||||
result: BarkBattleProcedureResult,
|
||||
label: &'static str,
|
||||
) -> Result<T, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
let row_json = result
|
||||
.row_json
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot(label))?;
|
||||
serde_json::from_str(&row_json)
|
||||
.map_err(|error| SpacetimeClientError::Runtime(format!("{label} JSON 解析失败: {error}")))
|
||||
}
|
||||
|
||||
pub type CreationEntryConfigRecord =
|
||||
shared_contracts::creation_entry_config::CreationEntryConfigResponse;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user