From 6b1e0c45c9d3e15ae1039e12027e8bfacda02715 Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 21:56:39 +0800 Subject: [PATCH 001/126] docs: add world lifecycle consolidation plan --- docs/ACTIVE_DELIVERY_INDEX.md | 34 ++++++ .../adr/ADR-001-world-domain-consolidation.md | 90 +++++++++++++++ .../2026-08-16-world-center-prototype.html | 48 ++++++++ .../2026-08-16-dzmm-product-domain-review.md | 84 ++++++++++++++ ...-16-world-lifecycle-and-content-interop.md | 105 ++++++++++++++++++ 5 files changed, 361 insertions(+) create mode 100644 docs/ACTIVE_DELIVERY_INDEX.md create mode 100644 docs/adr/ADR-001-world-domain-consolidation.md create mode 100644 docs/prototypes/2026-08-16-world-center-prototype.html create mode 100644 docs/reviews/2026-08-16-dzmm-product-domain-review.md create mode 100644 docs/superpowers/specs/2026-08-16-world-lifecycle-and-content-interop.md diff --git a/docs/ACTIVE_DELIVERY_INDEX.md b/docs/ACTIVE_DELIVERY_INDEX.md new file mode 100644 index 0000000..32b8dca --- /dev/null +++ b/docs/ACTIVE_DELIVERY_INDEX.md @@ -0,0 +1,34 @@ +# Active Delivery Index + +## DZMM 世界生命周期与内容互通重整 + +- **状态:** Proposed — awaiting product approval +- **日期:** 2026-08-16 +- **工作树 / 分支:** `.worktrees/dzmm-domain-consolidation-review` / `docs/dzmm-domain-consolidation-review` +- **基线:** `main` at `df38037` (`v0.16.0`) +- **目标:** 以一个可原子创建、可解释删除、可导入外部内容的世界聚合,替换当前三条叠加的创建与生命周期路径;保留已验证的开放世界运行时与 Android 游戏客户端边界。 + +### Canonical artifacts + +- [产品与领域现状评审](reviews/2026-08-16-dzmm-product-domain-review.md) +- [ADR-001:世界领域收敛](adr/ADR-001-world-domain-consolidation.md) +- [规格与分阶段实施计划](superpowers/specs/2026-08-16-world-lifecycle-and-content-interop.md) +- [世界中心交互原型](prototypes/2026-08-16-world-center-prototype.html) + +### 已确认决策 + +1. DZMM 保持「本地优先、单人 AI TRPG、Python 决定规则与状态」定位,不复刻酒馆的完整提示词/脚本工作台。 +2. SillyTavern 世界书优先作为**叙事知识层**导入;地点、NPC、势力和事件只有经用户显式提升后才成为可改变游戏状态的结构化实体。 +3. 不做全量推倒重写。采用兼容迁移 + 绞杀式收敛,先补数据生命周期缺口,再切换新建入口。 +4. Android remote client 是独立发布门槛;本重整不得在其真机验收前改变远程 gameplay API 合约。 + +### 当前证据与风险 + +- 新开放世界向导依次调用 `finalize framework -> create world -> create character -> create session`,是四个独立提交;中途失败或重试可留下孤儿资源。 +- `Session` 同时持有 `world_id` 与 `framework_id`,但两者无所有权关系;`DELETE /worlds/{id}?cascade=true` 不清理 framework 及其 session runtime 状态。 +- `delete_session_cascade()` 没有删除 `SessionLocationState`、`SessionNpcState`、`SessionEventState`、`SessionFactionState`、`SessionCampaignState`;SQLite 外键级联也未由应用开启。 +- 桌面 v0.16.0 的玩法成熟度为 88.1,但 Android remote acceptance 仍为 78.1,真机/LAN 验收未完成。 + +### 下一关 + +产品确认本规格的 P0 边界后,先建立临时数据库的创建失败、删除与迁移回归测试;测试能够证明无孤儿资源后,才实施数据模型迁移。 diff --git a/docs/adr/ADR-001-world-domain-consolidation.md b/docs/adr/ADR-001-world-domain-consolidation.md new file mode 100644 index 0000000..ac7aead --- /dev/null +++ b/docs/adr/ADR-001-world-domain-consolidation.md @@ -0,0 +1,90 @@ +# ADR-001:收敛世界领域为单一所有权聚合 + +**状态:** Proposed +**日期:** 2026-08-16 +**决策人:** 产品负责人、DZMM 工程负责人 + +## Context + +DZMM 当前存在 `World`、`WorldFramework`、`Screenplay` 三种重叠的世界/剧情容器。一个开放世界 session 同时引用 `world_id` 与 `framework_id`,但两者没有一对一关系;新向导用四个 HTTP 请求创建它们。删除逻辑按旧模型手工列出子表,遗漏 framework runtime state,且 SQLite 并未由应用统一启用 FK cascade。 + +这导致三类不可接受的状态:创建半成品、删除后的不可见孤儿、同一事实被剧本和 framework 双重控制。 + +## Decision + +保留现有 `World` 作为**唯一用户可见的世界根**,并将 `WorldFramework` 变为其唯一可选的结构化模板组件: + +```text +World (canonical root) +├── LoreEntry[] narrative knowledge; never mutates state +├── Character[] reusable PCs +├── Framework? (1:1) locations/factions/NPC templates/events/campaign +├── LegacyScreenplay[] read-only compatibility until migrated/retired +└── Session[] play runs + └── RuntimeState[] only session-owned mutable state +``` + +实现上,新增 `WorldFramework.world_id`(unique FK),回填现有 `Session(world_id, framework_id)` 对。新建开放世界使用一个 `POST /worlds/compose` 命令,在单个数据库事务中写入 World、Framework、PC、Session 及 runtime 初始状态。LLM 生成永远只产生可编辑草稿,最终提交才写数据库。 + +删除、归档、导入、导出集中到 `WorldLifecycleService`;路由不得各自维护子表名单。数据库连接统一开启 SQLite FK;迁移为需要重建表的 schema 使用受版本控制的 Alembic/SQLite rebuild 迁移,而不是继续只追加列。 + +## Options considered + +### A. 全量重写数据模型与前端 + +| 维度 | 评估 | +|---|---| +| 复杂度 | 极高 | +| 用户存档风险 | 极高 | +| 短期收益 | 中 | +| 长期清晰度 | 高 | + +优点:命名和表结构最干净。 +缺点:会丢失 v0.16 的长局、恢复、模型兼容回归保护;Android 分支需要重做;无法安全迁移真实本地存档。 + +**结论:拒绝。** + +### B. 保持双根,只补删除 if/else + +| 维度 | 评估 | +|---|---| +| 复杂度 | 低 | +| 用户存档风险 | 中 | +| 短期收益 | 低 | +| 长期清晰度 | 低 | + +优点:可以很快减少一个缺陷。 +缺点:不能阻止四次提交的孤儿资源,也不会解决“世界到底是什么”的产品认知。 + +**结论:拒绝,最多作为进入迁移前的止血补丁。** + +### C. 兼容迁移到单一 World root(采用) + +| 维度 | 评估 | +|---|---| +| 复杂度 | 高但可分阶段 | +| 用户存档风险 | 可控,需要备份与校验 | +| 短期收益 | 高 | +| 长期清晰度 | 高 | + +优点:保留 `world_id` 这一广泛使用的稳定外键,逐步收编 Framework、Lore 与旧剧本;可让新旧 session 并存到迁移完成。 +缺点:迁移期间会有适配层,不能一次删除旧 API。 + +**结论:采用。** + +## Consequences + +- 新功能只可依赖 `WorldLifecycleService` 和 canonical World manifest;禁止增加第二个世界根。 +- `Screenplay` 成为 legacy compatibility 或显式的 adventure template,不再与 Framework 共同控制开放世界回合。 +- 删除世界默认归档;物理清除只能从 World Center 发起,并显示 framework、session state、assets、RAG index 和外部导入 escrow 的完整影响。 +- 现有世界可继续游玩;迁移失败必须保持旧数据只读可恢复,绝不自动删除。 +- Android remote API 保持 gameplay-only。其会话读取接口可以增加 `world_manifest_version`,但不能在真机验收前破坏当前契约。 + +## Action items + +1. [ ] 为创建失败、session/world 删除、框架回填建立临时 DB 回归测试。 +2. [ ] 实现 `WorldLifecycleService` 和统一 manifest/impact preview,不改变默认 UI。 +3. [ ] 加入 schema migration、备份和完整性报告;回填 `WorldFramework.world_id`。 +4. [ ] 将开放世界向导切换到原子 compose endpoint;冻结旧向导的写入入口。 +5. [ ] 引入 LoreEntry 和 SillyTavern V3 / World Info import preview。 +6. [ ] 将 World Center 作为唯一管理入口,旧 Worlds/Screenplays 页面转为兼容视图后再下线。 diff --git a/docs/prototypes/2026-08-16-world-center-prototype.html b/docs/prototypes/2026-08-16-world-center-prototype.html new file mode 100644 index 0000000..5b09d54 --- /dev/null +++ b/docs/prototypes/2026-08-16-world-center-prototype.html @@ -0,0 +1,48 @@ + + + + + + DZMM 世界中心 · 生命周期原型 + + + +
DZMM · World Lifecycle

世界中心

可游玩 · 结构完整
+
+
+

盐雾港的失踪案

开放世界 · 悬疑 · 上次游玩:今天 21:14
可游玩Framework 已连接7 条世界知识
+

冬港备忘录

草稿 · 尚未开局
等待确认导入自 World Info
+

赤道观测站

已归档 · 可在 30 天内恢复
2 个存档无活动设备
+
+ +
+ + + diff --git a/docs/reviews/2026-08-16-dzmm-product-domain-review.md b/docs/reviews/2026-08-16-dzmm-product-domain-review.md new file mode 100644 index 0000000..a15f3f5 --- /dev/null +++ b/docs/reviews/2026-08-16-dzmm-product-domain-review.md @@ -0,0 +1,84 @@ +# DZMM 产品与领域现状评审 + +日期:2026-08-16 +范围:`main` 的 v0.16.0 桌面产品;Android 分支只作为未发布的在建能力。 + +## 结论 + +DZMM 已经不是一个「酒馆壳 + 提示词」项目。它在 Python-first 规则裁决、开放世界事实源、状态回写、回合恢复和本地模型协议兼容上,具备明确的引擎型优势。当前最大的产品问题不是功能少,而是**领域根对象没有收敛**:旧世界/剧本路径、新 Framework 路径和多个创建/删除入口同时存在,用户无法判断哪个资源才是“我的世界”,代码也无法安全表达其生命周期。 + +外部产品的启发应被拆开吸收:酒馆系提供内容互通与创作控制;Open Dungeon 提供低摩擦沉浸体验;DiceFrame 提供多人桌面与规则包;Z.RIC 提供地图/状态可视化。它们都不应替代 DZMM 的确定性状态内核。 + +## 当前 DZMM 的真实形态 + +### 已验证的优势 + +- `WorldFramework` 将地点、势力、NPC 模板、事件和 Campaign 作为开放世界的结构化模板;session 使用单独状态表保存运行时覆盖。 +- `Session.framework_id` 走开放世界 Director;代码会拒绝 screenplay 控制标签、校验地点与事件 ID,避免向导事实与实际回合各自生长。 +- 回合中的骰子、物品、战斗、事件谓词由 Python 引擎决定;LLM 叙述和表达意图,而非作为最终裁判。 +- 模型协议已区分 Ollama、LM Studio 和 OpenAI-compatible,且不会将 HTTP 200 错误体误判为成功。 +- v0.16.0 有独立数据库上的 50 回合真实模型证据,桌面成熟度记录为 88.1/100。 + +### 正在损耗体验与可维护性的叠加层 + +| 路径 | 写入资源 | 运行语义 | 当前问题 | +|---|---|---|---| +| 预置/手动世界 | `World`、`Character`、`Screenplay`、`Session` | 旧剧本模式 | 仍在导航与管理 UI 暴露;和新世界并列出现。 | +| 旧向导 API | `finalize_wizard()` 一次事务创建 World/Character/Session/Screenplay | 旧剧本模式 | 前端旧 Wizard 已移除,服务与 API 仍保留兼容路径。 | +| 新开放世界向导 | `WorldFramework`,再分别创建 `World`、`Character`、`Session` | Framework Director | 四个 HTTP 提交,没有原子边界;`World` 和 `WorldFramework` 没有关系。 | +| Session 删除 | 手动清理大量旧 session 子表 | 两种模式混合 | Framework runtime 状态未包含在清理列表。 | +| World 删除 | 删除 World/Character/Session/Screenplay + RAG 索引 | 旧世界所有权 | 不知道并不会删除关联 Framework,容易产生不可见遗留。 | + +这解释了“自动创建世界”和“创建世界入口”冲突:并非单个按钮重复,而是两个不相干的聚合根分别被创建,再被同一个 Session 并列引用。 + +## 外部设计中真正可借鉴的部分 + +| 参考 | 应学什么 | 不应照搬什么 | +|---|---|---| +| SillyTavern / RisuAI | 角色卡、Lorebook、预设等内容可以迁移;创作资源与运行对话分开;导入有可见的兼容报告。 | 无限参数、正则/脚本执行和任意 prompt 注入。它们会破坏 DZMM 的可解释规则边界。 | +| Hoplight | 一份本地 canonical content,针对外部平台用 adapter 导入/导出,并保留不能映射的原字段。 | 先做所有平台的双向完美转换。第一期只支持高价值、可解释的字段。 | +| Open Dungeon | `行动 / 对话 / 作者叙事` 的显式输入意图、单段编辑/重试、连续故事摘要、可选沉浸图像。 | 将故事文本当成唯一状态;DZMM 必须继续让结构状态优先。 | +| DiceFrame | 规则包、多玩家同步、群聊入口和插件是独立产品线。 | 现在把 DZMM 的单人闭环改造成多人平台。多人桌需要单独的回合协调、权限和主持人设计。 | +| Z.RIC | 玩家看得到的地点、事件、关系和地图状态,能让结构化世界“被感知”。 | 让 LLM 自动生长所有地图/实体并直接写状态。 | +| NativeTavern | 原生移动端、内容导入和触屏信息密度。 | 手机直接保管所有模型密钥与世界管理。DZMM 的 Mac host 边界更适合保持。 | + +GitHub 资料只用于设计对标,未对这些项目进行本地安装或安全审计;项目功能主张不等于同等级验证证据。 + +## 酒馆世界书是否值得借鉴 + +值得,但它应成为 DZMM 的**知识层**,而不能成为第二套世界状态机。 + +酒馆 World Info/Lorebook 的核心是很多独立条目:触发关键词、可选二级关键词、优先级、内容、注入位置/深度、递归、分组、概率和常驻标记。它非常适合补充“这个家族的历史”“某把剑的传闻”“城市俚语”等叙事事实;它不适合直接决定 NPC 是否死亡、地点是否毁灭、事件是否完成。 + +建议的映射: + +| 外部条目字段 | DZMM v1 映射 | 原因 | +|---|---|---| +| title/comment/content | `LoreEntry.title/body_md` | 可搜索、可审阅的叙事知识。 | +| keys / secondary keys | `activation.keywords` | 用确定性关键词匹配,后续才叠加现有 RAG 召回。 | +| constant | `activation=always` | 常驻的世界基调或硬设定。 | +| order | `priority` | 在 token 预算内稳定排序。 | +| position / depth | 受限的 `world` / `scene` / `recent` 三个锚点 | 不直接复刻外部 prompt 内部位置。 | +| recursion / group / probability / regex/script | 原始字段 escrow 保存,不执行 | 保住数据可回导,同时不让不可解释逻辑进入游戏状态。 | + +条目必须提供“提升为结构化实体”操作:用户明确把一条 LoreEntry 提升为地点、NPC、势力或事件时,才通过一个受校验的命令写入 `WorldFramework`。同一事实不得同时由 Lore 和 Framework 静默决定。 + +## 体验提升假设 + +以下是重整完成后的目标假设,不是已验证的分数: + +| 体验 | 当前估计 | 完成 P0 后目标 | 主要变化 | +|---|---:|---:|---| +| 新建世界的可理解性 | 55 | 90 | 只剩一个“创建世界并开局”主路径,草稿与提交边界清晰。 | +| 中途失败后的数据可信度 | 45 | 95 | 单次原子提交;失败不留下 Framework/World/Character 孤儿。 | +| 删除与清理的可预测性 | 40 | 95 | 默认归档、显式影响清单、单个 cascade owner。 | +| 复用外部 RP 内容 | 35 | 80 | 先支持角色卡和 Lorebook 的安全导入、兼容报告与保真导出。 | +| 玩家对世界状态的感知 | 70 | 88 | 世界中心、地点/事件/关系可见,结构不再只活在 prompt。 | +| 核心长局可靠性 | 91 | >=91 | 维持 v0.16 的状态/恢复回归,不以重构换功能倒退。 | + +## 需要产品确认的边界 + +1. 以后是否仍支持“只建一个文本世界、手动配剧本”的专家模式?建议保留为导入/高级模式,不作为默认入口。 +2. 角色卡第一期只支持 SillyTavern V3/CharX,还是还要包含 PNG 嵌入资源?建议 V3 JSON + PNG metadata 一次完成。 +3. 删除策略是否接受默认软删除 30 天?建议接受;如果坚持立即物理删除,必须保留二次确认与本地备份导出。 +4. 多人桌是否属于 2026 年内的战略目标?若不是,DiceFrame 的多人/群聊能力仅作为 P2 观察项。 diff --git a/docs/superpowers/specs/2026-08-16-world-lifecycle-and-content-interop.md b/docs/superpowers/specs/2026-08-16-world-lifecycle-and-content-interop.md new file mode 100644 index 0000000..dcc8063 --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-world-lifecycle-and-content-interop.md @@ -0,0 +1,105 @@ +# DZMM 世界生命周期与内容互通规格 + +## Problem statement + +玩家创建开放世界时,实际会写入多套互不绑定的资源;失败、重试或删除后无法确信世界是否完整、是否仍有隐性数据。与此同时,酒馆生态中可复用的角色卡和条目式世界书无法自然进入 DZMM,用户必须从零输入设定。 + +本规格以一次领域收敛解决这两个问题:世界成为唯一所有权根;外部内容作为可审阅、可提升的知识输入;已验证的开放世界回合内核不重写。 + +## Goals + +1. 任意“创建世界并开局”在成功时完整创建,在失败/重试时不留下任何不可见资源。 +2. 世界删除或归档可以准确预览并覆盖全部子资源;完整性检查结果为零孤儿。 +3. 导入 SillyTavern V3 角色卡与 World Info 后,用户能在 3 分钟内将其用于新世界,不丢失未知字段。 +4. Framework 的地点、NPC、势力、事件继续是运行时唯一结构事实源;Lore 不得直接改变状态。 +5. 保留所有既有存档的可玩性,且 v0.16 的 50 回合与恢复回归不下降。 + +## Non-goals + +- 不复刻 SillyTavern 的完整 Prompt Manager、Regex Script、插件脚本或所有 provider 参数。 +- 不在本计划内实现多人桌、群聊 Bot、公共云同步或 iOS。 +- 不自动把 Lore 条目转成地点/NPC/事件;必须有用户确认。 +- 不在 Android remote RC 真机验收前重写远程 gameplay API。 + +## User stories + +- 作为新玩家,我要在一个页面生成、检查并确认世界、角色和开局,以便失败时不会留下难以理解的半成品。 +- 作为长期玩家,我要先归档世界,再在明确看到将删除哪些存档/资源时选择永久删除。 +- 作为酒馆内容拥有者,我要导入角色卡和世界书,并看到哪些字段被使用、保留或忽略。 +- 作为世界作者,我要把一条 lore 明确提升为“地点”或“事件”,以便它开始参与规则与状态推进。 +- 作为旧存档玩家,我要无需迁移操作即可继续原来的跑团。 + +## Requirements + +### P0 — 生命周期安全与领域收敛 + +1. **Canonical manifest** + - `World` 是唯一用户可见根;`WorldFramework.world_id` 是唯一 FK。 + - 一个 World 最多一个 Framework;每个新 Framework 必须绑定一个 World。 + - `GET /worlds/{id}/manifest` 返回 World、Framework、角色、存档、legacy screenplay、assets、lore、RAG index 的计数与版本。 + +2. **Atomic compose** + - `POST /worlds/compose` 接收经用户确认的 draft,在一个数据库事务中创建 World、Framework、Character、Session 和初始 runtime state。 + - 任一约束、写入或请求失败后 rollback;同一 `request_id` 重试最多产生一个完整世界。 + - 向导生成步骤只保存在本地草稿或明确的 server draft,不直接提交领域资源。 + +3. **Unified lifecycle** + - `WorldLifecycleService.preview_delete()` 返回精确资源清单;`archive()` 与 `purge()` 是唯一删除入口。 + - purge 删除 framework child、所有 session runtime state、legacy child、RAG/NPC memory/asset 引用;每个外部文件清理记录结果。 + - SQLite connection 启用 FK;应用层仍按领域顺序清理非 SQL 资源。 + - `DELETE /sessions/{id}` 必须删除五张 `Session*State` 表;删除 world 必须覆盖 Framework。 + +4. **旧数据迁移与恢复** + - 启动迁移前生成带 manifest 的本地备份;可从备份恢复。 + - 对每个 `Session.framework_id` 回填唯一 `WorldFramework.world_id`;歧义记录为阻塞项,不猜测。 + - migration 后运行 referential-integrity report:所有 FK、session runtime state、RAG/asset references 无孤儿。 + +### P1 — Lorebook 与角色卡互通 + +1. `LoreEntry` 字段:title、body、activation、keywords、priority、anchor、source_format、source_payload、created_at/updated_at。 +2. 导入 ST V3 character card、PNG metadata 与 World Info JSON;显示 supported / preserved / ignored 字段报告。 +3. `source_payload` 保存外部未知字段,导出时可无损带回;禁用外部 regex/script/概率执行。 +4. prompt context 采用确定性预算:Framework facts 优先,Lore 按 always → keyword → optional semantic recall 排序;记录本回合入选条目 ID。 +5. Lore 提升为结构实体时产生显式 diff 与用户确认;提升后建立一个 source link,避免重复编辑漂移。 + +### P1 — 世界中心体验 + +1. World Center 显示“草稿 / 可游玩 / 已归档”三态,不让用户面对 World 与 Framework 两个概念。 +2. 删除默认操作是“归档”;永久删除必须经过 manifest 预览和键入世界名确认。 +3. Session 页只能删除单个 run;删除世界的入口跳转 World Center,不提供模糊的三级 nuking。 +4. 玩家视图显示当前地点、事件、关系和可追踪目标,但不泄漏 hidden state。 + +### P2 — 后续观察 + +- ComfyUI/本地生图的可选场景资产;失败不影响回合提交。 +- 多人桌、主持人权限和聊天平台 bridge。 +- 更多内容 adapter(Risu、CharX、Hoplight package)。 + +## Delivery plan + +| Phase | 产出 | 依赖 | 验收门槛 | +|---|---|---|---| +| 0. Freeze & audit | domain inventory、真实 DB 只读扫描工具、失败矩阵 | 不碰真实存档 | 临时 DB 能重现四步创建中断与现有孤儿。 | +| 1. Data-safety hotfix | 补 session framework runtime 删除、world framework 影响预览、删除回归测试 | Phase 0 | 每个删除入口后 SQL 与文件完整性报告为零孤儿。 | +| 2. Canonical root | `WorldFramework.world_id`、manifest、迁移/备份、lifecycle service | Phase 1 | 新旧 DB upgrade、回滚、50 回合恢复均通过。 | +| 3. Atomic creation | compose endpoint、新向导切换、旧写入入口 deprecate | Phase 2 | 故障注入 20 次、每次为 0 或 1 个完整聚合。 | +| 4. Content interoperability | LoreEntry、ST V3/World Info preview/import/export | Phase 2 | 10 个混合条目的映射报告可重复,未知字段可 round-trip。 | +| 5. World Center | 新管理入口、归档/永久删除 UX、状态面板提升 | Phase 2-4 | 用户旅程:创建、失败恢复、归档、恢复、永久删除、刷新后状态正确。 | +| 6. Legacy retirement | 标记/迁移旧 screenplay path、移除死入口 | Phase 3 & 5 | 无 production caller 使用 legacy write API;兼容读取有到期版本。 | + +Android remote acceptance 是并行但独立的发布流:先完成真机、路由器、30 回合和 100 回合断线验收;领域重整 branch 之后再从最新 main 适配,避免在同一个 PR 中同时改变网络与持久化风险。 + +## Success metrics + +- 创建失败或重试导致的 orphan aggregate:0。 +- World purge 后 referential-integrity report 的 dangling SQL/file reference:0。 +- World Info import:>=90% 支持字段可解释映射,100% 未支持字段留在 escrow。 +- 新建到第一回合的中位时间:比当前双路径基线降低 >=30%。 +- 迁移后 v0.16 长局回归:50/50 回合、状态恢复、模型协议与删除测试全部通过。 + +## Open questions + +- **产品:** 归档保留期是默认 30 天,还是仅支持本地手动永久删除? +- **产品:** 旧“手动世界 + 剧本”是否保留为高级模式,还是在 vNext 只保留导入? +- **工程:** 真实用户库中是否存在一个 Framework 被多个 World 语义复用的异常数据?必须由只读扫描回答。 +- **设计:** Lore 触发第一期是否只做关键词,还是同一版本接现有 RAG?建议先关键词,保证可解释性和 token 可控。 From f8d7bf8e5227075427b015304d72acbf688147e0 Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 22:15:33 +0800 Subject: [PATCH 002/126] docs: define clean-slate dzmm vnext goal --- docs/ACTIVE_DELIVERY_INDEX.md | 28 ++-- .../adr/ADR-001-world-domain-consolidation.md | 2 +- docs/adr/ADR-002-vnext-clean-slate-rebuild.md | 98 +++++++++++ .../2026-08-16-dzmm-vnext-clean-rebuild.md | 155 ++++++++++++++++++ ...-16-world-lifecycle-and-content-interop.md | 4 + 5 files changed, 272 insertions(+), 15 deletions(-) create mode 100644 docs/adr/ADR-002-vnext-clean-slate-rebuild.md create mode 100644 docs/superpowers/specs/2026-08-16-dzmm-vnext-clean-rebuild.md diff --git a/docs/ACTIVE_DELIVERY_INDEX.md b/docs/ACTIVE_DELIVERY_INDEX.md index 32b8dca..489b5bf 100644 --- a/docs/ACTIVE_DELIVERY_INDEX.md +++ b/docs/ACTIVE_DELIVERY_INDEX.md @@ -1,34 +1,34 @@ # Active Delivery Index -## DZMM 世界生命周期与内容互通重整 +## DZMM vNext 干净重做 -- **状态:** Proposed — awaiting product approval +- **状态:** Active — clean-slate scope confirmed - **日期:** 2026-08-16 - **工作树 / 分支:** `.worktrees/dzmm-domain-consolidation-review` / `docs/dzmm-domain-consolidation-review` - **基线:** `main` at `df38037` (`v0.16.0`) -- **目标:** 以一个可原子创建、可解释删除、可导入外部内容的世界聚合,替换当前三条叠加的创建与生命周期路径;保留已验证的开放世界运行时与 Android 游戏客户端边界。 +- **目标:** 在隔离的 vNext 产品根中重建 DZMM:单一版本化世界聚合、可恢复 Python-first 回合、受限 Lorebook/内容导入、Mac host 与 Android gameplay client。旧数据库、旧 API 和旧代码不构成迁移或兼容约束。 ### Canonical artifacts -- [产品与领域现状评审](reviews/2026-08-16-dzmm-product-domain-review.md) -- [ADR-001:世界领域收敛](adr/ADR-001-world-domain-consolidation.md) -- [规格与分阶段实施计划](superpowers/specs/2026-08-16-world-lifecycle-and-content-interop.md) +- [产品与领域现状评审](reviews/2026-08-16-dzmm-product-domain-review.md)(历史与设计参考) +- [ADR-002:vNext 干净重做](adr/ADR-002-vnext-clean-slate-rebuild.md) +- [vNext 规格、评分矩阵与分阶段实施计划](superpowers/specs/2026-08-16-dzmm-vnext-clean-rebuild.md) - [世界中心交互原型](prototypes/2026-08-16-world-center-prototype.html) ### 已确认决策 1. DZMM 保持「本地优先、单人 AI TRPG、Python 决定规则与状态」定位,不复刻酒馆的完整提示词/脚本工作台。 -2. SillyTavern 世界书优先作为**叙事知识层**导入;地点、NPC、势力和事件只有经用户显式提升后才成为可改变游戏状态的结构化实体。 -3. 不做全量推倒重写。采用兼容迁移 + 绞杀式收敛,先补数据生命周期缺口,再切换新建入口。 -4. Android remote client 是独立发布门槛;本重整不得在其真机验收前改变远程 gameplay API 合约。 +2. vNext 不迁移真实用户数据,不兼容旧 API、旧 schema 或旧页面;`main` 只作为产品行为和测试设计的参考。 +3. 世界是一个版本化 aggregate;WorldBook 是受限叙事知识层,不能直接写入运行态。 +4. Android 仍为 gameplay-only client,Mac 为 host;vNext 使用版本化的独立 API,而不是兼容旧远程接口。 +5. 每阶段只能按 vNext 成熟度矩阵累积证据;旧项目的 88.1 成熟度和 Android CI 不能转移为 vNext 分数。 ### 当前证据与风险 -- 新开放世界向导依次调用 `finalize framework -> create world -> create character -> create session`,是四个独立提交;中途失败或重试可留下孤儿资源。 -- `Session` 同时持有 `world_id` 与 `framework_id`,但两者无所有权关系;`DELETE /worlds/{id}?cascade=true` 不清理 framework 及其 session runtime 状态。 -- `delete_session_cascade()` 没有删除 `SessionLocationState`、`SessionNpcState`、`SessionEventState`、`SessionFactionState`、`SessionCampaignState`;SQLite 外键级联也未由应用开启。 -- 桌面 v0.16.0 的玩法成熟度为 88.1,但 Android remote acceptance 仍为 78.1,真机/LAN 验收未完成。 +- v0.16 的重叠领域模型与手工级联删除是 vNext clean-slate 的触发证据,而不是待迁移负担。 +- 桌面 v0.16.0 的玩法成熟度 88.1 可用于定义质量下限;vNext 首次评分为 0。 +- Android remote acceptance 78.1 证明已有功能方向,但 vNext 仍必须重新完成 Mac + Android + LAN 物理验收。 ### 下一关 -产品确认本规格的 P0 边界后,先建立临时数据库的创建失败、删除与迁移回归测试;测试能够证明无孤儿资源后,才实施数据模型迁移。 +在 `.worktrees/dzmm-vnext` 创建干净产品根与新数据库后,先完成 Phase 0 的可运行骨架和评分 harness;不得从 legacy 目录复制领域模型或 API。 diff --git a/docs/adr/ADR-001-world-domain-consolidation.md b/docs/adr/ADR-001-world-domain-consolidation.md index ac7aead..4234da2 100644 --- a/docs/adr/ADR-001-world-domain-consolidation.md +++ b/docs/adr/ADR-001-world-domain-consolidation.md @@ -1,6 +1,6 @@ # ADR-001:收敛世界领域为单一所有权聚合 -**状态:** Proposed +**状态:** Superseded by ADR-002 **日期:** 2026-08-16 **决策人:** 产品负责人、DZMM 工程负责人 diff --git a/docs/adr/ADR-002-vnext-clean-slate-rebuild.md b/docs/adr/ADR-002-vnext-clean-slate-rebuild.md new file mode 100644 index 0000000..7bf27af --- /dev/null +++ b/docs/adr/ADR-002-vnext-clean-slate-rebuild.md @@ -0,0 +1,98 @@ +# ADR-002:采用 vNext 隔离产品根的干净重做 + +**状态:** Accepted +**日期:** 2026-08-16 +**决策人:** DZMM 产品负责人 + +## Context + +用户明确不要求保留当前数据、旧 API 或旧实现兼容。当前项目经历 screenplay-first、framework、Python 规则引擎与 Android remote 等多轮叠加;继续在原结构上做兼容迁移会把历史表、双重语义和手工删除规则带入下一阶段。 + +v0.16 的核心玩法、模型协议与 Android 方案有可复用的产品经验,但不应成为 vNext 的代码依赖或成熟度得分。 + +## Decision + +在同一仓库的新 vNext 产品根中从零实现,而非修改 legacy `backend/`、`frontend/`、`mobile/`: + +```text +vnext/ +├── backend/ FastAPI + fresh SQLite schema + Alembic +├── desktop/ Tauri + Vue world-management shell +├── mobile/ Flutter Android gameplay-only client +├── contracts/ API/OpenAPI, event schema, world/run JSON schema +├── eval/ deterministic fixtures, model replay and score harness +└── packaging/ vNext-only build/release artifacts +``` + +vNext 的 core model 只有三个持续对象: + +```text +World ──> WorldVersion ──> Run ──> Turn[] + │ └── RunState (versioned JSON) + └── WorldDefinition (validated JSON: lore, graph, NPC, events, rules) +``` + +- `WorldVersion` 是不可变的已发布世界定义;编辑世界会生成新 version,已经开始的 Run 永远指向自己的 version。 +- `RunState` 是唯一的可变游戏状态,Python engine 接收已验证 command 后更新它;LLM 永不直写数据库。 +- `Turn` 同时记录 request id、玩家输入、叙事、command、state revision 与诊断,成为回滚与恢复的唯一审计单位。 +- Lorebook 是 WorldDefinition 的受限条目集,按明确 activation 选择上下文;只有用户显式“提升”为实体才改变结构世界。 +- Mac host 与 Android 走新的 `api_version=2` capability contract;Android 不管理模型、世界、密钥或删除操作。 + +## Options considered + +### A. 在 legacy 主线继续兼容迁移 + +| 维度 | 评估 | +|---|---| +| 交付速度 | 中 | +| 领域清晰度 | 低 | +| 历史负担 | 极高 | +| 数据风险 | 中 | + +优点:可以重用更多代码。 +缺点:需要维护三种创建/删除语义,无法真正移除老 schema 和 route。 + +**结论:拒绝。** + +### B. 同仓库隔离 vNext 根(采用) + +| 维度 | 评估 | +|---|---| +| 交付速度 | 中 | +| 领域清晰度 | 高 | +| 历史负担 | 低 | +| 数据风险 | 低 | + +优点:保留 GitHub、打包和学习资产;新编译/数据目录天然隔离;能逐项拿回成熟度证据。 +缺点:短期会存在两个产品根,必须防止直接复制 legacy 领域代码。 + +**结论:采用。** + +### C. 新建独立仓库 + +| 维度 | 评估 | +|---|---| +| 交付速度 | 低 | +| 领域清晰度 | 高 | +| 运维摩擦 | 高 | +| 可追溯性 | 低 | + +优点:物理隔离最彻底。 +缺点:分散 issue、release、artifact 和上下文;当前不带来额外用户价值。 + +**结论:暂不采用。** + +## Consequences + +- vNext 不读取 `~/.dzmm/dzmm.db`;开发与 RC 使用独立 `~/.dzmm-vnext/` 目录和新的 application identifier。 +- 不做旧存档导入、旧 URL/API 兼容、旧模型配置迁移或旧世界迁移;用户需要手工导入内容包或新建。 +- 所有 schema 均由 Alembic 管理,SQLite foreign keys 在每个连接上显式开启;不再使用“仅加列”的迁移机制。 +- 新代码从最小 vertical slice 开始。可以借鉴 legacy 的测试场景、协议错误边界和 UI 文案,但不得引用其 ORM、route 或 service 模块。 +- promotion 到主产品前,需要在新的成熟度矩阵获得全部证据;不以 legacy 测试绿灯代替。 + +## Action items + +1. [ ] 创建 `feature/dzmm-vnext` 和 `.worktrees/dzmm-vnext`,初始化 vNext 产品根与独立 app data path。 +2. [ ] 锁定 contracts:WorldDefinition、RunState、TurnCommand、SSE event、API capability。 +3. [ ] 建立评分 harness 与 Phase 0 baseline,任何功能没有证据不得加分。 +4. [ ] 按 vNext spec 的 phase/gate 推进;每个 gate 更新 Active Delivery Index。 diff --git a/docs/superpowers/specs/2026-08-16-dzmm-vnext-clean-rebuild.md b/docs/superpowers/specs/2026-08-16-dzmm-vnext-clean-rebuild.md new file mode 100644 index 0000000..43a7d90 --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-dzmm-vnext-clean-rebuild.md @@ -0,0 +1,155 @@ +# DZMM vNext Clean Rebuild:规格、Plan 与成熟度矩阵 + +## Product thesis + +DZMM vNext 是一个本地优先的单人 AI TRPG:玩家在 Mac 建立可审阅的世界与角色,在桌面或 Android 继续同一局;Python 是规则和状态的唯一裁判,模型只负责叙事与意图。它消费酒馆生态的内容,但不成为酒馆式通用 prompt 工作台。 + +## Scope and constraints + +- **Clean slate:** 不迁移、读取或修改 v0.x 的数据,不兼容旧 API/schema/UI。开发与 RC 数据目录固定为 `~/.dzmm-vnext/`。 +- **保留:** 本地优先、FastAPI/Python engine、SQLite、SSE、Tauri desktop host、Flutter Android gameplay client 的技术方向。 +- **重做:** 数据库、route、domain service、frontend route、mobile API contract、打包 identifier。 +- **不做:** 云同步、多人桌、群聊 bot、任意脚本/regex、iOS、公共网络直连。 + +## P0 product requirements + +1. **一个世界,一次提交。** 向导产生草稿;确认后使用一个幂等 command 原子创建 World、WorldVersion、Hero、Run 与初始 RunState。 +2. **一个运行态。** 一局 Run 只读取固定 WorldVersion,所有可变地点/NPC/事件/物品/角色状态存于 versioned RunState。 +3. **一个回合真相。** Turn command 被 Python 验证和应用;模型流式叙事无法直接改状态;失败、断线、重试都最多提交一次。 +4. **一个内容边界。** Lorebook 可以通过关键词/常驻/优先级参与上下文,原始 ST 字段可保留;只有确认提升才成为结构实体。 +5. **一个主机。** Mac 保存模型、世界和 Run;Android 配对后仅能游玩、恢复和查看允许状态。 +6. **一个退出路径。** World 可归档或永久删除;删除影响由 manifest 预览,SQL、文件和索引无孤儿。 + +## Canonical architecture + +```text +World + └─ WorldVersion (immutable WorldDefinition JSON) + ├─ lore[] context-only + ├─ map / factions / NPC definitions / event definitions + └─ ruleset descriptor + +Run (world_version_id, hero, model profile) + ├─ RunState (revisioned JSON: all mutable gameplay facts) + └─ Turn[] (input, narrative, validated commands, before/after revisions, diagnostics) + +Mac host + ├─ ModelProfile (protocol + probe) + ├─ Python engine + TurnCoordinator + └─ API v2 / pairing / SSE resume + ├─ Vue/Tauri: authoring and host control + └─ Flutter: gameplay only +``` + +### Content contract + +`WorldDefinition` uses JSON Schema and versioning. It keeps all references inside one definition, so a world cannot partially own locations, events or NPCs in different tables. `RunState` uses a separate JSON Schema and an integer revision. Python commands operate on typed IDs from that definition; invalid IDs, invalid state transitions and arbitrary LLM attributes fail closed. + +The MVP deliberately uses JSON documents rather than an ORM table per world entity. A local single-player app needs atomic authoring, safe lifecycle and snapshot restoration more than cross-world SQL joins. Analytics/search can be added as derived indexes only after the aggregate is stable. + +## Maturity matrix — the only scoring system + +All vNext dimensions start at **0**. No legacy score, old CI result, mock, source copy or prior playtest earns a vNext point. Score only against committed code on `feature/dzmm-vnext` and recorded evidence. + +| Dimension | Weight | P0 threshold | 85-point evidence | +|---|---:|---:|---| +| Domain & lifecycle integrity | 15 | 80 | Atomic compose/retry/failure tests; archive/purge manifest; SQL/file/index orphan scan is zero. | +| Game loop & rule truth | 20 | 80 | Python commands, state revision, rollback, choices/dice/events and 30-turn real-model run pass. | +| Creation & content interoperability | 15 | 80 | One wizard journey; ST V3 card + World Info import report; Lore promotion and export round-trip pass. | +| Model & stream robustness | 10 | 80 | Ollama, LM Studio and OpenAI-compatible probes; malformed 200, empty stream, 429 and cancellation handled. | +| Desktop UX & accessibility | 10 | 80 | Create/play/archive/recover on packaged desktop; keyboard and screen-reader primary flow reviewed. | +| Mac host & Android recovery | 10 | 80 | QR/PIN/manual pair, revoke, SSE resume, Wi-Fi/Mac restart and concurrent submission on physical devices. | +| Long-play performance | 10 | 80 | 50-turn run, 500-message reopen and target-device streaming budgets measured; no state corruption. | +| Engineering & release readiness | 10 | 80 | Fresh-db migrations, contract/e2e suite, signed RC artifacts, diagnostics export and release checklist pass. | +| **Total** | **100** | **all P0 >=80** | **weighted score >=85, no open P0 defect** | + +### Score evidence rules + +- 0–39: code/design only, or an untested capability. +- 40–64: deterministic unit/contract coverage, no full user journey. +- 65–79: local integration/E2E with temporary DB and deterministic/fake provider. +- 80–84: packaged app plus real provider or real device evidence for the dimension. +- 85+: repeated target environment evidence, recovery/failure coverage, no unresolved P0 defect. + +Each gate records command output, environment, artifact/commit, score delta and remaining gap in `docs/ACTIVE_DELIVERY_INDEX.md`. A dimension never receives a higher score by inference from another dimension. + +## Delivery Plan + +### Phase 0 — contracts and score harness + +**Outcome:** a blank vNext product root can boot against a fresh isolated database and report zero-score baseline. + +- Create `vnext/` root, application identifier and `~/.dzmm-vnext/` config/data path. +- Define JSON Schemas for WorldDefinition, RunState, TurnCommand and protocol event envelopes. +- Create Alembic baseline and enforce SQLite foreign keys on every connection. +- Build `eval/scorecard` that reads evidence files and produces the matrix; start each dimension at 0. + +**Gate:** API health, schema validation and a fresh DB smoke pass. Total may remain below 20; no legacy imports. + +### Phase 1 — playable vertical slice + +**Outcome:** user can compose a two-location world, one hero and one run through a single command, then play three recoverable turns on desktop. + +- Implement World/WorldVersion/Run/Turn persistence and atomic idempotent compose. +- Implement Python command validation for narrative, choices, dice and basic state change. +- Implement model profiles/probe and SSE turn stream with request id. +- Deliver a minimal Vue desktop flow: Create → Confirm → Play → Refresh/recover. + +**Gate:** 20 failure injections yield either 0 or 1 complete aggregate; 3/3 turns recover after refresh. Matrix target: **>=45**, Domain and Game Loop >=40. + +### Phase 2 — lifecycle, real rules and long play + +**Outcome:** vNext is a trustworthy local game, even before content imports or Android. + +- Add archive/purge and manifest impact preview; include asset/index cleanup and integrity scan. +- Expand RunState and engine for inventory, combat, NPC/event state, location travel and rollback. +- Add director context builder that only consumes WorldVersion + RunState; remove screenplay concept entirely. +- Package the desktop host and run a real model 30-turn journey. + +**Gate:** purge scan reports zero orphans; rollback and 30-turn real-model run pass. Matrix target: **>=65**, Domain/Game Loop/Model >=65. + +### Phase 3 — content ecosystem and authoring quality + +**Outcome:** existing RP content becomes useful without turning the product into a prompt editor. + +- Implement LoreEntry selection, context budget diagnostics and explicit promotion workflow. +- Import/export ST V3 character cards, PNG metadata and World Info with supported/preserved/ignored report. +- Add World Center: draft, playable and archived state; edit creates new WorldVersion and never mutates active runs. +- Add Do / Say / Story input modes, per-turn edit/retry/rollback and player-visible world state. + +**Gate:** 10 mixed World Info entries round-trip unknown fields; create/import/archive/recover E2E passes. Matrix target: **>=75**, Content and UX >=70. + +### Phase 4 — mobile host and release candidate + +**Outcome:** Android is a reliable remote play surface, not a web wrapper. + +- Rebuild remote control plane on API v2: capability discovery, mDNS/subnet/manual, QR/PIN approval, token revoke and allowlist. +- Implement Flutter session/run hydration, state/choice view, stream/reconnect/gap recovery and accessibility semantics. +- Perform physical Android + two-router + mDNS-blocked + DHCP-change matrix. +- Produce signed Android RC and packaged Mac host; run real-model 30-turn Android journey and physical 100-turn disconnect soak. + +**Gate:** Matrix **>=85**, every P0 >=80, no P0 defect. This is the only release gate. + +## Expected experience impact + +| User outcome | Current v0.x concern | vNext target | +|---|---|---| +| “我创建的到底是什么?” | World/Framework/Screenplay overlap | One confirmed WorldVersion and one Run. | +| “失败后留下了什么?” | Four sequential writes | One atomic request and visible draft state. | +| “删了是否干净?” | Hand-maintained cascades | Archive first; purge manifest plus zero-orphan scan. | +| “酒馆内容能否用?” | Manual copy/paste | Import report, safe Lore activation and explicit entity promotion. | +| “手机是否能顺畅玩?” | Current RC has unrun physical gate | New versioned host contract validated on real LAN/device. | + +## Explicit non-goals and risk controls + +- 不把 v0.x 的成熟度、测试或真实用户库当成 vNext 的验收替代品。 +- 不删除或改写现有 `~/.dzmm` 数据;vNext 数据目录、bundle id、端口/instance identity 独立。 +- 不因“重做”扩大成多人、云、插件市场或任意 prompt scripting。 +- 如果模型质量影响叙事,记录为模型维度问题;不能借由放宽 Python 状态校验来掩盖。 + +## First implementation decision + +开始代码前只需确认两项: + +1. vNext 的用户可见名称是否仍为 **DZMM**,还是先以 **DZMM Next / vNext Preview** 发布?建议 Preview,避免覆盖已安装应用。 +2. Phase 1 的真实模型基准是否固定为 `huihui-ai_qwen3-14b-abliterated`,并用本地 Qwen 7B 做快速工程测试?建议是。 diff --git a/docs/superpowers/specs/2026-08-16-world-lifecycle-and-content-interop.md b/docs/superpowers/specs/2026-08-16-world-lifecycle-and-content-interop.md index dcc8063..c0bf6b9 100644 --- a/docs/superpowers/specs/2026-08-16-world-lifecycle-and-content-interop.md +++ b/docs/superpowers/specs/2026-08-16-world-lifecycle-and-content-interop.md @@ -1,5 +1,9 @@ # DZMM 世界生命周期与内容互通规格 +> **状态:已被 vNext 规格取代。** 用户已明确不保留现有数据和兼容性;请使用 +> [`2026-08-16-dzmm-vnext-clean-rebuild.md`](2026-08-16-dzmm-vnext-clean-rebuild.md) +> 作为唯一实施计划。本文件仅保留问题证据与早期设计讨论。 + ## Problem statement 玩家创建开放世界时,实际会写入多套互不绑定的资源;失败、重试或删除后无法确信世界是否完整、是否仍有隐性数据。与此同时,酒馆生态中可复用的角色卡和条目式世界书无法自然进入 DZMM,用户必须从零输入设定。 From d22cfdd844efa51a9c2b003fdddc457588408448 Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 22:16:20 +0800 Subject: [PATCH 003/126] docs: lock vnext model evaluation baseline --- .../specs/2026-08-16-dzmm-vnext-clean-rebuild.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-08-16-dzmm-vnext-clean-rebuild.md b/docs/superpowers/specs/2026-08-16-dzmm-vnext-clean-rebuild.md index 43a7d90..c86179b 100644 --- a/docs/superpowers/specs/2026-08-16-dzmm-vnext-clean-rebuild.md +++ b/docs/superpowers/specs/2026-08-16-dzmm-vnext-clean-rebuild.md @@ -10,6 +10,7 @@ DZMM vNext 是一个本地优先的单人 AI TRPG:玩家在 Mac 建立可审 - **保留:** 本地优先、FastAPI/Python engine、SQLite、SSE、Tauri desktop host、Flutter Android gameplay client 的技术方向。 - **重做:** 数据库、route、domain service、frontend route、mobile API contract、打包 identifier。 - **不做:** 云同步、多人桌、群聊 bot、任意脚本/regex、iOS、公共网络直连。 +- **模型基准:** 本地 Qwen 7B 用于快速工程/断线/压力测试;台式机 `huihui-ai_qwen3-14b-abliterated` 是正式真实跑团和成熟度评分模型;22B 只用于定位 prompt 与模型能力差异,不是发布门槛。 ## P0 product requirements @@ -149,7 +150,4 @@ Each gate records command output, environment, artifact/commit, score delta and ## First implementation decision -开始代码前只需确认两项: - -1. vNext 的用户可见名称是否仍为 **DZMM**,还是先以 **DZMM Next / vNext Preview** 发布?建议 Preview,避免覆盖已安装应用。 -2. Phase 1 的真实模型基准是否固定为 `huihui-ai_qwen3-14b-abliterated`,并用本地 Qwen 7B 做快速工程测试?建议是。 +vNext 先以 **DZMM Next Preview** 的 application identifier、数据目录与安装产物发布,避免覆盖现有 DZMM;正式模型基准已固定为 `huihui-ai_qwen3-14b-abliterated`,本地 Qwen 7B 用于快速工程测试。 From 401c7be73ddb9ab9264dc3b708c38ea6e93622f1 Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 22:26:09 +0800 Subject: [PATCH 004/126] feat: scaffold dzmm vnext phase zero --- docs/ACTIVE_DELIVERY_INDEX.md | 7 +- vnext/README.md | 26 +++++++ vnext/backend/alembic.ini | 38 ++++++++++ vnext/backend/migrations/env.py | 39 ++++++++++ vnext/backend/migrations/script.py.mako | 23 ++++++ .../versions/0001_phase0_baseline.py | 39 ++++++++++ vnext/backend/pyproject.toml | 35 +++++++++ vnext/backend/src/dzmm_vnext/__init__.py | 5 ++ vnext/backend/src/dzmm_vnext/config.py | 32 +++++++++ vnext/backend/src/dzmm_vnext/contracts.py | 35 +++++++++ vnext/backend/src/dzmm_vnext/db.py | 19 +++++ vnext/backend/src/dzmm_vnext/main.py | 45 ++++++++++++ vnext/backend/tests/test_phase0.py | 71 +++++++++++++++++++ vnext/contracts/event_envelope.schema.json | 14 ++++ vnext/contracts/run_state.schema.json | 17 +++++ vnext/contracts/turn_command.schema.json | 12 ++++ vnext/contracts/world_definition.schema.json | 22 ++++++ vnext/desktop/README.md | 4 ++ vnext/eval/evidence/phase0.json | 14 ++++ vnext/eval/scorecard.py | 49 +++++++++++++ vnext/mobile/README.md | 5 ++ vnext/packaging/README.md | 4 ++ 22 files changed, 552 insertions(+), 3 deletions(-) create mode 100644 vnext/README.md create mode 100644 vnext/backend/alembic.ini create mode 100644 vnext/backend/migrations/env.py create mode 100644 vnext/backend/migrations/script.py.mako create mode 100644 vnext/backend/migrations/versions/0001_phase0_baseline.py create mode 100644 vnext/backend/pyproject.toml create mode 100644 vnext/backend/src/dzmm_vnext/__init__.py create mode 100644 vnext/backend/src/dzmm_vnext/config.py create mode 100644 vnext/backend/src/dzmm_vnext/contracts.py create mode 100644 vnext/backend/src/dzmm_vnext/db.py create mode 100644 vnext/backend/src/dzmm_vnext/main.py create mode 100644 vnext/backend/tests/test_phase0.py create mode 100644 vnext/contracts/event_envelope.schema.json create mode 100644 vnext/contracts/run_state.schema.json create mode 100644 vnext/contracts/turn_command.schema.json create mode 100644 vnext/contracts/world_definition.schema.json create mode 100644 vnext/desktop/README.md create mode 100644 vnext/eval/evidence/phase0.json create mode 100644 vnext/eval/scorecard.py create mode 100644 vnext/mobile/README.md create mode 100644 vnext/packaging/README.md diff --git a/docs/ACTIVE_DELIVERY_INDEX.md b/docs/ACTIVE_DELIVERY_INDEX.md index 489b5bf..57ad422 100644 --- a/docs/ACTIVE_DELIVERY_INDEX.md +++ b/docs/ACTIVE_DELIVERY_INDEX.md @@ -2,9 +2,9 @@ ## DZMM vNext 干净重做 -- **状态:** Active — clean-slate scope confirmed +- **状态:** Active — Phase 0 gate passed; Phase 1 is next - **日期:** 2026-08-16 -- **工作树 / 分支:** `.worktrees/dzmm-domain-consolidation-review` / `docs/dzmm-domain-consolidation-review` +- **工作树 / 分支:** `.worktrees/dzmm-vnext` / `feature/dzmm-vnext` - **基线:** `main` at `df38037` (`v0.16.0`) - **目标:** 在隔离的 vNext 产品根中重建 DZMM:单一版本化世界聚合、可恢复 Python-first 回合、受限 Lorebook/内容导入、Mac host 与 Android gameplay client。旧数据库、旧 API 和旧代码不构成迁移或兼容约束。 @@ -28,7 +28,8 @@ - v0.16 的重叠领域模型与手工级联删除是 vNext clean-slate 的触发证据,而不是待迁移负担。 - 桌面 v0.16.0 的玩法成熟度 88.1 可用于定义质量下限;vNext 首次评分为 0。 - Android remote acceptance 78.1 证明已有功能方向,但 vNext 仍必须重新完成 Mac + Android + LAN 物理验收。 +- **Phase 0 已验证:** Python 3.13.3 的独立 venv 中,`pytest -q` 为 `4 passed`、`ruff check src tests` 通过;临时 `DZMM_NEXT_DATA_DIR` 执行 `alembic upgrade head` 后,`schema_meta` 有 app/api/contract 三条基线记录、`alembic_version=0001_phase0`;`/health` 返回 `api_version=2`、四份契约、`storage=isolated` 与 `foreign_keys=true`。评分器读取 `vnext/eval/evidence/phase0.json` 得 **0.0/100**、所有 P0 未达标、不可发布——这是尚未实现功能的正确零基线,不得据此加分。 ### 下一关 -在 `.worktrees/dzmm-vnext` 创建干净产品根与新数据库后,先完成 Phase 0 的可运行骨架和评分 harness;不得从 legacy 目录复制领域模型或 API。 +Phase 1:实现 `World → WorldVersion → Run → Turn[]` 的原子 idempotent compose、首次 `RunState` 与三回合可恢复桌面竖切;以 20 次失败注入和刷新恢复结果争取总分至少 45、Domain 与 Game Loop 至少 40。不得从 legacy 目录复制领域模型或 API。 diff --git a/vnext/README.md b/vnext/README.md new file mode 100644 index 0000000..5a4d923 --- /dev/null +++ b/vnext/README.md @@ -0,0 +1,26 @@ +# DZMM Next Preview + +This is a clean product root for DZMM vNext. It intentionally has no import +path to the legacy application and defaults to a separate `~/.dzmm-vnext/` +data directory. + +Phase 0 provides the contracts, fresh database baseline, API v2 health check, +and scorecard harness. It is not yet a playable game. + +## Layout + +- `backend/`: FastAPI host, fresh schema and Alembic migrations. +- `contracts/`: versioned JSON Schemas shared by clients and server. +- `desktop/`: future Tauri/Vue authoring and host-control shell. +- `mobile/`: future Flutter gameplay-only client. +- `eval/`: evidence-first maturity scorecard. +- `packaging/`: vNext-only release assembly. + +## Phase 0 verification + +```bash +cd vnext/backend +python -m alembic upgrade head +python -m pytest -q +python ../eval/scorecard.py ../eval/evidence/phase0.json +``` diff --git a/vnext/backend/alembic.ini b/vnext/backend/alembic.ini new file mode 100644 index 0000000..fc73764 --- /dev/null +++ b/vnext/backend/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = %(here)s/migrations +prepend_sys_path = %(here)s/src +path_separator = os +sqlalchemy.url = sqlite:///./dzmm-next.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/vnext/backend/migrations/env.py b/vnext/backend/migrations/env.py new file mode 100644 index 0000000..4b4b5d7 --- /dev/null +++ b/vnext/backend/migrations/env.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context + +from dzmm_vnext.config import Settings + + +config = context.config +if config.config_file_name: + fileConfig(config.config_file_name) + + +def run_migrations_offline() -> None: + settings = Settings.from_env() + context.configure(url=settings.sync_database_url, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + settings = Settings.from_env() + settings.ensure_layout() + from sqlalchemy import create_engine + + connectable = create_engine(settings.sync_database_url, future=True) + with connectable.connect() as connection: + connection.exec_driver_sql("PRAGMA foreign_keys=ON") + connection.commit() + context.configure(connection=connection) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/vnext/backend/migrations/script.py.mako b/vnext/backend/migrations/script.py.mako new file mode 100644 index 0000000..ab749b4 --- /dev/null +++ b/vnext/backend/migrations/script.py.mako @@ -0,0 +1,23 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from alembic import op +import sqlalchemy as sa + + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/vnext/backend/migrations/versions/0001_phase0_baseline.py b/vnext/backend/migrations/versions/0001_phase0_baseline.py new file mode 100644 index 0000000..de8d258 --- /dev/null +++ b/vnext/backend/migrations/versions/0001_phase0_baseline.py @@ -0,0 +1,39 @@ +"""phase 0 baseline + +Revision ID: 0001_phase0 +Revises: +Create Date: 2026-08-16 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0001_phase0" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "schema_meta", + sa.Column("key", sa.String(length=80), primary_key=True), + sa.Column("value", sa.String(length=255), nullable=False), + ) + op.bulk_insert( + sa.table( + "schema_meta", + sa.column("key", sa.String), + sa.column("value", sa.String), + ), + [ + {"key": "app", "value": "dzmm-next"}, + {"key": "api_version", "value": "2"}, + {"key": "contract_version", "value": "2026-08-16"}, + ], + ) + + +def downgrade() -> None: + op.drop_table("schema_meta") diff --git a/vnext/backend/pyproject.toml b/vnext/backend/pyproject.toml new file mode 100644 index 0000000..eea963c --- /dev/null +++ b/vnext/backend/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "dzmm-next-backend" +version = "0.1.0" +description = "Clean-slate backend for DZMM Next Preview" +requires-python = ">=3.11" +dependencies = [ + "alembic>=1.14", + "aiosqlite>=0.19", + "fastapi>=0.110", + "jsonschema>=4.23", + "sqlalchemy[asyncio]>=2.0", + "uvicorn[standard]>=0.27", +] + +[project.optional-dependencies] +dev = [ + "httpx>=0.27", + "pytest>=8.0", + "ruff>=0.8", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/dzmm_vnext"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.ruff] +line-length = 100 +target-version = "py311" diff --git a/vnext/backend/src/dzmm_vnext/__init__.py b/vnext/backend/src/dzmm_vnext/__init__.py new file mode 100644 index 0000000..d416132 --- /dev/null +++ b/vnext/backend/src/dzmm_vnext/__init__.py @@ -0,0 +1,5 @@ +"""DZMM Next Preview backend.""" + +APP_NAME = "dzmm-next" +API_VERSION = 2 +CONTRACT_VERSION = "2026-08-16" diff --git a/vnext/backend/src/dzmm_vnext/config.py b/vnext/backend/src/dzmm_vnext/config.py new file mode 100644 index 0000000..d803e02 --- /dev/null +++ b/vnext/backend/src/dzmm_vnext/config.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +DEFAULT_DATA_DIR = Path.home() / ".dzmm-vnext" + + +@dataclass(frozen=True) +class Settings: + data_dir: Path + + @property + def database_path(self) -> Path: + return self.data_dir / "dzmm-next.db" + + @property + def database_url(self) -> str: + return f"sqlite+aiosqlite:///{self.database_path}" + + @property + def sync_database_url(self) -> str: + return f"sqlite:///{self.database_path}" + + @classmethod + def from_env(cls) -> Settings: + value = os.environ.get("DZMM_NEXT_DATA_DIR") + return cls(data_dir=Path(value).expanduser() if value else DEFAULT_DATA_DIR) + + def ensure_layout(self) -> None: + self.data_dir.mkdir(parents=True, exist_ok=True) diff --git a/vnext/backend/src/dzmm_vnext/contracts.py b/vnext/backend/src/dzmm_vnext/contracts.py new file mode 100644 index 0000000..078e93e --- /dev/null +++ b/vnext/backend/src/dzmm_vnext/contracts.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from jsonschema import Draft202012Validator + +from . import CONTRACT_VERSION + + +def contracts_dir() -> Path: + return Path(__file__).resolve().parents[3] / "contracts" + + +def load_contracts() -> dict[str, dict]: + contracts: dict[str, dict] = {} + for path in sorted(contracts_dir().glob("*.schema.json")): + payload = json.loads(path.read_text(encoding="utf-8")) + Draft202012Validator.check_schema(payload) + contracts[path.name] = payload + if not contracts: + raise RuntimeError("no vNext contracts found") + return contracts + + +def contract_manifest() -> dict[str, object]: + return {"version": CONTRACT_VERSION, "contracts": sorted(load_contracts())} + + +def contract_validator(name: str) -> Draft202012Validator: + try: + contract = load_contracts()[name] + except KeyError as error: + raise ValueError(f"unknown vNext contract: {name}") from error + return Draft202012Validator(contract) diff --git a/vnext/backend/src/dzmm_vnext/db.py b/vnext/backend/src/dzmm_vnext/db.py new file mode 100644 index 0000000..ff9dd65 --- /dev/null +++ b/vnext/backend/src/dzmm_vnext/db.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from sqlalchemy import event +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from .config import Settings + + +def create_engine(settings: Settings) -> AsyncEngine: + settings.ensure_layout() + engine = create_async_engine(settings.database_url, future=True) + + @event.listens_for(engine.sync_engine, "connect") + def enable_foreign_keys(dbapi_connection, _connection_record) -> None: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + return engine diff --git a/vnext/backend/src/dzmm_vnext/main.py b/vnext/backend/src/dzmm_vnext/main.py new file mode 100644 index 0000000..20906a1 --- /dev/null +++ b/vnext/backend/src/dzmm_vnext/main.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from sqlalchemy import text + +from . import API_VERSION, APP_NAME +from .config import Settings +from .contracts import contract_manifest +from .db import create_engine + + +def create_app(settings: Settings | None = None) -> FastAPI: + resolved_settings = settings or Settings.from_env() + + @asynccontextmanager + async def lifespan(app: FastAPI): + resolved_settings.ensure_layout() + app.state.settings = resolved_settings + app.state.contracts = contract_manifest() + app.state.engine = create_engine(resolved_settings) + try: + yield + finally: + await app.state.engine.dispose() + + app = FastAPI(title="DZMM Next Preview", version="0.1.0", lifespan=lifespan) + + @app.get("/health") + async def health() -> dict[str, object]: + async with app.state.engine.connect() as connection: + foreign_keys = (await connection.execute(text("PRAGMA foreign_keys"))).scalar_one() + return { + "app": APP_NAME, + "api_version": API_VERSION, + "contract": app.state.contracts, + "storage": "isolated", + "foreign_keys": bool(foreign_keys), + } + + return app + + +app = create_app() diff --git a/vnext/backend/tests/test_phase0.py b/vnext/backend/tests/test_phase0.py new file mode 100644 index 0000000..c936922 --- /dev/null +++ b/vnext/backend/tests/test_phase0.py @@ -0,0 +1,71 @@ +import sqlite3 +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from fastapi.testclient import TestClient +from jsonschema import ValidationError + +from dzmm_vnext.config import Settings +from dzmm_vnext.contracts import contract_manifest, contract_validator +from dzmm_vnext.main import create_app + + +def test_contract_manifest_contains_every_vnext_contract() -> None: + manifest = contract_manifest() + assert manifest["version"] == "2026-08-16" + assert manifest["contracts"] == [ + "event_envelope.schema.json", + "run_state.schema.json", + "turn_command.schema.json", + "world_definition.schema.json", + ] + + +def test_health_uses_isolated_fresh_data_directory(tmp_path) -> None: + app = create_app(Settings(data_dir=tmp_path / "dzmm-vnext-test")) + with TestClient(app) as client: + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == { + "app": "dzmm-next", + "api_version": 2, + "contract": contract_manifest(), + "storage": "isolated", + "foreign_keys": True, + } + + +def test_contract_validators_reject_incomplete_payloads() -> None: + world = { + "schema_version": 1, + "name": "Fog Harbor", + "lore": [], + "locations": [], + "factions": [], + "npcs": [], + "events": [], + "ruleset": {"id": "core"}, + } + contract_validator("world_definition.schema.json").validate(world) + + with pytest.raises(ValidationError): + contract_validator("run_state.schema.json").validate({"schema_version": 1}) + + +def test_fresh_database_migration_records_vnext_baseline(tmp_path, monkeypatch) -> None: + data_dir = tmp_path / "dzmm-vnext-migration" + monkeypatch.setenv("DZMM_NEXT_DATA_DIR", str(data_dir)) + config = Config(str(Path(__file__).parents[1] / "alembic.ini")) + + command.upgrade(config, "head") + + with sqlite3.connect(data_dir / "dzmm-next.db") as connection: + rows = connection.execute("SELECT key, value FROM schema_meta ORDER BY key").fetchall() + assert rows == [ + ("api_version", "2"), + ("app", "dzmm-next"), + ("contract_version", "2026-08-16"), + ] diff --git a/vnext/contracts/event_envelope.schema.json b/vnext/contracts/event_envelope.schema.json new file mode 100644 index 0000000..66220bd --- /dev/null +++ b/vnext/contracts/event_envelope.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dzmm.local/contracts/event-envelope/2026-08-16", + "title": "EventEnvelope", + "type": "object", + "required": ["id", "type", "run_id", "payload"], + "properties": { + "id": {"type": "integer", "minimum": 1}, + "type": {"enum": ["turn_started", "narrative_delta", "command_applied", "turn_completed", "turn_error", "event_gap"]}, + "run_id": {"type": "string", "minLength": 1}, + "payload": {"type": "object"} + }, + "additionalProperties": false +} diff --git a/vnext/contracts/run_state.schema.json b/vnext/contracts/run_state.schema.json new file mode 100644 index 0000000..08d77fc --- /dev/null +++ b/vnext/contracts/run_state.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dzmm.local/contracts/run-state/2026-08-16", + "title": "RunState", + "type": "object", + "required": ["schema_version", "revision", "hero", "location_id", "inventory", "entities", "events"], + "properties": { + "schema_version": {"const": 1}, + "revision": {"type": "integer", "minimum": 0}, + "hero": {"type": "object"}, + "location_id": {"type": "string"}, + "inventory": {"type": "array", "items": {"type": "object"}}, + "entities": {"type": "object"}, + "events": {"type": "object"} + }, + "additionalProperties": false +} diff --git a/vnext/contracts/turn_command.schema.json b/vnext/contracts/turn_command.schema.json new file mode 100644 index 0000000..a006eaa --- /dev/null +++ b/vnext/contracts/turn_command.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dzmm.local/contracts/turn-command/2026-08-16", + "title": "TurnCommand", + "type": "object", + "required": ["type"], + "properties": { + "type": {"enum": ["narrate", "offer_choices", "roll_dice", "move", "set_entity_state", "set_event_state", "inventory_change"]}, + "payload": {"type": "object"} + }, + "additionalProperties": false +} diff --git a/vnext/contracts/world_definition.schema.json b/vnext/contracts/world_definition.schema.json new file mode 100644 index 0000000..f8f092e --- /dev/null +++ b/vnext/contracts/world_definition.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dzmm.local/contracts/world-definition/2026-08-16", + "title": "WorldDefinition", + "type": "object", + "required": ["schema_version", "name", "lore", "locations", "factions", "npcs", "events", "ruleset"], + "properties": { + "schema_version": {"const": 1}, + "name": {"type": "string", "minLength": 1, "maxLength": 120}, + "lore": {"type": "array", "items": {"$ref": "#/$defs/loreEntry"}}, + "locations": {"type": "array", "items": {"$ref": "#/$defs/entity"}}, + "factions": {"type": "array", "items": {"$ref": "#/$defs/entity"}}, + "npcs": {"type": "array", "items": {"$ref": "#/$defs/entity"}}, + "events": {"type": "array", "items": {"$ref": "#/$defs/entity"}}, + "ruleset": {"type": "object", "required": ["id"], "properties": {"id": {"type": "string", "minLength": 1}}, "additionalProperties": false} + }, + "additionalProperties": false, + "$defs": { + "entity": {"type": "object", "required": ["id", "name"], "properties": {"id": {"type": "string", "pattern": "^[a-z][a-z0-9_-]{0,63}$"}, "name": {"type": "string", "minLength": 1}}, "additionalProperties": true}, + "loreEntry": {"type": "object", "required": ["id", "title", "body", "activation", "priority"], "properties": {"id": {"type": "string", "pattern": "^[a-z][a-z0-9_-]{0,63}$"}, "title": {"type": "string", "minLength": 1}, "body": {"type": "string"}, "activation": {"enum": ["always", "keyword"]}, "keywords": {"type": "array", "items": {"type": "string"}}, "priority": {"type": "integer", "minimum": 0, "maximum": 100}, "source": {"type": "object"}}, "additionalProperties": false} + } +} diff --git a/vnext/desktop/README.md b/vnext/desktop/README.md new file mode 100644 index 0000000..763f921 --- /dev/null +++ b/vnext/desktop/README.md @@ -0,0 +1,4 @@ +# vNext desktop + +Phase 0 reserves this root for the Tauri/Vue authoring and Mac host-control +shell. It must use API v2 contracts only and never import legacy frontend code. diff --git a/vnext/eval/evidence/phase0.json b/vnext/eval/evidence/phase0.json new file mode 100644 index 0000000..312a561 --- /dev/null +++ b/vnext/eval/evidence/phase0.json @@ -0,0 +1,14 @@ +{ + "commit": "feature/dzmm-vnext Phase 0 baseline", + "environment": "Python 3.13.3; fresh temporary DZMM_NEXT_DATA_DIR; no legacy database", + "scores": { + "domain_lifecycle": 0, + "game_loop": 0, + "creation_content": 0, + "model_stream": 0, + "desktop_ux": 0, + "mobile_recovery": 0, + "long_play_performance": 0, + "engineering_release": 0 + } +} diff --git a/vnext/eval/scorecard.py b/vnext/eval/scorecard.py new file mode 100644 index 0000000..30c4c31 --- /dev/null +++ b/vnext/eval/scorecard.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +DIMENSIONS = { + "domain_lifecycle": 15, + "game_loop": 20, + "creation_content": 15, + "model_stream": 10, + "desktop_ux": 10, + "mobile_recovery": 10, + "long_play_performance": 10, + "engineering_release": 10, +} + + +def score(evidence: dict[str, object]) -> dict[str, object]: + scores = evidence.get("scores") + if not isinstance(scores, dict) or set(scores) != set(DIMENSIONS): + raise ValueError("scores must contain every vNext maturity dimension exactly once") + values: dict[str, int] = {} + for name in DIMENSIONS: + value = scores[name] + if not isinstance(value, int) or not 0 <= value <= 100: + raise ValueError(f"{name} must be an integer from 0 to 100") + values[name] = value + weighted = sum(values[name] * weight for name, weight in DIMENSIONS.items()) / 100 + return { + "scores": values, + "weighted_score": round(weighted, 2), + "all_p0_at_least_80": all(value >= 80 for value in values.values()), + "release_ready": weighted >= 85 and all(value >= 80 for value in values.values()), + } + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print("usage: scorecard.py EVIDENCE.json", file=sys.stderr) + return 2 + result = score(json.loads(Path(argv[1]).read_text(encoding="utf-8"))) + print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/vnext/mobile/README.md b/vnext/mobile/README.md new file mode 100644 index 0000000..094f543 --- /dev/null +++ b/vnext/mobile/README.md @@ -0,0 +1,5 @@ +# vNext mobile + +Phase 0 reserves this root for the Flutter Android gameplay-only client. It +will pair with the vNext Mac host and will not manage models, worlds or data +deletion. diff --git a/vnext/packaging/README.md b/vnext/packaging/README.md new file mode 100644 index 0000000..ceb7f45 --- /dev/null +++ b/vnext/packaging/README.md @@ -0,0 +1,4 @@ +# vNext packaging + +All vNext artifacts use a distinct application identifier and user-data path +until the release matrix is complete. No v0.x installation is overwritten. From 3065fa4302c85c8c94d472f4ac31546a3196bef6 Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 22:26:33 +0800 Subject: [PATCH 005/126] docs: record vnext phase zero evidence --- docs/ACTIVE_DELIVERY_INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ACTIVE_DELIVERY_INDEX.md b/docs/ACTIVE_DELIVERY_INDEX.md index 57ad422..7060d06 100644 --- a/docs/ACTIVE_DELIVERY_INDEX.md +++ b/docs/ACTIVE_DELIVERY_INDEX.md @@ -28,7 +28,7 @@ - v0.16 的重叠领域模型与手工级联删除是 vNext clean-slate 的触发证据,而不是待迁移负担。 - 桌面 v0.16.0 的玩法成熟度 88.1 可用于定义质量下限;vNext 首次评分为 0。 - Android remote acceptance 78.1 证明已有功能方向,但 vNext 仍必须重新完成 Mac + Android + LAN 物理验收。 -- **Phase 0 已验证:** Python 3.13.3 的独立 venv 中,`pytest -q` 为 `4 passed`、`ruff check src tests` 通过;临时 `DZMM_NEXT_DATA_DIR` 执行 `alembic upgrade head` 后,`schema_meta` 有 app/api/contract 三条基线记录、`alembic_version=0001_phase0`;`/health` 返回 `api_version=2`、四份契约、`storage=isolated` 与 `foreign_keys=true`。评分器读取 `vnext/eval/evidence/phase0.json` 得 **0.0/100**、所有 P0 未达标、不可发布——这是尚未实现功能的正确零基线,不得据此加分。 +- **Phase 0 已验证:** 实现提交为 `401c7be`。Python 3.13.3 的独立 venv 中,`pytest -q` 为 `4 passed`、`ruff check src tests` 通过;临时 `DZMM_NEXT_DATA_DIR` 执行 `alembic upgrade head` 后,`schema_meta` 有 app/api/contract 三条基线记录、`alembic_version=0001_phase0`;`/health` 返回 `api_version=2`、四份契约、`storage=isolated` 与 `foreign_keys=true`。评分器读取 `vnext/eval/evidence/phase0.json` 得 **0.0/100**、所有 P0 未达标、不可发布——这是尚未实现功能的正确零基线,不得据此加分。 ### 下一关 From 9bf5f4f430f30db51a319516b9753ee82d8a5c6b Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 22:34:35 +0800 Subject: [PATCH 006/126] feat: add vnext world aggregate and turns --- .../versions/0002_phase1_world_aggregate.py | 86 +++++++ vnext/backend/src/dzmm_vnext/main.py | 86 ++++++- vnext/backend/src/dzmm_vnext/persistence.py | 86 +++++++ vnext/backend/src/dzmm_vnext/turns.py | 235 ++++++++++++++++++ vnext/backend/src/dzmm_vnext/worlds.py | 212 ++++++++++++++++ vnext/backend/tests/test_world_compose.py | 190 ++++++++++++++ 6 files changed, 894 insertions(+), 1 deletion(-) create mode 100644 vnext/backend/migrations/versions/0002_phase1_world_aggregate.py create mode 100644 vnext/backend/src/dzmm_vnext/persistence.py create mode 100644 vnext/backend/src/dzmm_vnext/turns.py create mode 100644 vnext/backend/src/dzmm_vnext/worlds.py create mode 100644 vnext/backend/tests/test_world_compose.py diff --git a/vnext/backend/migrations/versions/0002_phase1_world_aggregate.py b/vnext/backend/migrations/versions/0002_phase1_world_aggregate.py new file mode 100644 index 0000000..b5d48ea --- /dev/null +++ b/vnext/backend/migrations/versions/0002_phase1_world_aggregate.py @@ -0,0 +1,86 @@ +"""phase 1 world aggregate + +Revision ID: 0002_phase1 +Revises: 0001_phase0 +Create Date: 2026-08-16 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0002_phase1" +down_revision = "0001_phase0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "worlds", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + op.create_table( + "world_versions", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("world_id", sa.String(length=36), sa.ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False), + sa.Column("version_number", sa.Integer(), nullable=False), + sa.Column("definition", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.UniqueConstraint("world_id", "version_number", name="uq_world_versions_number"), + ) + op.create_table( + "heroes", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("profile", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + op.create_table( + "runs", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("world_version_id", sa.String(length=36), sa.ForeignKey("world_versions.id", ondelete="RESTRICT"), nullable=False), + sa.Column("hero_id", sa.String(length=36), sa.ForeignKey("heroes.id", ondelete="RESTRICT"), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("state", sa.JSON(), nullable=False), + sa.Column("state_revision", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_table( + "turns", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("run_id", sa.String(length=36), sa.ForeignKey("runs.id", ondelete="CASCADE"), nullable=False), + sa.Column("request_id", sa.String(length=80), nullable=False), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("player_input", sa.Text(), nullable=False), + sa.Column("narrative", sa.Text(), nullable=False), + sa.Column("commands", sa.JSON(), nullable=False), + sa.Column("outcomes", sa.JSON(), nullable=False), + sa.Column("before_revision", sa.Integer(), nullable=False), + sa.Column("after_revision", sa.Integer(), nullable=False), + sa.Column("after_state", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.UniqueConstraint("run_id", "request_id", name="uq_turns_run_request"), + sa.UniqueConstraint("run_id", "sequence", name="uq_turns_run_sequence"), + ) + op.create_table( + "compose_requests", + sa.Column("request_id", sa.String(length=80), primary_key=True), + sa.Column("fingerprint", sa.String(length=64), nullable=False), + sa.Column("world_id", sa.String(length=36), sa.ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False), + sa.Column("run_id", sa.String(length=36), sa.ForeignKey("runs.id", ondelete="CASCADE"), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("compose_requests") + op.drop_table("turns") + op.drop_table("runs") + op.drop_table("heroes") + op.drop_table("world_versions") + op.drop_table("worlds") diff --git a/vnext/backend/src/dzmm_vnext/main.py b/vnext/backend/src/dzmm_vnext/main.py index 20906a1..f2eae92 100644 --- a/vnext/backend/src/dzmm_vnext/main.py +++ b/vnext/backend/src/dzmm_vnext/main.py @@ -1,14 +1,31 @@ from __future__ import annotations from contextlib import asynccontextmanager +from typing import Any -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse, StreamingResponse from sqlalchemy import text +from sqlalchemy.ext.asyncio import async_sessionmaker from . import API_VERSION, APP_NAME from .config import Settings from .contracts import contract_manifest from .db import create_engine +from .turns import ( + RevisionConflictError, + RunNotFoundError, + TurnCoordinator, + TurnIdempotencyConflictError, + TurnInput, + TurnResult, +) +from .worlds import ( + ComposeWorldInput, + DomainValidationError, + IdempotencyConflictError, + WorldComposer, +) def create_app(settings: Settings | None = None) -> FastAPI: @@ -20,6 +37,9 @@ async def lifespan(app: FastAPI): app.state.settings = resolved_settings app.state.contracts = contract_manifest() app.state.engine = create_engine(resolved_settings) + app.state.sessions = async_sessionmaker(app.state.engine, expire_on_commit=False) + app.state.world_composer = WorldComposer(app.state.sessions) + app.state.turn_coordinator = TurnCoordinator(app.state.sessions) try: yield finally: @@ -39,7 +59,71 @@ async def health() -> dict[str, object]: "foreign_keys": bool(foreign_keys), } + @app.post("/api/v2/worlds:compose") + async def compose_world(payload: ComposeWorldInput) -> JSONResponse: + try: + result = await app.state.world_composer.compose(payload) + except DomainValidationError as error: + raise HTTPException(status_code=422, detail=str(error)) from error + except IdempotencyConflictError as error: + raise HTTPException(status_code=409, detail=str(error)) from error + return JSONResponse( + status_code=201 if result.created else 200, + content=result.model_dump(mode="json"), + ) + + @app.get("/api/v2/runs/{run_id}") + async def get_run(run_id: str) -> dict[str, object]: + snapshot = await app.state.world_composer.load_run(run_id) + if snapshot is None: + raise HTTPException(status_code=404, detail="run not found") + return snapshot.model_dump(mode="json") + + async def play_turn(run_id: str, payload: TurnInput) -> TurnResult: + try: + return await app.state.turn_coordinator.play(run_id, payload) + except RunNotFoundError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except (RevisionConflictError, TurnIdempotencyConflictError) as error: + raise HTTPException(status_code=409, detail=str(error)) from error + + @app.post("/api/v2/runs/{run_id}/turns") + async def create_turn(run_id: str, payload: TurnInput) -> JSONResponse: + result = await play_turn(run_id, payload) + return JSONResponse( + status_code=201 if result.created else 200, + content=result.model_dump(mode="json"), + ) + + @app.post("/api/v2/runs/{run_id}/turns:stream") + async def stream_turn(run_id: str, payload: TurnInput) -> StreamingResponse: + result = await play_turn(run_id, payload) + + async def events(): + event_id = result.sequence * 100 + yield _sse(event_id, "turn_started", {"revision": result.before_revision}) + yield _sse(event_id + 1, "narrative_delta", {"text": result.narrative}) + for index, outcome in enumerate(result.outcomes, start=2): + yield _sse(event_id + index, "command_applied", outcome) + yield _sse( + event_id + len(result.outcomes) + 2, + "turn_completed", + { + "turn_id": result.turn_id, + "revision": result.after_revision, + "created": result.created, + }, + ) + + return StreamingResponse(events(), media_type="text/event-stream") + return app app = create_app() + + +def _sse(event_id: int, event_type: str, payload: dict[str, Any]) -> str: + import json + + return f"id: {event_id}\nevent: {event_type}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" diff --git a/vnext/backend/src/dzmm_vnext/persistence.py b/vnext/backend/src/dzmm_vnext/persistence.py new file mode 100644 index 0000000..6586aa5 --- /dev/null +++ b/vnext/backend/src/dzmm_vnext/persistence.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from sqlalchemy import ( + JSON, + Column, + DateTime, + ForeignKey, + Integer, + MetaData, + String, + Table, + UniqueConstraint, +) + +metadata = MetaData() + +worlds = Table( + "worlds", + metadata, + Column("id", String(36), primary_key=True), + Column("name", String(120), nullable=False), + Column("status", String(20), nullable=False), + Column("created_at", DateTime(), nullable=False), +) + +world_versions = Table( + "world_versions", + metadata, + Column("id", String(36), primary_key=True), + Column("world_id", String(36), ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False), + Column("version_number", Integer(), nullable=False), + Column("definition", JSON(), nullable=False), + Column("created_at", DateTime(), nullable=False), + UniqueConstraint("world_id", "version_number", name="uq_world_versions_number"), +) + +heroes = Table( + "heroes", + metadata, + Column("id", String(36), primary_key=True), + Column("name", String(120), nullable=False), + Column("profile", JSON(), nullable=False), + Column("created_at", DateTime(), nullable=False), +) + +runs = Table( + "runs", + metadata, + Column("id", String(36), primary_key=True), + Column("world_version_id", String(36), ForeignKey("world_versions.id", ondelete="RESTRICT"), nullable=False), + Column("hero_id", String(36), ForeignKey("heroes.id", ondelete="RESTRICT"), nullable=False), + Column("status", String(20), nullable=False), + Column("state", JSON(), nullable=False), + Column("state_revision", Integer(), nullable=False), + Column("created_at", DateTime(), nullable=False), + Column("updated_at", DateTime(), nullable=False), +) + +turns = Table( + "turns", + metadata, + Column("id", String(36), primary_key=True), + Column("run_id", String(36), ForeignKey("runs.id", ondelete="CASCADE"), nullable=False), + Column("request_id", String(80), nullable=False), + Column("sequence", Integer(), nullable=False), + Column("player_input", String(), nullable=False), + Column("narrative", String(), nullable=False), + Column("commands", JSON(), nullable=False), + Column("outcomes", JSON(), nullable=False), + Column("before_revision", Integer(), nullable=False), + Column("after_revision", Integer(), nullable=False), + Column("after_state", JSON(), nullable=False), + Column("created_at", DateTime(), nullable=False), + UniqueConstraint("run_id", "request_id", name="uq_turns_run_request"), + UniqueConstraint("run_id", "sequence", name="uq_turns_run_sequence"), +) + +compose_requests = Table( + "compose_requests", + metadata, + Column("request_id", String(80), primary_key=True), + Column("fingerprint", String(64), nullable=False), + Column("world_id", String(36), ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False), + Column("run_id", String(36), ForeignKey("runs.id", ondelete="CASCADE"), nullable=False), + Column("created_at", DateTime(), nullable=False), +) diff --git a/vnext/backend/src/dzmm_vnext/turns.py b/vnext/backend/src/dzmm_vnext/turns.py new file mode 100644 index 0000000..650cf28 --- /dev/null +++ b/vnext/backend/src/dzmm_vnext/turns.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from copy import deepcopy +from datetime import UTC, datetime +from secrets import randbelow +from typing import Any +from uuid import uuid4 + +from jsonschema import ValidationError +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import func, insert, select, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from .contracts import contract_validator +from .persistence import runs, turns, world_versions + + +class TurnInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + request_id: str = Field(min_length=1, max_length=80) + expected_revision: int = Field(ge=0) + player_input: str = Field(min_length=1, max_length=4000) + commands: list[dict[str, Any]] = Field(min_length=1, max_length=16) + + +class TurnResult(BaseModel): + turn_id: str + sequence: int + narrative: str + commands: list[dict[str, Any]] + outcomes: list[dict[str, Any]] + before_revision: int + after_revision: int + state: dict[str, Any] + created: bool + + +class RunNotFoundError(ValueError): + pass + + +class RevisionConflictError(ValueError): + pass + + +class TurnIdempotencyConflictError(ValueError): + pass + + +class TurnCoordinator: + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + + async def play(self, run_id: str, payload: TurnInput) -> TurnResult: + async with self._session_factory() as session, session.begin(): + existing = await session.execute( + select(turns).where( + turns.c.run_id == run_id, + turns.c.request_id == payload.request_id, + ) + ) + existing_row = existing.mappings().one_or_none() + if existing_row: + if ( + existing_row["player_input"] != payload.player_input + or existing_row["commands"] != payload.commands + ): + raise TurnIdempotencyConflictError( + "request_id was already used for different turn input" + ) + return _turn_result(existing_row, created=False) + + result = await session.execute( + select(runs.c.state, runs.c.state_revision, world_versions.c.definition) + .join(world_versions, world_versions.c.id == runs.c.world_version_id) + .where(runs.c.id == run_id) + ) + run = result.mappings().one_or_none() + if run is None: + raise RunNotFoundError("run not found") + if payload.expected_revision != run["state_revision"]: + raise RevisionConflictError( + f"expected revision {payload.expected_revision}, current revision is {run['state_revision']}" + ) + + state = deepcopy(run["state"]) + outcomes = _apply_commands(state, run["definition"], payload.commands) + before_revision = run["state_revision"] + after_revision = before_revision + 1 + state["revision"] = after_revision + try: + contract_validator("run_state.schema.json").validate(state) + except ValidationError as error: + raise RevisionConflictError(f"engine created invalid RunState: {error.message}") from error + + changed = await session.execute( + update(runs) + .where(runs.c.id == run_id, runs.c.state_revision == before_revision) + .values( + state=state, + state_revision=after_revision, + updated_at=datetime.now(UTC).replace(tzinfo=None), + ) + ) + if changed.rowcount != 1: + raise RevisionConflictError("run changed while the turn was being applied") + + sequence = ( + await session.execute( + select(func.coalesce(func.max(turns.c.sequence), 0)).where(turns.c.run_id == run_id) + ) + ).scalar_one() + 1 + narrative = _narrative(state, payload.player_input) + turn_id = str(uuid4()) + await session.execute( + insert(turns).values( + id=turn_id, + run_id=run_id, + request_id=payload.request_id, + sequence=sequence, + player_input=payload.player_input, + narrative=narrative, + commands=payload.commands, + outcomes=outcomes, + before_revision=before_revision, + after_revision=after_revision, + after_state=state, + created_at=datetime.now(UTC).replace(tzinfo=None), + ) + ) + return TurnResult( + turn_id=turn_id, + sequence=sequence, + narrative=narrative, + commands=payload.commands, + outcomes=outcomes, + before_revision=before_revision, + after_revision=after_revision, + state=state, + created=True, + ) + +def _apply_commands( + state: dict[str, Any], definition: dict[str, Any], commands: list[dict[str, Any]] +) -> list[dict[str, Any]]: + known_locations = {location["id"] for location in definition["locations"]} + known_entities = { + entity["id"] + for group in ("locations", "factions", "npcs") + for entity in definition[group] + } + known_events = {event["id"] for event in definition["events"]} + outcomes: list[dict[str, Any]] = [] + for command in commands: + _validate_command(command) + command_type = command["type"] + payload = command.get("payload", {}) + if command_type == "narrate": + outcomes.append({"type": "narrate", "accepted": True}) + elif command_type == "offer_choices": + choices = payload.get("choices") + if not isinstance(choices, list) or not all(isinstance(choice, str) for choice in choices): + raise RevisionConflictError("offer_choices requires a list of string choices") + outcomes.append({"type": "offer_choices", "choices": choices}) + elif command_type == "roll_dice": + sides = payload.get("sides") + if not isinstance(sides, int) or not 2 <= sides <= 100: + raise RevisionConflictError("roll_dice requires sides from 2 to 100") + outcomes.append({"type": "roll_dice", "sides": sides, "result": randbelow(sides) + 1}) + elif command_type == "move": + location_id = payload.get("location_id") + if location_id not in known_locations: + raise RevisionConflictError("move references an unknown location") + state["location_id"] = location_id + outcomes.append({"type": "move", "location_id": location_id}) + elif command_type == "set_entity_state": + entity_id = payload.get("entity_id") + if entity_id not in known_entities: + raise RevisionConflictError("set_entity_state references an unknown entity") + value = payload.get("value") + state["entities"][entity_id] = value + outcomes.append({"type": "set_entity_state", "entity_id": entity_id}) + elif command_type == "set_event_state": + event_id = payload.get("event_id") + if event_id not in known_events: + raise RevisionConflictError("set_event_state references an unknown event") + value = payload.get("value") + state["events"][event_id] = value + outcomes.append({"type": "set_event_state", "event_id": event_id}) + elif command_type == "inventory_change": + item_id, delta = payload.get("item_id"), payload.get("delta") + if not isinstance(item_id, str) or not item_id or not isinstance(delta, int) or delta == 0: + raise RevisionConflictError("inventory_change requires item_id and non-zero integer delta") + _change_inventory(state["inventory"], item_id, delta) + outcomes.append({"type": "inventory_change", "item_id": item_id, "delta": delta}) + return outcomes + + +def _validate_command(command: dict[str, Any]) -> None: + try: + contract_validator("turn_command.schema.json").validate(command) + except ValidationError as error: + raise RevisionConflictError(f"invalid TurnCommand: {error.message}") from error + + +def _change_inventory(inventory: list[dict[str, Any]], item_id: str, delta: int) -> None: + current = next((item for item in inventory if item.get("id") == item_id), None) + quantity = (current.get("quantity", 0) if current else 0) + delta + if quantity < 0: + raise RevisionConflictError("inventory cannot become negative") + if current is None and quantity: + inventory.append({"id": item_id, "quantity": quantity}) + elif current is not None and quantity: + current["quantity"] = quantity + elif current is not None: + inventory.remove(current) + + +def _narrative(state: dict[str, Any], player_input: str) -> str: + return f"{state['hero']['name']} acts at {state['location_id']}: {player_input}" + + +def _turn_result(row: Any, *, created: bool) -> TurnResult: + return TurnResult( + turn_id=row["id"], + sequence=row["sequence"], + narrative=row["narrative"], + commands=row["commands"], + outcomes=row["outcomes"], + before_revision=row["before_revision"], + after_revision=row["after_revision"], + state=row["after_state"], + created=created, + ) diff --git a/vnext/backend/src/dzmm_vnext/worlds.py b/vnext/backend/src/dzmm_vnext/worlds.py new file mode 100644 index 0000000..88cebb4 --- /dev/null +++ b/vnext/backend/src/dzmm_vnext/worlds.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from jsonschema import ValidationError +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import insert, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from .contracts import contract_validator +from .persistence import compose_requests, heroes, runs, turns, world_versions, worlds + + +class HeroInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=120) + profile: dict[str, Any] = Field(default_factory=dict) + + +class ComposeWorldInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + request_id: str = Field(min_length=1, max_length=80) + world_definition: dict[str, Any] + hero: HeroInput + + +class ComposeWorldResult(BaseModel): + world_id: str + world_version_id: str + hero_id: str + run_id: str + state: dict[str, Any] + created: bool + + +class RunSnapshot(BaseModel): + run_id: str + world_version_id: str + hero_id: str + state: dict[str, Any] + turns: list[dict[str, Any]] + + +class DomainValidationError(ValueError): + pass + + +class IdempotencyConflictError(ValueError): + pass + + +class WorldComposer: + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + + async def compose(self, payload: ComposeWorldInput) -> ComposeWorldResult: + self._validate_definition(payload.world_definition) + fingerprint = _fingerprint(payload) + + async with self._session_factory() as session, session.begin(): + existing = await session.execute( + select(compose_requests).where(compose_requests.c.request_id == payload.request_id) + ) + row = existing.mappings().one_or_none() + if row: + if row["fingerprint"] != fingerprint: + raise IdempotencyConflictError("request_id was already used for different world input") + return await self._result_for_existing(session, row["world_id"], row["run_id"]) + + now = datetime.now(UTC).replace(tzinfo=None) + world_id, world_version_id, hero_id, run_id = (str(uuid4()) for _ in range(4)) + state = _initial_state(payload.world_definition, hero_id, payload.hero) + try: + contract_validator("run_state.schema.json").validate(state) + except ValidationError as error: + raise DomainValidationError(f"invalid initial RunState: {error.message}") from error + + await session.execute( + insert(worlds).values( + id=world_id, + name=payload.world_definition["name"], + status="active", + created_at=now, + ) + ) + await session.execute( + insert(world_versions).values( + id=world_version_id, + world_id=world_id, + version_number=1, + definition=payload.world_definition, + created_at=now, + ) + ) + await session.execute( + insert(heroes).values( + id=hero_id, + name=payload.hero.name, + profile=payload.hero.profile, + created_at=now, + ) + ) + await session.execute( + insert(runs).values( + id=run_id, + world_version_id=world_version_id, + hero_id=hero_id, + status="active", + state=state, + state_revision=0, + created_at=now, + updated_at=now, + ) + ) + await session.execute( + insert(compose_requests).values( + request_id=payload.request_id, + fingerprint=fingerprint, + world_id=world_id, + run_id=run_id, + created_at=now, + ) + ) + return ComposeWorldResult( + world_id=world_id, + world_version_id=world_version_id, + hero_id=hero_id, + run_id=run_id, + state=state, + created=True, + ) + + def _validate_definition(self, definition: dict[str, Any]) -> None: + try: + contract_validator("world_definition.schema.json").validate(definition) + except ValidationError as error: + raise DomainValidationError(f"invalid WorldDefinition: {error.message}") from error + if len(definition["locations"]) < 2: + raise DomainValidationError("a playable vNext world needs at least two locations") + + async def _result_for_existing( + self, session: AsyncSession, world_id: str, run_id: str + ) -> ComposeWorldResult: + result = await session.execute( + select(runs.c.world_version_id, runs.c.hero_id, runs.c.state).where(runs.c.id == run_id) + ) + row = result.mappings().one() + return ComposeWorldResult( + world_id=world_id, + world_version_id=row["world_version_id"], + hero_id=row["hero_id"], + run_id=run_id, + state=row["state"], + created=False, + ) + + async def load_run(self, run_id: str) -> RunSnapshot | None: + async with self._session_factory() as session: + result = await session.execute( + select(runs.c.id, runs.c.world_version_id, runs.c.hero_id, runs.c.state).where( + runs.c.id == run_id + ) + ) + run = result.mappings().one_or_none() + if run is None: + return None + turn_rows = await session.execute( + select( + turns.c.id, + turns.c.sequence, + turns.c.player_input, + turns.c.narrative, + turns.c.commands, + turns.c.outcomes, + turns.c.before_revision, + turns.c.after_revision, + ) + .where(turns.c.run_id == run_id) + .order_by(turns.c.sequence) + ) + return RunSnapshot( + run_id=run["id"], + world_version_id=run["world_version_id"], + hero_id=run["hero_id"], + state=run["state"], + turns=[dict(row) for row in turn_rows.mappings()], + ) + + +def _initial_state( + definition: dict[str, Any], hero_id: str, hero: HeroInput +) -> dict[str, Any]: + return { + "schema_version": 1, + "revision": 0, + "hero": {"id": hero_id, "name": hero.name, "profile": hero.profile}, + "location_id": definition["locations"][0]["id"], + "inventory": [], + "entities": {}, + "events": {}, + } + + +def _fingerprint(payload: ComposeWorldInput) -> str: + canonical = json.dumps(payload.model_dump(mode="json"), ensure_ascii=False, sort_keys=True) + return hashlib.sha256(canonical.encode()).hexdigest() diff --git a/vnext/backend/tests/test_world_compose.py b/vnext/backend/tests/test_world_compose.py new file mode 100644 index 0000000..53a5bf8 --- /dev/null +++ b/vnext/backend/tests/test_world_compose.py @@ -0,0 +1,190 @@ +import sqlite3 +from pathlib import Path +from uuid import uuid4 + +import pytest +from alembic import command +from alembic.config import Config +from fastapi.testclient import TestClient + +from dzmm_vnext.config import Settings +from dzmm_vnext.main import create_app + + +@pytest.fixture +def migrated_client(tmp_path, monkeypatch): + data_dir = tmp_path / "dzmm-vnext" + monkeypatch.setenv("DZMM_NEXT_DATA_DIR", str(data_dir)) + command.upgrade(Config(str(Path(__file__).parents[1] / "alembic.ini")), "head") + app = create_app(Settings(data_dir=data_dir)) + with TestClient(app, raise_server_exceptions=False) as client: + yield client, data_dir / "dzmm-next.db" + + +def compose_payload(request_id: str) -> dict: + return { + "request_id": request_id, + "world_definition": { + "schema_version": 1, + "name": "Fog Harbor", + "lore": [], + "locations": [ + {"id": "harbor", "name": "Fog Harbor"}, + {"id": "lighthouse", "name": "Old Lighthouse"}, + ], + "factions": [], + "npcs": [], + "events": [], + "ruleset": {"id": "core"}, + }, + "hero": {"name": "Mira", "profile": {"origin": "sailor"}}, + } + + +def table_counts(database: Path) -> dict[str, int]: + names = ["worlds", "world_versions", "heroes", "runs", "compose_requests"] + with sqlite3.connect(database) as connection: + return {name: connection.execute(f"SELECT COUNT(*) FROM {name}").fetchone()[0] for name in names} + + +def test_compose_is_atomic_idempotent_and_recovers_run(migrated_client) -> None: + client, database = migrated_client + payload = compose_payload("compose-1") + + first = client.post("/api/v2/worlds:compose", json=payload) + assert first.status_code == 201 + created = first.json() + assert created["state"] == { + "schema_version": 1, + "revision": 0, + "hero": { + "id": created["hero_id"], + "name": "Mira", + "profile": {"origin": "sailor"}, + }, + "location_id": "harbor", + "inventory": [], + "entities": {}, + "events": {}, + } + assert table_counts(database) == { + "worlds": 1, + "world_versions": 1, + "heroes": 1, + "runs": 1, + "compose_requests": 1, + } + + retry = client.post("/api/v2/worlds:compose", json=payload) + assert retry.status_code == 200 + assert {key: retry.json()[key] for key in ("world_id", "world_version_id", "hero_id", "run_id")} == { + key: created[key] for key in ("world_id", "world_version_id", "hero_id", "run_id") + } + assert table_counts(database)["runs"] == 1 + + recovered = client.get(f"/api/v2/runs/{created['run_id']}") + assert recovered.status_code == 200 + assert recovered.json()["state"] == created["state"] + assert recovered.json()["turns"] == [] + + +def test_compose_rejects_reused_request_id_with_different_input(migrated_client) -> None: + client, database = migrated_client + payload = compose_payload("compose-conflict") + assert client.post("/api/v2/worlds:compose", json=payload).status_code == 201 + + conflicting = compose_payload("compose-conflict") + conflicting["hero"]["name"] = "Not Mira" + response = client.post("/api/v2/worlds:compose", json=conflicting) + + assert response.status_code == 409 + assert table_counts(database)["runs"] == 1 + + +def test_twenty_database_failures_leave_no_partial_aggregate(migrated_client) -> None: + client, database = migrated_client + with sqlite3.connect(database) as connection: + connection.execute( + "CREATE TRIGGER fail_hero BEFORE INSERT ON heroes " + "BEGIN SELECT RAISE(ABORT, 'injected failure'); END;" + ) + + for _ in range(20): + response = client.post("/api/v2/worlds:compose", json=compose_payload(str(uuid4()))) + assert response.status_code == 500 + + assert table_counts(database) == { + "worlds": 0, + "world_versions": 0, + "heroes": 0, + "runs": 0, + "compose_requests": 0, + } + + +def test_three_turns_recover_after_refresh_and_sse_replays_events(migrated_client) -> None: + client, _ = migrated_client + created = client.post("/api/v2/worlds:compose", json=compose_payload("turn-world")).json() + run_id = created["run_id"] + first = client.post( + f"/api/v2/runs/{run_id}/turns", + json={ + "request_id": "turn-1", + "expected_revision": 0, + "player_input": "I walk to the lighthouse.", + "commands": [{"type": "move", "payload": {"location_id": "lighthouse"}}], + }, + ) + assert first.status_code == 201 + assert first.json()["after_revision"] == 1 + assert first.json()["state"]["location_id"] == "lighthouse" + + second = client.post( + f"/api/v2/runs/{run_id}/turns", + json={ + "request_id": "turn-2", + "expected_revision": 1, + "player_input": "I take a lantern.", + "commands": [ + {"type": "inventory_change", "payload": {"item_id": "lantern", "delta": 1}} + ], + }, + ) + assert second.status_code == 201 + stream = client.post( + f"/api/v2/runs/{run_id}/turns:stream", + json={ + "request_id": "turn-3", + "expected_revision": 2, + "player_input": "I search the light room.", + "commands": [ + {"type": "roll_dice", "payload": {"sides": 20}}, + {"type": "narrate", "payload": {}}, + ], + }, + ) + assert stream.status_code == 200 + assert stream.headers["content-type"].startswith("text/event-stream") + assert "event: turn_started" in stream.text + assert "event: narrative_delta" in stream.text + assert "event: command_applied" in stream.text + assert "event: turn_completed" in stream.text + + recovered = client.get(f"/api/v2/runs/{run_id}") + assert recovered.status_code == 200 + assert recovered.json()["state"]["revision"] == 3 + assert recovered.json()["state"]["location_id"] == "lighthouse" + assert recovered.json()["state"]["inventory"] == [{"id": "lantern", "quantity": 1}] + assert [turn["sequence"] for turn in recovered.json()["turns"]] == [1, 2, 3] + + retry = client.post( + f"/api/v2/runs/{run_id}/turns", + json={ + "request_id": "turn-1", + "expected_revision": 0, + "player_input": "I walk to the lighthouse.", + "commands": [{"type": "move", "payload": {"location_id": "lighthouse"}}], + }, + ) + assert retry.status_code == 200 + assert retry.json()["state"]["revision"] == 1 From d413a873b09f310a4d5c1407d97dcce8b098c2c8 Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 22:55:15 +0800 Subject: [PATCH 007/126] feat: add vnext model profile probing --- .../versions/0003_model_profiles.py | 31 ++++ vnext/backend/src/dzmm_vnext/main.py | 18 +++ .../backend/src/dzmm_vnext/model_profiles.py | 141 ++++++++++++++++++ vnext/backend/src/dzmm_vnext/persistence.py | 11 ++ vnext/backend/tests/conftest.py | 19 +++ vnext/backend/tests/test_model_profiles.py | 94 ++++++++++++ vnext/backend/tests/test_world_compose.py | 18 --- 7 files changed, 314 insertions(+), 18 deletions(-) create mode 100644 vnext/backend/migrations/versions/0003_model_profiles.py create mode 100644 vnext/backend/src/dzmm_vnext/model_profiles.py create mode 100644 vnext/backend/tests/conftest.py create mode 100644 vnext/backend/tests/test_model_profiles.py diff --git a/vnext/backend/migrations/versions/0003_model_profiles.py b/vnext/backend/migrations/versions/0003_model_profiles.py new file mode 100644 index 0000000..e472611 --- /dev/null +++ b/vnext/backend/migrations/versions/0003_model_profiles.py @@ -0,0 +1,31 @@ +"""model profiles + +Revision ID: 0003_model_profiles +Revises: 0002_phase1 +Create Date: 2026-08-16 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0003_model_profiles" +down_revision = "0002_phase1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "model_profiles", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("name", sa.String(length=120), nullable=False, unique=True), + sa.Column("provider_type", sa.String(length=30), nullable=False), + sa.Column("base_url", sa.String(length=500), nullable=False), + sa.Column("model_name", sa.String(length=200), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("model_profiles") diff --git a/vnext/backend/src/dzmm_vnext/main.py b/vnext/backend/src/dzmm_vnext/main.py index f2eae92..fa34469 100644 --- a/vnext/backend/src/dzmm_vnext/main.py +++ b/vnext/backend/src/dzmm_vnext/main.py @@ -12,6 +12,7 @@ from .config import Settings from .contracts import contract_manifest from .db import create_engine +from .model_profiles import ModelProber, ModelProfileInput, ModelProfileService from .turns import ( RevisionConflictError, RunNotFoundError, @@ -40,6 +41,8 @@ async def lifespan(app: FastAPI): app.state.sessions = async_sessionmaker(app.state.engine, expire_on_commit=False) app.state.world_composer = WorldComposer(app.state.sessions) app.state.turn_coordinator = TurnCoordinator(app.state.sessions) + app.state.model_profiles = ModelProfileService(app.state.sessions) + app.state.model_prober = ModelProber() try: yield finally: @@ -117,6 +120,21 @@ async def events(): return StreamingResponse(events(), media_type="text/event-stream") + @app.post("/api/v2/model-profiles") + async def create_model_profile(payload: ModelProfileInput) -> JSONResponse: + try: + profile = await app.state.model_profiles.create(payload) + except ValueError as error: + raise HTTPException(status_code=422, detail=str(error)) from error + return JSONResponse(status_code=201, content=profile.model_dump(mode="json")) + + @app.post("/api/v2/model-profiles/{profile_id}:probe") + async def probe_model_profile(profile_id: str) -> dict[str, object]: + profile = await app.state.model_profiles.get(profile_id) + if profile is None: + raise HTTPException(status_code=404, detail="model profile not found") + return (await app.state.model_prober.probe(profile)).model_dump(mode="json") + return app diff --git a/vnext/backend/src/dzmm_vnext/model_profiles.py b/vnext/backend/src/dzmm_vnext/model_profiles.py new file mode 100644 index 0000000..fe92cf5 --- /dev/null +++ b/vnext/backend/src/dzmm_vnext/model_profiles.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from enum import StrEnum +from typing import Any +from uuid import uuid4 + +import httpx +from pydantic import BaseModel, ConfigDict, Field, field_validator +from sqlalchemy import insert, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from .persistence import model_profiles + + +class ProviderType(StrEnum): + OLLAMA = "ollama" + LM_STUDIO = "lm_studio" + OPENAI_COMPAT = "openai_compat" + + +class ModelProfileInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=120) + provider_type: ProviderType + base_url: str = Field(min_length=1, max_length=500) + model_name: str = Field(min_length=1, max_length=200) + + @field_validator("base_url") + @classmethod + def remove_trailing_slash(cls, value: str) -> str: + return value.rstrip("/") + + +class ModelProfile(ModelProfileInput): + id: str + + +class ProbeResult(BaseModel): + success: bool + endpoint: str + detail: str + + +class ModelProfileService: + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + + async def create(self, payload: ModelProfileInput) -> ModelProfile: + _chat_endpoint(payload) + profile = ModelProfile(id=str(uuid4()), **payload.model_dump()) + async with self._session_factory() as session, session.begin(): + await session.execute( + insert(model_profiles).values( + id=profile.id, + name=profile.name, + provider_type=profile.provider_type, + base_url=profile.base_url, + model_name=profile.model_name, + created_at=datetime.now(UTC).replace(tzinfo=None), + ) + ) + return profile + + async def get(self, profile_id: str) -> ModelProfile | None: + async with self._session_factory() as session: + result = await session.execute(select(model_profiles).where(model_profiles.c.id == profile_id)) + row = result.mappings().one_or_none() + if row is None: + return None + return ModelProfile( + id=row["id"], + name=row["name"], + provider_type=row["provider_type"], + base_url=row["base_url"], + model_name=row["model_name"], + ) + + +class ModelProber: + def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None: + self._transport = transport + + async def probe(self, profile: ModelProfile) -> ProbeResult: + endpoint = _chat_endpoint(profile) + body = _probe_body(profile) + try: + async with httpx.AsyncClient(transport=self._transport, timeout=10.0) as client: + response = await client.post(endpoint, json=body) + except httpx.HTTPError as error: + return ProbeResult(success=False, endpoint=endpoint, detail=f"connection failed: {error}") + if response.status_code != 200: + return ProbeResult( + success=False, + endpoint=endpoint, + detail=f"provider returned HTTP {response.status_code}", + ) + try: + payload = response.json() + except ValueError: + return ProbeResult(success=False, endpoint=endpoint, detail="provider returned non-JSON response") + content = _probe_content(profile.provider_type, payload) + if isinstance(content, str) and content.strip(): + return ProbeResult(success=True, endpoint=endpoint, detail="protocol response contains content") + return ProbeResult( + success=False, + endpoint=endpoint, + detail="provider returned HTTP 200 but not a valid non-empty chat response", + ) + + +def _chat_endpoint(profile: ModelProfileInput) -> str: + base_url = profile.base_url + if profile.provider_type is ProviderType.OLLAMA: + if base_url.endswith("/v1"): + raise ValueError("Ollama base_url must be the server root, not an OpenAI /v1 root") + return f"{base_url}/api/chat" + if not base_url.endswith("/v1"): + raise ValueError("LM Studio and OpenAI-compatible base_url must end with /v1") + return f"{base_url}/chat/completions" + + +def _probe_body(profile: ModelProfile) -> dict[str, Any]: + messages = [{"role": "user", "content": "Reply with OK."}] + if profile.provider_type is ProviderType.OLLAMA: + return {"model": profile.model_name, "messages": messages, "stream": False} + return {"model": profile.model_name, "messages": messages, "stream": False, "max_tokens": 8} + + +def _probe_content(provider_type: ProviderType, payload: Any) -> str | None: + if not isinstance(payload, dict) or payload.get("error"): + return None + if provider_type is ProviderType.OLLAMA: + message = payload.get("message") + return message.get("content") if isinstance(message, dict) else None + choices = payload.get("choices") + if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict): + return None + message = choices[0].get("message") + return message.get("content") if isinstance(message, dict) else None diff --git a/vnext/backend/src/dzmm_vnext/persistence.py b/vnext/backend/src/dzmm_vnext/persistence.py index 6586aa5..2494056 100644 --- a/vnext/backend/src/dzmm_vnext/persistence.py +++ b/vnext/backend/src/dzmm_vnext/persistence.py @@ -84,3 +84,14 @@ Column("run_id", String(36), ForeignKey("runs.id", ondelete="CASCADE"), nullable=False), Column("created_at", DateTime(), nullable=False), ) + +model_profiles = Table( + "model_profiles", + metadata, + Column("id", String(36), primary_key=True), + Column("name", String(120), nullable=False, unique=True), + Column("provider_type", String(30), nullable=False), + Column("base_url", String(500), nullable=False), + Column("model_name", String(200), nullable=False), + Column("created_at", DateTime(), nullable=False), +) diff --git a/vnext/backend/tests/conftest.py b/vnext/backend/tests/conftest.py new file mode 100644 index 0000000..370cfee --- /dev/null +++ b/vnext/backend/tests/conftest.py @@ -0,0 +1,19 @@ +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from fastapi.testclient import TestClient + +from dzmm_vnext.config import Settings +from dzmm_vnext.main import create_app + + +@pytest.fixture +def migrated_client(tmp_path, monkeypatch): + data_dir = tmp_path / "dzmm-vnext" + monkeypatch.setenv("DZMM_NEXT_DATA_DIR", str(data_dir)) + command.upgrade(Config(str(Path(__file__).parents[1] / "alembic.ini")), "head") + app = create_app(Settings(data_dir=data_dir)) + with TestClient(app, raise_server_exceptions=False) as client: + yield client, data_dir / "dzmm-next.db" diff --git a/vnext/backend/tests/test_model_profiles.py b/vnext/backend/tests/test_model_profiles.py new file mode 100644 index 0000000..b6c55b1 --- /dev/null +++ b/vnext/backend/tests/test_model_profiles.py @@ -0,0 +1,94 @@ +import asyncio +import json + +import httpx + +from dzmm_vnext.model_profiles import ModelProber, ModelProfile, ProviderType + + +def test_profile_requires_a_complete_provider_protocol(migrated_client) -> None: + client, _ = migrated_client + + missing_v1 = client.post( + "/api/v2/model-profiles", + json={ + "name": "LM Studio", + "provider_type": "lm_studio", + "base_url": "http://localhost:1234", + "model_name": "huihui-ai_qwen3-14b-abliterated", + }, + ) + assert missing_v1.status_code == 422 + + bad_ollama_root = client.post( + "/api/v2/model-profiles", + json={ + "name": "Ollama", + "provider_type": "ollama", + "base_url": "http://localhost:11434/v1", + "model_name": "qwen3:8b", + }, + ) + assert bad_ollama_root.status_code == 422 + + created = client.post( + "/api/v2/model-profiles", + json={ + "name": "Desktop LM Studio", + "provider_type": "lm_studio", + "base_url": "http://desktop.local:1234/v1/", + "model_name": "huihui-ai_qwen3-14b-abliterated", + }, + ) + assert created.status_code == 201 + assert created.json()["base_url"] == "http://desktop.local:1234/v1" + + +def test_lm_studio_http_200_error_is_not_a_successful_probe() -> None: + seen: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["body"] = json.loads(request.content) + return httpx.Response(200, json={"error": "Unexpected endpoint or method"}) + + profile = ModelProfile( + id="profile-1", + name="LM Studio", + provider_type=ProviderType.LM_STUDIO, + base_url="http://desktop.local:1234/v1", + model_name="huihui-ai_qwen3-14b-abliterated", + ) + result = asyncio.run(ModelProber(httpx.MockTransport(handler)).probe(profile)) + + assert result.success is False + assert seen["url"] == "http://desktop.local:1234/v1/chat/completions" + assert seen["body"] == { + "model": "huihui-ai_qwen3-14b-abliterated", + "messages": [{"role": "user", "content": "Reply with OK."}], + "stream": False, + "max_tokens": 8, + } + + +def test_probe_requires_non_empty_protocol_content() -> None: + profile = ModelProfile( + id="profile-2", + name="Ollama", + provider_type=ProviderType.OLLAMA, + base_url="http://localhost:11434", + model_name="qwen3:8b", + ) + empty = asyncio.run( + ModelProber(httpx.MockTransport(lambda _: httpx.Response(200, json={"message": {"content": ""}}))).probe( + profile + ) + ) + successful = asyncio.run( + ModelProber( + httpx.MockTransport(lambda _: httpx.Response(200, json={"message": {"content": "OK"}})) + ).probe(profile) + ) + + assert empty.success is False + assert successful.success is True diff --git a/vnext/backend/tests/test_world_compose.py b/vnext/backend/tests/test_world_compose.py index 53a5bf8..716242f 100644 --- a/vnext/backend/tests/test_world_compose.py +++ b/vnext/backend/tests/test_world_compose.py @@ -2,24 +2,6 @@ from pathlib import Path from uuid import uuid4 -import pytest -from alembic import command -from alembic.config import Config -from fastapi.testclient import TestClient - -from dzmm_vnext.config import Settings -from dzmm_vnext.main import create_app - - -@pytest.fixture -def migrated_client(tmp_path, monkeypatch): - data_dir = tmp_path / "dzmm-vnext" - monkeypatch.setenv("DZMM_NEXT_DATA_DIR", str(data_dir)) - command.upgrade(Config(str(Path(__file__).parents[1] / "alembic.ini")), "head") - app = create_app(Settings(data_dir=data_dir)) - with TestClient(app, raise_server_exceptions=False) as client: - yield client, data_dir / "dzmm-next.db" - def compose_payload(request_id: str) -> dict: return { From 78736890a1b29e5e7af96fc6216a00f9db1cd5e9 Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 23:03:02 +0800 Subject: [PATCH 008/126] feat: add vnext desktop vertical slice --- docs/ACTIVE_DELIVERY_INDEX.md | 3 +- vnext/desktop/.gitignore | 3 + vnext/desktop/README.md | 16 +- vnext/desktop/index.html | 12 + vnext/desktop/package-lock.json | 1516 ++++++++++++++++++++++++++++++ vnext/desktop/package.json | 19 + vnext/desktop/src/App.vue | 175 ++++ vnext/desktop/src/api.ts | 60 ++ vnext/desktop/src/env.d.ts | 1 + vnext/desktop/src/main.ts | 6 + vnext/desktop/src/style.css | 26 + vnext/desktop/tsconfig.app.json | 19 + vnext/desktop/tsconfig.json | 7 + vnext/desktop/tsconfig.node.json | 15 + vnext/desktop/vite.config.ts | 18 + 15 files changed, 1892 insertions(+), 4 deletions(-) create mode 100644 vnext/desktop/.gitignore create mode 100644 vnext/desktop/index.html create mode 100644 vnext/desktop/package-lock.json create mode 100644 vnext/desktop/package.json create mode 100644 vnext/desktop/src/App.vue create mode 100644 vnext/desktop/src/api.ts create mode 100644 vnext/desktop/src/env.d.ts create mode 100644 vnext/desktop/src/main.ts create mode 100644 vnext/desktop/src/style.css create mode 100644 vnext/desktop/tsconfig.app.json create mode 100644 vnext/desktop/tsconfig.json create mode 100644 vnext/desktop/tsconfig.node.json create mode 100644 vnext/desktop/vite.config.ts diff --git a/docs/ACTIVE_DELIVERY_INDEX.md b/docs/ACTIVE_DELIVERY_INDEX.md index 7060d06..af3696e 100644 --- a/docs/ACTIVE_DELIVERY_INDEX.md +++ b/docs/ACTIVE_DELIVERY_INDEX.md @@ -29,7 +29,8 @@ - 桌面 v0.16.0 的玩法成熟度 88.1 可用于定义质量下限;vNext 首次评分为 0。 - Android remote acceptance 78.1 证明已有功能方向,但 vNext 仍必须重新完成 Mac + Android + LAN 物理验收。 - **Phase 0 已验证:** 实现提交为 `401c7be`。Python 3.13.3 的独立 venv 中,`pytest -q` 为 `4 passed`、`ruff check src tests` 通过;临时 `DZMM_NEXT_DATA_DIR` 执行 `alembic upgrade head` 后,`schema_meta` 有 app/api/contract 三条基线记录、`alembic_version=0001_phase0`;`/health` 返回 `api_version=2`、四份契约、`storage=isolated` 与 `foreign_keys=true`。评分器读取 `vnext/eval/evidence/phase0.json` 得 **0.0/100**、所有 P0 未达标、不可发布——这是尚未实现功能的正确零基线,不得据此加分。 +- **Phase 1 中间证据(尚未评分):** `9bf5f4f` 在临时 SQLite 上验证了 World/WorldVersion/Hero/Run 的单事务 compose、重试、冲突与 20 次数据库写失败零残留;还覆盖三回合、SSE 事件与刷新恢复。`d413a87` 将模型 profile 固化为完整协议并验证 LM Studio 的 HTTP 200 error body 必须失败。桌面 vNext 的 `npm run build` 已通过,浏览器实测 Create → Confirm → Turn → Refresh 恢复成功。此证据仅覆盖本地未打包竖切和模拟模型响应;尚未满足 Phase 1 的真实模型 narration、已打包桌面和完整评分门槛。 ### 下一关 -Phase 1:实现 `World → WorldVersion → Run → Turn[]` 的原子 idempotent compose、首次 `RunState` 与三回合可恢复桌面竖切;以 20 次失败注入和刷新恢复结果争取总分至少 45、Domain 与 Game Loop 至少 40。不得从 legacy 目录复制领域模型或 API。 +Phase 1:把已存在的 Python-first 回合接入已探测的真实模型 profile,并以 `huihui-ai_qwen3-14b-abliterated` 完成三回合桌面实跑;补齐模型取消/空流错误路径和对已打包桌面壳的验收。之后才按证据给 Domain、Game Loop、Model、Desktop 与 Engineering 维度评分;不得从 legacy 目录复制领域模型或 API。 diff --git a/vnext/desktop/.gitignore b/vnext/desktop/.gitignore new file mode 100644 index 0000000..f4e2c6d --- /dev/null +++ b/vnext/desktop/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/vnext/desktop/README.md b/vnext/desktop/README.md index 763f921..6afdf6d 100644 --- a/vnext/desktop/README.md +++ b/vnext/desktop/README.md @@ -1,4 +1,14 @@ -# vNext desktop +# DZMM Next desktop -Phase 0 reserves this root for the Tauri/Vue authoring and Mac host-control -shell. It must use API v2 contracts only and never import legacy frontend code. +The Phase 1 desktop surface is a clean Vue application that uses API v2 only. +It owns the minimal authoring and recovery flow: create a world, confirm its +first Run, play a turn, and reopen the active Run after refresh. Tauri host +packaging is introduced in a later release phase. + +```bash +npm install +npm run dev +``` + +By default Vite proxies `/api` to `http://127.0.0.1:8765`. For a packaged host, +set `VITE_API_BASE=http://127.0.0.1:8765/api/v2`. diff --git a/vnext/desktop/index.html b/vnext/desktop/index.html new file mode 100644 index 0000000..a88cb65 --- /dev/null +++ b/vnext/desktop/index.html @@ -0,0 +1,12 @@ + + + + + + DZMM Next + + +
+ + + diff --git a/vnext/desktop/package-lock.json b/vnext/desktop/package-lock.json new file mode 100644 index 0000000..de814ac --- /dev/null +++ b/vnext/desktop/package-lock.json @@ -0,0 +1,1516 @@ +{ + "name": "@dzmm-next/desktop", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@dzmm-next/desktop", + "version": "0.1.0", + "dependencies": { + "vue": "^3.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/vnext/desktop/package.json b/vnext/desktop/package.json new file mode 100644 index 0000000..30429d3 --- /dev/null +++ b/vnext/desktop/package.json @@ -0,0 +1,19 @@ +{ + "name": "@dzmm-next/desktop", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build" + }, + "dependencies": { + "vue": "^3.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vue-tsc": "^2.2.0" + } +} diff --git a/vnext/desktop/src/App.vue b/vnext/desktop/src/App.vue new file mode 100644 index 0000000..cfbe43f --- /dev/null +++ b/vnext/desktop/src/App.vue @@ -0,0 +1,175 @@ + + + diff --git a/vnext/desktop/src/api.ts b/vnext/desktop/src/api.ts new file mode 100644 index 0000000..66fd49f --- /dev/null +++ b/vnext/desktop/src/api.ts @@ -0,0 +1,60 @@ +export type RunState = { + revision: number + hero: { id: string; name: string } + location_id: string + inventory: Array<{ id: string; quantity: number }> +} + +export type ComposedRun = { + world_id: string + world_version_id: string + hero_id: string + run_id: string + state: RunState +} + +export type Turn = { + id: string + sequence: number + player_input: string + narrative: string + before_revision: number + after_revision: number +} + +export type RunSnapshot = { + run_id: string + state: RunState + turns: Turn[] +} + +const apiBase = import.meta.env.VITE_API_BASE ?? '/api/v2' + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${apiBase}${path}`, init) + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { detail?: string } + throw new Error(body.detail ?? `请求失败(${response.status})`) + } + return response.json() as Promise +} + +export function composeWorld(payload: object) { + return request('/worlds:compose', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export function getRun(runId: string) { + return request(`/runs/${runId}`) +} + +export function createTurn(runId: string, payload: object) { + return request<{ state: RunState }>(`/runs/${runId}/turns`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }) +} diff --git a/vnext/desktop/src/env.d.ts b/vnext/desktop/src/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/vnext/desktop/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vnext/desktop/src/main.ts b/vnext/desktop/src/main.ts new file mode 100644 index 0000000..cb78378 --- /dev/null +++ b/vnext/desktop/src/main.ts @@ -0,0 +1,6 @@ +import { createApp } from 'vue' + +import App from './App.vue' +import './style.css' + +createApp(App).mount('#app') diff --git a/vnext/desktop/src/style.css b/vnext/desktop/src/style.css new file mode 100644 index 0000000..81b5ab0 --- /dev/null +++ b/vnext/desktop/src/style.css @@ -0,0 +1,26 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Noto+Serif+SC:wght@500;600;700&display=swap'); + +:root { color: #d9e1dc; background: #10211f; font-family: 'Noto Serif SC', Georgia, serif; } +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; min-height: 100vh; } +button, input, select { font: inherit; } +button { cursor: pointer; border: 0; color: #10211f; background: #d8bb74; padding: 13px 18px; font-weight: 700; } +button:disabled { cursor: wait; opacity: .55; } +button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 3px solid #e8d59e; outline-offset: 3px; } +.shell { width: min(1120px, calc(100% - 40px)); margin: 0 auto; padding: 26px 0 80px; } +.masthead { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; border-bottom: 1px solid #58716a; padding-bottom: 18px; font-family: 'DM Mono', monospace; font-size: 12px; letter-spacing: .08em; } +.masthead p { margin: 0; color: #a6bbb4; text-align: center; } +.brand { color: #f0e9d8; text-decoration: none; font-size: 21px; font-weight: 700; letter-spacing: -.02em; } +.brand span { color: #d8bb74; font-weight: 400; } +.host-dot { justify-self: end; color: #a6bbb4; } +.host-dot i { display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: #7cc9a5; } +.route-strip { display: flex; align-items: center; gap: 12px; margin: 32px 0; font: 12px 'DM Mono', monospace; color: #809c94; letter-spacing: .08em; } +.route-strip b { color: #45635c; }.route-strip .active { color: #e6cf8e; } +.scene { animation: rise .38s ease-out both; }.eyebrow { margin: 0 0 12px; color: #d8bb74; font: 12px 'DM Mono', monospace; letter-spacing: .12em; text-transform: uppercase; } +.compose-scene { display: grid; grid-template-columns: 1.1fr .9fr; gap: clamp(32px, 7vw, 108px); align-items: center; min-height: 570px; }.scene-copy h1, .confirmation h1 { margin: 0; color: #f1e8d6; font-size: clamp(45px, 6vw, 78px); line-height: 1.08; letter-spacing: -.055em; }.scene-copy > p:last-child { max-width: 440px; margin: 27px 0 0; color: #b5c6c0; font-size: 17px; line-height: 1.85; } +.ledger-card { position: relative; display: grid; gap: 20px; padding: 34px; border: 1px solid #718881; background: #18312e; box-shadow: 13px 13px 0 #0a1614; }.ledger-card::before { content: 'WORLD / 01'; position: absolute; right: 18px; top: -10px; padding: 0 5px; background: #18312e; color: #8ca39c; font: 10px 'DM Mono', monospace; letter-spacing: .1em; }.ledger-card label, .turn-form label { display: grid; gap: 8px; color: #b9c9c3; font: 12px 'DM Mono', monospace; }.ledger-card input, .turn-form input, .turn-form select { width: 100%; border: 1px solid #607d74; border-radius: 0; background: #10211f; color: #f1e8d6; padding: 12px; }.location-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.confirmation { max-width: 690px; padding: 62px 0; }.confirmation > p:not(.eyebrow) { color: #b5c6c0; font-size: 18px; line-height: 1.8; }.confirmation dl, .run-state dl { display: grid; gap: 12px; margin: 34px 0; font-family: 'DM Mono', monospace; }.confirmation dl div, .run-state dl div { display: flex; justify-content: space-between; gap: 20px; border-bottom: 1px solid #45635c; padding-bottom: 9px; }.confirmation dt, .run-state dt { color: #91aaa2; }.confirmation dd, .run-state dd { margin: 0; color: #e3d0a0; } +.play-scene { display: grid; grid-template-columns: 250px 1fr; gap: 28px; }.run-state { padding: 25px; align-self: start; border-top: 4px solid #d8bb74; background: #18312e; }.run-state h2 { margin: 0; color: #f1e8d6; font-size: 27px; }.chronicle { min-height: 390px; border-left: 1px solid #58716a; padding-left: 29px; }.chronicle article { padding: 16px 0; border-bottom: 1px solid #36534d; }.chronicle article small { color: #8ca39c; font: 11px 'DM Mono', monospace; }.chronicle article p { margin: 9px 0 0; line-height: 1.7; }.chronicle .player { color: #e6cf8e; }.empty { color: #a6bbb4; line-height: 1.8; }.turn-form { grid-column: 2; display: grid; grid-template-columns: 1fr 190px auto; gap: 14px; padding-top: 2px; }.notice { margin: 0 0 18px; border-left: 4px solid #de8e71; background: #452c28; padding: 13px 16px; color: #f2d7c9; } +@keyframes rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } +@media (max-width: 720px) { .shell { width: min(100% - 28px, 1120px); }.masthead { grid-template-columns: 1fr auto; }.masthead p { display: none; }.compose-scene, .play-scene { grid-template-columns: 1fr; min-height: auto; }.ledger-card { margin-bottom: 30px; }.chronicle { border-left: 0; padding-left: 0; }.turn-form { grid-column: 1; grid-template-columns: 1fr; }.location-pair { grid-template-columns: 1fr; } } +@media (prefers-reduced-motion: reduce) { .scene { animation: none; } } diff --git a/vnext/desktop/tsconfig.app.json b/vnext/desktop/tsconfig.app.json new file mode 100644 index 0000000..8258a03 --- /dev/null +++ b/vnext/desktop/tsconfig.app.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "jsx": "preserve" + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/vnext/desktop/tsconfig.json b/vnext/desktop/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/vnext/desktop/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/vnext/desktop/tsconfig.node.json b/vnext/desktop/tsconfig.node.json new file mode 100644 index 0000000..14cadbf --- /dev/null +++ b/vnext/desktop/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["vite.config.ts"] +} diff --git a/vnext/desktop/vite.config.ts b/vnext/desktop/vite.config.ts new file mode 100644 index 0000000..a85cd99 --- /dev/null +++ b/vnext/desktop/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, '', '') + return { + plugins: [vue()], + server: { + port: 5182, + proxy: { + '/api': { + target: env.VITE_BACKEND_ORIGIN || 'http://127.0.0.1:8765', + changeOrigin: true, + }, + }, + }, + } +}) From ca82df64175fb226940cab544f0d7546b7c7ef2e Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 23:11:55 +0800 Subject: [PATCH 009/126] feat: narrate vnext turns through model profiles --- .../versions/0004_run_model_profile.py | 33 ++++++ vnext/backend/src/dzmm_vnext/main.py | 4 +- .../backend/src/dzmm_vnext/model_profiles.py | 103 +++++++++++++++++- vnext/backend/src/dzmm_vnext/persistence.py | 1 + vnext/backend/src/dzmm_vnext/turns.py | 52 ++++++++- vnext/backend/src/dzmm_vnext/worlds.py | 26 ++++- vnext/backend/tests/test_model_profiles.py | 56 +++++++++- vnext/backend/tests/test_world_compose.py | 11 ++ 8 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 vnext/backend/migrations/versions/0004_run_model_profile.py diff --git a/vnext/backend/migrations/versions/0004_run_model_profile.py b/vnext/backend/migrations/versions/0004_run_model_profile.py new file mode 100644 index 0000000..0e29e9e --- /dev/null +++ b/vnext/backend/migrations/versions/0004_run_model_profile.py @@ -0,0 +1,33 @@ +"""associate runs with model profiles + +Revision ID: 0004_run_model_profile +Revises: 0003_model_profiles +Create Date: 2026-08-16 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0004_run_model_profile" +down_revision = "0003_model_profiles" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("runs") as batch: + batch.add_column(sa.Column("model_profile_id", sa.String(length=36), nullable=True)) + batch.create_foreign_key( + "fk_runs_model_profiles", + "model_profiles", + ["model_profile_id"], + ["id"], + ondelete="RESTRICT", + ) + + +def downgrade() -> None: + with op.batch_alter_table("runs") as batch: + batch.drop_constraint("fk_runs_model_profiles", type_="foreignkey") + batch.drop_column("model_profile_id") diff --git a/vnext/backend/src/dzmm_vnext/main.py b/vnext/backend/src/dzmm_vnext/main.py index fa34469..2a0b94d 100644 --- a/vnext/backend/src/dzmm_vnext/main.py +++ b/vnext/backend/src/dzmm_vnext/main.py @@ -12,7 +12,7 @@ from .config import Settings from .contracts import contract_manifest from .db import create_engine -from .model_profiles import ModelProber, ModelProfileInput, ModelProfileService +from .model_profiles import ModelProber, ModelProfileInput, ModelProfileService, NarrationError from .turns import ( RevisionConflictError, RunNotFoundError, @@ -87,6 +87,8 @@ async def play_turn(run_id: str, payload: TurnInput) -> TurnResult: return await app.state.turn_coordinator.play(run_id, payload) except RunNotFoundError as error: raise HTTPException(status_code=404, detail=str(error)) from error + except NarrationError as error: + raise HTTPException(status_code=502, detail=str(error)) from error except (RevisionConflictError, TurnIdempotencyConflictError) as error: raise HTTPException(status_code=409, detail=str(error)) from error diff --git a/vnext/backend/src/dzmm_vnext/model_profiles.py b/vnext/backend/src/dzmm_vnext/model_profiles.py index fe92cf5..9f9e59f 100644 --- a/vnext/backend/src/dzmm_vnext/model_profiles.py +++ b/vnext/backend/src/dzmm_vnext/model_profiles.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +import re from datetime import UTC, datetime from enum import StrEnum from typing import Any @@ -43,6 +45,10 @@ class ProbeResult(BaseModel): detail: str +class NarrationError(ValueError): + pass + + class ModelProfileService: def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: self._session_factory = session_factory @@ -100,7 +106,7 @@ async def probe(self, profile: ModelProfile) -> ProbeResult: payload = response.json() except ValueError: return ProbeResult(success=False, endpoint=endpoint, detail="provider returned non-JSON response") - content = _probe_content(profile.provider_type, payload) + content = _chat_content(profile.provider_type, payload) if isinstance(content, str) and content.strip(): return ProbeResult(success=True, endpoint=endpoint, detail="protocol response contains content") return ProbeResult( @@ -110,6 +116,37 @@ async def probe(self, profile: ModelProfile) -> ProbeResult: ) +class ModelNarrator: + def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None: + self._transport = transport + + async def narrate( + self, + profile: ModelProfile, + definition: dict[str, Any], + state: dict[str, Any], + player_input: str, + outcomes: list[dict[str, Any]], + ) -> str: + endpoint = _chat_endpoint(profile) + body = _narration_body(profile, definition, state, player_input, outcomes) + try: + async with httpx.AsyncClient(transport=self._transport, timeout=30.0) as client: + response = await client.post(endpoint, json=body) + except httpx.HTTPError as error: + raise NarrationError(f"model connection failed: {error}") from error + if response.status_code != 200: + raise NarrationError(f"model returned HTTP {response.status_code}") + try: + content = _chat_content(profile.provider_type, response.json()) + except ValueError as error: + raise NarrationError("model returned non-JSON response") from error + narrative = _clean_narrative(content) + if not narrative: + raise NarrationError("model returned no valid narrative content") + return narrative + + def _chat_endpoint(profile: ModelProfileInput) -> str: base_url = profile.base_url if profile.provider_type is ProviderType.OLLAMA: @@ -128,7 +165,7 @@ def _probe_body(profile: ModelProfile) -> dict[str, Any]: return {"model": profile.model_name, "messages": messages, "stream": False, "max_tokens": 8} -def _probe_content(provider_type: ProviderType, payload: Any) -> str | None: +def _chat_content(provider_type: ProviderType, payload: Any) -> str | None: if not isinstance(payload, dict) or payload.get("error"): return None if provider_type is ProviderType.OLLAMA: @@ -139,3 +176,65 @@ def _probe_content(provider_type: ProviderType, payload: Any) -> str | None: return None message = choices[0].get("message") return message.get("content") if isinstance(message, dict) else None + + +def _narration_body( + profile: ModelProfile, + definition: dict[str, Any], + state: dict[str, Any], + player_input: str, + outcomes: list[dict[str, Any]], +) -> dict[str, Any]: + messages = [ + { + "role": "system", + "content": ( + "你是单人 TRPG 的叙事者。只用简洁中文描述已经由规则引擎确认的结果;" + "不要编造物品、地点、数值或状态变化,不要解释规则,不要输出标签或 JSON。" + ), + }, + { + "role": "user", + "content": "/no_think\n" + + json.dumps( + { + "world": definition["name"], + "hero": state["hero"]["name"], + "location_id": state["location_id"], + "player_input": player_input, + "validated_outcomes": outcomes, + }, + ensure_ascii=False, + ), + }, + ] + if profile.provider_type is ProviderType.OLLAMA: + return { + "model": profile.model_name, + "messages": messages, + "stream": False, + "options": {"num_predict": 220}, + } + body: dict[str, Any] = { + "model": profile.model_name, + "messages": messages, + "stream": False, + "max_tokens": 160, + } + if profile.provider_type is ProviderType.LM_STUDIO and "qwen" in profile.model_name.lower(): + body["chat_template_kwargs"] = {"enable_thinking": False} + return body + + +def _clean_narrative(content: str | None) -> str | None: + if not isinstance(content, str): + return None + value = content.strip() + if "" in value: + value = value.split("", maxsplit=1)[1].strip() + if "### TRPG Narrative:" in value: + value = value.split("### TRPG Narrative:", maxsplit=1)[1] + if "### JSON:" in value: + value = value.split("### JSON:", maxsplit=1)[0] + value = re.sub(r"^#+\s*", "", value.strip()) + return value.strip() or None diff --git a/vnext/backend/src/dzmm_vnext/persistence.py b/vnext/backend/src/dzmm_vnext/persistence.py index 2494056..c2d5e9c 100644 --- a/vnext/backend/src/dzmm_vnext/persistence.py +++ b/vnext/backend/src/dzmm_vnext/persistence.py @@ -49,6 +49,7 @@ Column("id", String(36), primary_key=True), Column("world_version_id", String(36), ForeignKey("world_versions.id", ondelete="RESTRICT"), nullable=False), Column("hero_id", String(36), ForeignKey("heroes.id", ondelete="RESTRICT"), nullable=False), + Column("model_profile_id", String(36), nullable=True), Column("status", String(20), nullable=False), Column("state", JSON(), nullable=False), Column("state_revision", Integer(), nullable=False), diff --git a/vnext/backend/src/dzmm_vnext/turns.py b/vnext/backend/src/dzmm_vnext/turns.py index 650cf28..de4e281 100644 --- a/vnext/backend/src/dzmm_vnext/turns.py +++ b/vnext/backend/src/dzmm_vnext/turns.py @@ -12,7 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from .contracts import contract_validator -from .persistence import runs, turns, world_versions +from .model_profiles import ModelNarrator, ModelProfile +from .persistence import model_profiles, runs, turns, world_versions class TurnInput(BaseModel): @@ -49,8 +50,13 @@ class TurnIdempotencyConflictError(ValueError): class TurnCoordinator: - def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + def __init__( + self, + session_factory: async_sessionmaker[AsyncSession], + narrator: ModelNarrator | None = None, + ) -> None: self._session_factory = session_factory + self._narrator = narrator or ModelNarrator() async def play(self, run_id: str, payload: TurnInput) -> TurnResult: async with self._session_factory() as session, session.begin(): @@ -72,8 +78,18 @@ async def play(self, run_id: str, payload: TurnInput) -> TurnResult: return _turn_result(existing_row, created=False) result = await session.execute( - select(runs.c.state, runs.c.state_revision, world_versions.c.definition) + select( + runs.c.state, + runs.c.state_revision, + world_versions.c.definition, + model_profiles.c.id.label("model_id"), + model_profiles.c.name.label("model_name_label"), + model_profiles.c.provider_type, + model_profiles.c.base_url, + model_profiles.c.model_name, + ) .join(world_versions, world_versions.c.id == runs.c.world_version_id) + .outerjoin(model_profiles, model_profiles.c.id == runs.c.model_profile_id) .where(runs.c.id == run_id) ) run = result.mappings().one_or_none() @@ -94,6 +110,9 @@ async def play(self, run_id: str, payload: TurnInput) -> TurnResult: except ValidationError as error: raise RevisionConflictError(f"engine created invalid RunState: {error.message}") from error + profile = _profile_from_row(run) + narrative = await self._narrate(profile, run["definition"], state, payload.player_input, outcomes) + changed = await session.execute( update(runs) .where(runs.c.id == run_id, runs.c.state_revision == before_revision) @@ -111,7 +130,6 @@ async def play(self, run_id: str, payload: TurnInput) -> TurnResult: select(func.coalesce(func.max(turns.c.sequence), 0)).where(turns.c.run_id == run_id) ) ).scalar_one() + 1 - narrative = _narrative(state, payload.player_input) turn_id = str(uuid4()) await session.execute( insert(turns).values( @@ -141,6 +159,18 @@ async def play(self, run_id: str, payload: TurnInput) -> TurnResult: created=True, ) + async def _narrate( + self, + profile: ModelProfile | None, + definition: dict[str, Any], + state: dict[str, Any], + player_input: str, + outcomes: list[dict[str, Any]], + ) -> str: + if profile is None: + return _deterministic_narrative(state, player_input) + return await self._narrator.narrate(profile, definition, state, player_input, outcomes) + def _apply_commands( state: dict[str, Any], definition: dict[str, Any], commands: list[dict[str, Any]] ) -> list[dict[str, Any]]: @@ -217,10 +247,22 @@ def _change_inventory(inventory: list[dict[str, Any]], item_id: str, delta: int) inventory.remove(current) -def _narrative(state: dict[str, Any], player_input: str) -> str: +def _deterministic_narrative(state: dict[str, Any], player_input: str) -> str: return f"{state['hero']['name']} acts at {state['location_id']}: {player_input}" +def _profile_from_row(row: Any) -> ModelProfile | None: + if row["model_id"] is None: + return None + return ModelProfile( + id=row["model_id"], + name=row["model_name_label"], + provider_type=row["provider_type"], + base_url=row["base_url"], + model_name=row["model_name"], + ) + + def _turn_result(row: Any, *, created: bool) -> TurnResult: return TurnResult( turn_id=row["id"], diff --git a/vnext/backend/src/dzmm_vnext/worlds.py b/vnext/backend/src/dzmm_vnext/worlds.py index 88cebb4..1ef4ffa 100644 --- a/vnext/backend/src/dzmm_vnext/worlds.py +++ b/vnext/backend/src/dzmm_vnext/worlds.py @@ -12,7 +12,15 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from .contracts import contract_validator -from .persistence import compose_requests, heroes, runs, turns, world_versions, worlds +from .persistence import ( + compose_requests, + heroes, + model_profiles, + runs, + turns, + world_versions, + worlds, +) class HeroInput(BaseModel): @@ -28,6 +36,7 @@ class ComposeWorldInput(BaseModel): request_id: str = Field(min_length=1, max_length=80) world_definition: dict[str, Any] hero: HeroInput + model_profile_id: str | None = None class ComposeWorldResult(BaseModel): @@ -35,6 +44,7 @@ class ComposeWorldResult(BaseModel): world_version_id: str hero_id: str run_id: str + model_profile_id: str | None state: dict[str, Any] created: bool @@ -73,6 +83,13 @@ async def compose(self, payload: ComposeWorldInput) -> ComposeWorldResult: raise IdempotencyConflictError("request_id was already used for different world input") return await self._result_for_existing(session, row["world_id"], row["run_id"]) + if payload.model_profile_id: + profile = await session.execute( + select(model_profiles.c.id).where(model_profiles.c.id == payload.model_profile_id) + ) + if profile.scalar_one_or_none() is None: + raise DomainValidationError("model_profile_id does not exist") + now = datetime.now(UTC).replace(tzinfo=None) world_id, world_version_id, hero_id, run_id = (str(uuid4()) for _ in range(4)) state = _initial_state(payload.world_definition, hero_id, payload.hero) @@ -111,6 +128,7 @@ async def compose(self, payload: ComposeWorldInput) -> ComposeWorldResult: id=run_id, world_version_id=world_version_id, hero_id=hero_id, + model_profile_id=payload.model_profile_id, status="active", state=state, state_revision=0, @@ -132,6 +150,7 @@ async def compose(self, payload: ComposeWorldInput) -> ComposeWorldResult: world_version_id=world_version_id, hero_id=hero_id, run_id=run_id, + model_profile_id=payload.model_profile_id, state=state, created=True, ) @@ -148,7 +167,9 @@ async def _result_for_existing( self, session: AsyncSession, world_id: str, run_id: str ) -> ComposeWorldResult: result = await session.execute( - select(runs.c.world_version_id, runs.c.hero_id, runs.c.state).where(runs.c.id == run_id) + select(runs.c.world_version_id, runs.c.hero_id, runs.c.model_profile_id, runs.c.state).where( + runs.c.id == run_id + ) ) row = result.mappings().one() return ComposeWorldResult( @@ -156,6 +177,7 @@ async def _result_for_existing( world_version_id=row["world_version_id"], hero_id=row["hero_id"], run_id=run_id, + model_profile_id=row["model_profile_id"], state=row["state"], created=False, ) diff --git a/vnext/backend/tests/test_model_profiles.py b/vnext/backend/tests/test_model_profiles.py index b6c55b1..b04df60 100644 --- a/vnext/backend/tests/test_model_profiles.py +++ b/vnext/backend/tests/test_model_profiles.py @@ -3,7 +3,7 @@ import httpx -from dzmm_vnext.model_profiles import ModelProber, ModelProfile, ProviderType +from dzmm_vnext.model_profiles import ModelNarrator, ModelProber, ModelProfile, ProviderType def test_profile_requires_a_complete_provider_protocol(migrated_client) -> None: @@ -92,3 +92,57 @@ def test_probe_requires_non_empty_protocol_content() -> None: assert empty.success is False assert successful.success is True + + +def test_narrator_only_accepts_protocol_valid_content() -> None: + profile = ModelProfile( + id="profile-3", + name="LM Studio", + provider_type=ProviderType.LM_STUDIO, + base_url="http://desktop.local:1234/v1", + model_name="huihui-ai_qwen3-14b-abliterated", + ) + narrator = ModelNarrator( + httpx.MockTransport( + lambda _: httpx.Response(200, json={"choices": [{"message": {"content": "灯塔亮起。"}}]}) + ) + ) + narrative = asyncio.run( + narrator.narrate( + profile, + {"name": "Fog Harbor"}, + {"hero": {"name": "Mira"}, "location_id": "lighthouse"}, + "I light the lamp.", + [{"type": "move", "location_id": "lighthouse"}], + ) + ) + + assert narrative == "灯塔亮起。" + + +def test_narrator_removes_qwen_rp_wrapper_and_json_echo() -> None: + profile = ModelProfile( + id="profile-4", + name="LM Studio", + provider_type=ProviderType.LM_STUDIO, + base_url="http://desktop.local:1234/v1", + model_name="huihui-ai_qwen3-14b-abliterated", + ) + wrapped = "### TRPG Narrative:\n### 灯塔的微光摇曳。\n\n### JSON:\n{\"narrative\": \"ignored\"}" + narrator = ModelNarrator( + httpx.MockTransport( + lambda _: httpx.Response(200, json={"choices": [{"message": {"content": wrapped}}]}) + ) + ) + + narrative = asyncio.run( + narrator.narrate( + profile, + {"name": "Fog Harbor"}, + {"hero": {"name": "Mira"}, "location_id": "lighthouse"}, + "I light the lamp.", + [], + ) + ) + + assert narrative == "灯塔的微光摇曳。" diff --git a/vnext/backend/tests/test_world_compose.py b/vnext/backend/tests/test_world_compose.py index 716242f..6244dfd 100644 --- a/vnext/backend/tests/test_world_compose.py +++ b/vnext/backend/tests/test_world_compose.py @@ -83,6 +83,17 @@ def test_compose_rejects_reused_request_id_with_different_input(migrated_client) assert table_counts(database)["runs"] == 1 +def test_compose_rejects_unknown_model_profile(migrated_client) -> None: + client, database = migrated_client + payload = compose_payload("missing-model-profile") + payload["model_profile_id"] = "missing" + + response = client.post("/api/v2/worlds:compose", json=payload) + + assert response.status_code == 422 + assert table_counts(database)["runs"] == 0 + + def test_twenty_database_failures_leave_no_partial_aggregate(migrated_client) -> None: client, database = migrated_client with sqlite3.connect(database) as connection: From 6a021fd85e9773c750c5e4bd91bb6ef006c79722 Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 23:12:42 +0800 Subject: [PATCH 010/126] docs: record vnext phase one interim matrix --- docs/ACTIVE_DELIVERY_INDEX.md | 7 ++++--- vnext/eval/evidence/phase1-interim.json | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 vnext/eval/evidence/phase1-interim.json diff --git a/docs/ACTIVE_DELIVERY_INDEX.md b/docs/ACTIVE_DELIVERY_INDEX.md index af3696e..2162c89 100644 --- a/docs/ACTIVE_DELIVERY_INDEX.md +++ b/docs/ACTIVE_DELIVERY_INDEX.md @@ -26,11 +26,12 @@ ### 当前证据与风险 - v0.16 的重叠领域模型与手工级联删除是 vNext clean-slate 的触发证据,而不是待迁移负担。 -- 桌面 v0.16.0 的玩法成熟度 88.1 可用于定义质量下限;vNext 首次评分为 0。 +- 桌面 v0.16.0 的玩法成熟度 88.1 可用于定义质量下限;vNext 的 Phase 0 初始评分为 0,不继承 legacy 分数。 - Android remote acceptance 78.1 证明已有功能方向,但 vNext 仍必须重新完成 Mac + Android + LAN 物理验收。 - **Phase 0 已验证:** 实现提交为 `401c7be`。Python 3.13.3 的独立 venv 中,`pytest -q` 为 `4 passed`、`ruff check src tests` 通过;临时 `DZMM_NEXT_DATA_DIR` 执行 `alembic upgrade head` 后,`schema_meta` 有 app/api/contract 三条基线记录、`alembic_version=0001_phase0`;`/health` 返回 `api_version=2`、四份契约、`storage=isolated` 与 `foreign_keys=true`。评分器读取 `vnext/eval/evidence/phase0.json` 得 **0.0/100**、所有 P0 未达标、不可发布——这是尚未实现功能的正确零基线,不得据此加分。 -- **Phase 1 中间证据(尚未评分):** `9bf5f4f` 在临时 SQLite 上验证了 World/WorldVersion/Hero/Run 的单事务 compose、重试、冲突与 20 次数据库写失败零残留;还覆盖三回合、SSE 事件与刷新恢复。`d413a87` 将模型 profile 固化为完整协议并验证 LM Studio 的 HTTP 200 error body 必须失败。桌面 vNext 的 `npm run build` 已通过,浏览器实测 Create → Confirm → Turn → Refresh 恢复成功。此证据仅覆盖本地未打包竖切和模拟模型响应;尚未满足 Phase 1 的真实模型 narration、已打包桌面和完整评分门槛。 +- **Phase 1 中间证据(已评分,未过 gate):** `9bf5f4f` 在临时 SQLite 上验证了 World/WorldVersion/Hero/Run 的单事务 compose、重试、冲突与 20 次数据库写失败零残留;还覆盖三回合、SSE 事件与刷新恢复。`d413a87` 将模型 profile 固化为完整协议并验证 LM Studio 的 HTTP 200 error body 必须失败。`ca82df6` 把 Run 绑定到 profile,并以台式机 LM Studio 的 `huihui-ai_qwen3-14b-abliterated` 完成四回合实跑:Python 最终状态为地点/库存/revision 的唯一来源,Qwen RP 包装与 JSON 回显会被净化。桌面 vNext 的 `npm run build` 已通过,浏览器实测 Create → Confirm → Turn → Refresh 恢复成功。 +- **当前 vNext 矩阵:37.5 / 100,全部 P0 未达标,不可发布。** 取证文件为 `vnext/eval/evidence/phase1-interim.json`:Domain 45、Game Loop 50、Content 35、Model 55、Desktop 50、Mobile 0、Long-play 0、Engineering 50。低分不是实现失败的代名词,而是如实反映缺少 archive/purge、Lore/ST、Tauri RC、Android、长局和发布证据;不以局部真实模型成功推断这些维度完成。 ### 下一关 -Phase 1:把已存在的 Python-first 回合接入已探测的真实模型 profile,并以 `huihui-ai_qwen3-14b-abliterated` 完成三回合桌面实跑;补齐模型取消/空流错误路径和对已打包桌面壳的验收。之后才按证据给 Domain、Game Loop、Model、Desktop 与 Engineering 维度评分;不得从 legacy 目录复制领域模型或 API。 +Phase 1 剩余:补齐模型取消/空流错误路径和 Tauri 打包桌面壳的验收;随后转入 Phase 2 的 archive/purge manifest、零孤儿扫描、rollback 与 30 回合真实模型跑团。不得从 legacy 目录复制领域模型或 API。 diff --git a/vnext/eval/evidence/phase1-interim.json b/vnext/eval/evidence/phase1-interim.json new file mode 100644 index 0000000..4b9ae24 --- /dev/null +++ b/vnext/eval/evidence/phase1-interim.json @@ -0,0 +1,24 @@ +{ + "commit": "ca82df6", + "environment": "Python 3.13.3; fresh temporary DZMM_NEXT_DATA_DIR; LM Studio at 192.168.31.169:1234/v1; huihui-ai_qwen3-14b-abliterated", + "evidence": { + "domain_lifecycle": "Atomic compose/retry/conflict plus 20 injected database failures leave no partial aggregate.", + "game_loop": "Python validates commands and revision; 3 real-model turns reload correctly, then a fourth output-cleaning turn passes.", + "creation_content": "A two-location desktop creation flow exists, but Lorebook and ST import/export do not.", + "model_stream": "Ollama and OpenAI-compatible contract probes are tested; LM Studio HTTP 200 error bodies fail; one real LM Studio model narrates turns.", + "desktop_ux": "Vite production build plus local browser Create -> Confirm -> Turn -> Refresh recovery passed; no packaged Tauri evidence.", + "mobile_recovery": "Not implemented in vNext.", + "long_play_performance": "No long-play measurement yet.", + "engineering_release": "Fresh migrations, backend tests, lint, and desktop production build pass; no signed release artifacts." + }, + "scores": { + "domain_lifecycle": 45, + "game_loop": 50, + "creation_content": 35, + "model_stream": 55, + "desktop_ux": 50, + "mobile_recovery": 0, + "long_play_performance": 0, + "engineering_release": 50 + } +} From cde8ef38ac2f6c487473113207c14b824fff2c21 Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 23:15:06 +0800 Subject: [PATCH 011/126] feat: add vnext world lifecycle controls --- vnext/backend/src/dzmm_vnext/lifecycle.py | 125 ++++++++++++++++++++ vnext/backend/src/dzmm_vnext/main.py | 39 ++++++ vnext/backend/src/dzmm_vnext/turns.py | 6 +- vnext/backend/tests/test_world_lifecycle.py | 88 ++++++++++++++ 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 vnext/backend/src/dzmm_vnext/lifecycle.py create mode 100644 vnext/backend/tests/test_world_lifecycle.py diff --git a/vnext/backend/src/dzmm_vnext/lifecycle.py b/vnext/backend/src/dzmm_vnext/lifecycle.py new file mode 100644 index 0000000..127274a --- /dev/null +++ b/vnext/backend/src/dzmm_vnext/lifecycle.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from pydantic import BaseModel, Field +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from .persistence import compose_requests, heroes, runs, turns, world_versions, worlds + + +class PurgeManifest(BaseModel): + world_id: str + tables: dict[str, int] + file_paths: list[str] + derived_indexes: list[str] + confirmation_token: str + + +class PurgeConfirmation(BaseModel): + confirmation_token: str = Field(min_length=64, max_length=64) + + +class WorldNotFoundError(ValueError): + pass + + +class PurgeConfirmationError(ValueError): + pass + + +class WorldLifecycle: + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + + async def archive(self, world_id: str) -> str: + async with self._session_factory() as session, session.begin(): + result = await session.execute(select(worlds.c.status).where(worlds.c.id == world_id)) + status = result.scalar_one_or_none() + if status is None: + raise WorldNotFoundError("world not found") + if status == "archived": + return status + await session.execute(worlds.update().where(worlds.c.id == world_id).values(status="archived")) + return "archived" + + async def manifest(self, world_id: str) -> PurgeManifest: + async with self._session_factory() as session: + return await self._manifest(session, world_id) + + async def purge(self, world_id: str, confirmation_token: str) -> PurgeManifest: + async with self._session_factory() as session, session.begin(): + manifest = await self._manifest(session, world_id) + if manifest.confirmation_token != confirmation_token: + raise PurgeConfirmationError("purge confirmation token is stale or invalid") + version_ids = select(world_versions.c.id).where(world_versions.c.world_id == world_id) + hero_result = await session.execute( + select(runs.c.hero_id).where(runs.c.world_version_id.in_(version_ids)) + ) + hero_ids = list(hero_result.scalars()) + await session.execute(delete(runs).where(runs.c.world_version_id.in_(version_ids))) + if hero_ids: + await session.execute(delete(heroes).where(heroes.c.id.in_(hero_ids))) + await session.execute(delete(worlds).where(worlds.c.id == world_id)) + return manifest + + async def _manifest(self, session: AsyncSession, world_id: str) -> PurgeManifest: + exists = await session.execute(select(worlds.c.id).where(worlds.c.id == world_id)) + if exists.scalar_one_or_none() is None: + raise WorldNotFoundError("world not found") + version_ids = select(world_versions.c.id).where(world_versions.c.world_id == world_id) + run_ids = select(runs.c.id).where(runs.c.world_version_id.in_(version_ids)) + counts = { + "worlds": 1, + "world_versions": await _count(session, world_versions.c.id, world_versions.c.world_id == world_id), + "runs": await _count(session, runs.c.id, runs.c.world_version_id.in_(version_ids)), + "turns": await _count(session, turns.c.id, turns.c.run_id.in_(run_ids)), + "heroes": await _count(session, runs.c.hero_id, runs.c.world_version_id.in_(version_ids)), + "compose_requests": await _count( + session, + compose_requests.c.request_id, + compose_requests.c.world_id == world_id, + ), + } + token = hashlib.sha256( + json.dumps({"world_id": world_id, "tables": counts}, sort_keys=True).encode() + ).hexdigest() + return PurgeManifest( + world_id=world_id, + tables=counts, + file_paths=[], + derived_indexes=[], + confirmation_token=token, + ) + + +async def integrity_scan(session_factory: async_sessionmaker[AsyncSession]) -> dict[str, int]: + async with session_factory() as session: + checks = { + "world_versions_without_world": select(func.count()) + .select_from(world_versions.outerjoin(worlds)) + .where(worlds.c.id.is_(None)), + "runs_without_world_version": select(func.count()) + .select_from(runs.outerjoin(world_versions)) + .where(world_versions.c.id.is_(None)), + "runs_without_hero": select(func.count()) + .select_from(runs.outerjoin(heroes)) + .where(heroes.c.id.is_(None)), + "turns_without_run": select(func.count()) + .select_from(turns.outerjoin(runs)) + .where(runs.c.id.is_(None)), + "compose_requests_without_world": select(func.count()) + .select_from(compose_requests.outerjoin(worlds)) + .where(worlds.c.id.is_(None)), + "compose_requests_without_run": select(func.count()) + .select_from(compose_requests.outerjoin(runs)) + .where(runs.c.id.is_(None)), + } + return {name: (await session.execute(statement)).scalar_one() for name, statement in checks.items()} + + +async def _count(session: AsyncSession, column: Any, where: Any) -> int: + return (await session.execute(select(func.count(column)).where(where))).scalar_one() diff --git a/vnext/backend/src/dzmm_vnext/main.py b/vnext/backend/src/dzmm_vnext/main.py index 2a0b94d..cd13947 100644 --- a/vnext/backend/src/dzmm_vnext/main.py +++ b/vnext/backend/src/dzmm_vnext/main.py @@ -12,6 +12,13 @@ from .config import Settings from .contracts import contract_manifest from .db import create_engine +from .lifecycle import ( + PurgeConfirmation, + PurgeConfirmationError, + WorldLifecycle, + WorldNotFoundError, + integrity_scan, +) from .model_profiles import ModelProber, ModelProfileInput, ModelProfileService, NarrationError from .turns import ( RevisionConflictError, @@ -43,6 +50,7 @@ async def lifespan(app: FastAPI): app.state.turn_coordinator = TurnCoordinator(app.state.sessions) app.state.model_profiles = ModelProfileService(app.state.sessions) app.state.model_prober = ModelProber() + app.state.world_lifecycle = WorldLifecycle(app.state.sessions) try: yield finally: @@ -82,6 +90,37 @@ async def get_run(run_id: str) -> dict[str, object]: raise HTTPException(status_code=404, detail="run not found") return snapshot.model_dump(mode="json") + @app.post("/api/v2/worlds/{world_id}:archive") + async def archive_world(world_id: str) -> dict[str, str]: + try: + status = await app.state.world_lifecycle.archive(world_id) + except WorldNotFoundError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + return {"world_id": world_id, "status": status} + + @app.get("/api/v2/worlds/{world_id}/purge-manifest") + async def world_purge_manifest(world_id: str) -> dict[str, object]: + try: + return (await app.state.world_lifecycle.manifest(world_id)).model_dump(mode="json") + except WorldNotFoundError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + + @app.delete("/api/v2/worlds/{world_id}") + async def purge_world(world_id: str, payload: PurgeConfirmation) -> dict[str, object]: + try: + return ( + await app.state.world_lifecycle.purge(world_id, payload.confirmation_token) + ).model_dump(mode="json") + except WorldNotFoundError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except PurgeConfirmationError as error: + raise HTTPException(status_code=409, detail=str(error)) from error + + @app.get("/api/v2/integrity") + async def get_integrity() -> dict[str, object]: + orphans = await integrity_scan(app.state.sessions) + return {"orphans": orphans, "clean": not any(orphans.values())} + async def play_turn(run_id: str, payload: TurnInput) -> TurnResult: try: return await app.state.turn_coordinator.play(run_id, payload) diff --git a/vnext/backend/src/dzmm_vnext/turns.py b/vnext/backend/src/dzmm_vnext/turns.py index de4e281..9ae898d 100644 --- a/vnext/backend/src/dzmm_vnext/turns.py +++ b/vnext/backend/src/dzmm_vnext/turns.py @@ -13,7 +13,7 @@ from .contracts import contract_validator from .model_profiles import ModelNarrator, ModelProfile -from .persistence import model_profiles, runs, turns, world_versions +from .persistence import model_profiles, runs, turns, world_versions, worlds class TurnInput(BaseModel): @@ -87,14 +87,18 @@ async def play(self, run_id: str, payload: TurnInput) -> TurnResult: model_profiles.c.provider_type, model_profiles.c.base_url, model_profiles.c.model_name, + worlds.c.status.label("world_status"), ) .join(world_versions, world_versions.c.id == runs.c.world_version_id) + .join(worlds, worlds.c.id == world_versions.c.world_id) .outerjoin(model_profiles, model_profiles.c.id == runs.c.model_profile_id) .where(runs.c.id == run_id) ) run = result.mappings().one_or_none() if run is None: raise RunNotFoundError("run not found") + if run["world_status"] != "active": + raise RevisionConflictError("archived world cannot receive new turns") if payload.expected_revision != run["state_revision"]: raise RevisionConflictError( f"expected revision {payload.expected_revision}, current revision is {run['state_revision']}" diff --git a/vnext/backend/tests/test_world_lifecycle.py b/vnext/backend/tests/test_world_lifecycle.py new file mode 100644 index 0000000..bfb1b4e --- /dev/null +++ b/vnext/backend/tests/test_world_lifecycle.py @@ -0,0 +1,88 @@ +def payload(request_id: str) -> dict: + return { + "request_id": request_id, + "world_definition": { + "schema_version": 1, + "name": "Fog Harbor", + "lore": [], + "locations": [ + {"id": "harbor", "name": "Fog Harbor"}, + {"id": "lighthouse", "name": "Old Lighthouse"}, + ], + "factions": [], + "npcs": [], + "events": [], + "ruleset": {"id": "core"}, + }, + "hero": {"name": "Mira", "profile": {}}, + } + + +def test_archive_manifest_purge_and_integrity_scan(migrated_client) -> None: + client, _ = migrated_client + created = client.post("/api/v2/worlds:compose", json=payload("lifecycle-compose")).json() + world_id, run_id = created["world_id"], created["run_id"] + turn = client.post( + f"/api/v2/runs/{run_id}/turns", + json={ + "request_id": "lifecycle-turn", + "expected_revision": 0, + "player_input": "I walk to the lighthouse.", + "commands": [{"type": "move", "payload": {"location_id": "lighthouse"}}], + }, + ) + assert turn.status_code == 201 + + archived = client.post(f"/api/v2/worlds/{world_id}:archive") + assert archived.status_code == 200 + assert archived.json()["status"] == "archived" + assert client.post(f"/api/v2/worlds/{world_id}:archive").json()["status"] == "archived" + + blocked_turn = client.post( + f"/api/v2/runs/{run_id}/turns", + json={ + "request_id": "blocked-after-archive", + "expected_revision": 1, + "player_input": "I return.", + "commands": [{"type": "move", "payload": {"location_id": "harbor"}}], + }, + ) + assert blocked_turn.status_code == 409 + + manifest = client.get(f"/api/v2/worlds/{world_id}/purge-manifest") + assert manifest.status_code == 200 + assert manifest.json()["tables"] == { + "worlds": 1, + "world_versions": 1, + "runs": 1, + "turns": 1, + "heroes": 1, + "compose_requests": 1, + } + assert manifest.json()["file_paths"] == [] + assert manifest.json()["derived_indexes"] == [] + + stale = client.request( + "DELETE", + f"/api/v2/worlds/{world_id}", + json={"confirmation_token": "0" * 64}, + ) + assert stale.status_code == 409 + purged = client.request( + "DELETE", + f"/api/v2/worlds/{world_id}", + json={"confirmation_token": manifest.json()["confirmation_token"]}, + ) + assert purged.status_code == 200 + assert client.get(f"/api/v2/runs/{run_id}").status_code == 404 + assert client.get("/api/v2/integrity").json() == { + "clean": True, + "orphans": { + "world_versions_without_world": 0, + "runs_without_world_version": 0, + "runs_without_hero": 0, + "turns_without_run": 0, + "compose_requests_without_world": 0, + "compose_requests_without_run": 0, + }, + } From cdc3e72b4e1758a8bbd56b90f8964a07fc542571 Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 23:16:01 +0800 Subject: [PATCH 012/126] docs: score vnext lifecycle evidence --- docs/ACTIVE_DELIVERY_INDEX.md | 5 ++-- .../evidence/phase2-lifecycle-interim.json | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 vnext/eval/evidence/phase2-lifecycle-interim.json diff --git a/docs/ACTIVE_DELIVERY_INDEX.md b/docs/ACTIVE_DELIVERY_INDEX.md index 2162c89..8daee78 100644 --- a/docs/ACTIVE_DELIVERY_INDEX.md +++ b/docs/ACTIVE_DELIVERY_INDEX.md @@ -30,8 +30,9 @@ - Android remote acceptance 78.1 证明已有功能方向,但 vNext 仍必须重新完成 Mac + Android + LAN 物理验收。 - **Phase 0 已验证:** 实现提交为 `401c7be`。Python 3.13.3 的独立 venv 中,`pytest -q` 为 `4 passed`、`ruff check src tests` 通过;临时 `DZMM_NEXT_DATA_DIR` 执行 `alembic upgrade head` 后,`schema_meta` 有 app/api/contract 三条基线记录、`alembic_version=0001_phase0`;`/health` 返回 `api_version=2`、四份契约、`storage=isolated` 与 `foreign_keys=true`。评分器读取 `vnext/eval/evidence/phase0.json` 得 **0.0/100**、所有 P0 未达标、不可发布——这是尚未实现功能的正确零基线,不得据此加分。 - **Phase 1 中间证据(已评分,未过 gate):** `9bf5f4f` 在临时 SQLite 上验证了 World/WorldVersion/Hero/Run 的单事务 compose、重试、冲突与 20 次数据库写失败零残留;还覆盖三回合、SSE 事件与刷新恢复。`d413a87` 将模型 profile 固化为完整协议并验证 LM Studio 的 HTTP 200 error body 必须失败。`ca82df6` 把 Run 绑定到 profile,并以台式机 LM Studio 的 `huihui-ai_qwen3-14b-abliterated` 完成四回合实跑:Python 最终状态为地点/库存/revision 的唯一来源,Qwen RP 包装与 JSON 回显会被净化。桌面 vNext 的 `npm run build` 已通过,浏览器实测 Create → Confirm → Turn → Refresh 恢复成功。 -- **当前 vNext 矩阵:37.5 / 100,全部 P0 未达标,不可发布。** 取证文件为 `vnext/eval/evidence/phase1-interim.json`:Domain 45、Game Loop 50、Content 35、Model 55、Desktop 50、Mobile 0、Long-play 0、Engineering 50。低分不是实现失败的代名词,而是如实反映缺少 archive/purge、Lore/ST、Tauri RC、Android、长局和发布证据;不以局部真实模型成功推断这些维度完成。 +- **Phase 2 lifecycle 证据(已评分,未过 gate):** `cde8ef3` 实现了 archive、确认 token 保护的 purge manifest、聚合删除与零 SQL 孤儿扫描;测试还确认 archived World 不能再收回合。当前没有资产或派生索引模块,manifest 因此明确报告空集合,不把不存在的清理能力计为完成。 +- **当前 vNext 矩阵:39.75 / 100,全部 P0 未达标,不可发布。** 取证文件为 `vnext/eval/evidence/phase2-lifecycle-interim.json`:Domain 60、Game Loop 50、Content 35、Model 55、Desktop 50、Mobile 0、Long-play 0、Engineering 50。低分不是实现失败的代名词,而是如实反映缺少 Lore/ST、Tauri RC、Android、长局和发布证据;不以局部真实模型成功推断这些维度完成。 ### 下一关 -Phase 1 剩余:补齐模型取消/空流错误路径和 Tauri 打包桌面壳的验收;随后转入 Phase 2 的 archive/purge manifest、零孤儿扫描、rollback 与 30 回合真实模型跑团。不得从 legacy 目录复制领域模型或 API。 +补齐模型取消/空流错误路径和 Tauri 打包桌面壳的验收;在 Phase 2 增加 rollback、资产/索引清理边界和 30 回合真实模型跑团。达到每项证据的实际门槛前,不得从 legacy 目录复制领域模型或 API。 diff --git a/vnext/eval/evidence/phase2-lifecycle-interim.json b/vnext/eval/evidence/phase2-lifecycle-interim.json new file mode 100644 index 0000000..234a6b2 --- /dev/null +++ b/vnext/eval/evidence/phase2-lifecycle-interim.json @@ -0,0 +1,24 @@ +{ + "commit": "cde8ef3", + "environment": "Python 3.13.3; fresh temporary SQLite database; deterministic lifecycle integration test", + "evidence": { + "domain_lifecycle": "Archive is idempotent, blocks new turns, purge requires a current manifest token, deletes the full aggregate, and reports zero SQL orphans.", + "game_loop": "Same as Phase 1 interim evidence.", + "creation_content": "Same as Phase 1 interim evidence.", + "model_stream": "Same as Phase 1 interim evidence.", + "desktop_ux": "Same as Phase 1 interim evidence.", + "mobile_recovery": "Not implemented in vNext.", + "long_play_performance": "No long-play measurement yet.", + "engineering_release": "Fresh migrations, backend tests, lint, and desktop production build pass; no signed release artifacts." + }, + "scores": { + "domain_lifecycle": 60, + "game_loop": 50, + "creation_content": 35, + "model_stream": 55, + "desktop_ux": 50, + "mobile_recovery": 0, + "long_play_performance": 0, + "engineering_release": 50 + } +} From abf1f609f9fb07312be5b7fc359d5411394c1789 Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 23:19:20 +0800 Subject: [PATCH 013/126] feat: add auditable vnext turn rollback --- .../versions/0005_turn_rollbacks.py | 37 ++++++ vnext/backend/src/dzmm_vnext/main.py | 17 +++ vnext/backend/src/dzmm_vnext/persistence.py | 2 + vnext/backend/src/dzmm_vnext/turns.py | 122 ++++++++++++++++++ vnext/backend/src/dzmm_vnext/worlds.py | 2 + vnext/backend/tests/test_world_compose.py | 56 ++++++++ 6 files changed, 236 insertions(+) create mode 100644 vnext/backend/migrations/versions/0005_turn_rollbacks.py diff --git a/vnext/backend/migrations/versions/0005_turn_rollbacks.py b/vnext/backend/migrations/versions/0005_turn_rollbacks.py new file mode 100644 index 0000000..ca40846 --- /dev/null +++ b/vnext/backend/migrations/versions/0005_turn_rollbacks.py @@ -0,0 +1,37 @@ +"""turn rollback audit fields + +Revision ID: 0005_turn_rollbacks +Revises: 0004_run_model_profile +Create Date: 2026-08-16 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0005_turn_rollbacks" +down_revision = "0004_run_model_profile" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("turns") as batch: + batch.add_column( + sa.Column("kind", sa.String(length=20), nullable=False, server_default="turn") + ) + batch.add_column(sa.Column("rollback_target_id", sa.String(length=36), nullable=True)) + batch.create_foreign_key( + "fk_turns_rollback_target", + "turns", + ["rollback_target_id"], + ["id"], + ondelete="RESTRICT", + ) + + +def downgrade() -> None: + with op.batch_alter_table("turns") as batch: + batch.drop_constraint("fk_turns_rollback_target", type_="foreignkey") + batch.drop_column("rollback_target_id") + batch.drop_column("kind") diff --git a/vnext/backend/src/dzmm_vnext/main.py b/vnext/backend/src/dzmm_vnext/main.py index cd13947..80be7d5 100644 --- a/vnext/backend/src/dzmm_vnext/main.py +++ b/vnext/backend/src/dzmm_vnext/main.py @@ -27,6 +27,7 @@ TurnIdempotencyConflictError, TurnInput, TurnResult, + TurnRollbackInput, ) from .worlds import ( ComposeWorldInput, @@ -131,6 +132,14 @@ async def play_turn(run_id: str, payload: TurnInput) -> TurnResult: except (RevisionConflictError, TurnIdempotencyConflictError) as error: raise HTTPException(status_code=409, detail=str(error)) from error + async def play_rollback(run_id: str, payload: TurnRollbackInput) -> TurnResult: + try: + return await app.state.turn_coordinator.rollback(run_id, payload) + except RunNotFoundError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except (RevisionConflictError, TurnIdempotencyConflictError) as error: + raise HTTPException(status_code=409, detail=str(error)) from error + @app.post("/api/v2/runs/{run_id}/turns") async def create_turn(run_id: str, payload: TurnInput) -> JSONResponse: result = await play_turn(run_id, payload) @@ -139,6 +148,14 @@ async def create_turn(run_id: str, payload: TurnInput) -> JSONResponse: content=result.model_dump(mode="json"), ) + @app.post("/api/v2/runs/{run_id}/rollbacks") + async def rollback_turn(run_id: str, payload: TurnRollbackInput) -> JSONResponse: + result = await play_rollback(run_id, payload) + return JSONResponse( + status_code=201 if result.created else 200, + content=result.model_dump(mode="json"), + ) + @app.post("/api/v2/runs/{run_id}/turns:stream") async def stream_turn(run_id: str, payload: TurnInput) -> StreamingResponse: result = await play_turn(run_id, payload) diff --git a/vnext/backend/src/dzmm_vnext/persistence.py b/vnext/backend/src/dzmm_vnext/persistence.py index c2d5e9c..c2762af 100644 --- a/vnext/backend/src/dzmm_vnext/persistence.py +++ b/vnext/backend/src/dzmm_vnext/persistence.py @@ -63,6 +63,8 @@ Column("id", String(36), primary_key=True), Column("run_id", String(36), ForeignKey("runs.id", ondelete="CASCADE"), nullable=False), Column("request_id", String(80), nullable=False), + Column("kind", String(20), nullable=False), + Column("rollback_target_id", String(36), ForeignKey("turns.id", ondelete="RESTRICT")), Column("sequence", Integer(), nullable=False), Column("player_input", String(), nullable=False), Column("narrative", String(), nullable=False), diff --git a/vnext/backend/src/dzmm_vnext/turns.py b/vnext/backend/src/dzmm_vnext/turns.py index 9ae898d..67b9e9a 100644 --- a/vnext/backend/src/dzmm_vnext/turns.py +++ b/vnext/backend/src/dzmm_vnext/turns.py @@ -25,8 +25,18 @@ class TurnInput(BaseModel): commands: list[dict[str, Any]] = Field(min_length=1, max_length=16) +class TurnRollbackInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + request_id: str = Field(min_length=1, max_length=80) + expected_revision: int = Field(ge=0) + target_turn_id: str = Field(min_length=1, max_length=36) + + class TurnResult(BaseModel): turn_id: str + kind: str + rollback_target_id: str | None sequence: int narrative: str commands: list[dict[str, Any]] @@ -140,6 +150,8 @@ async def play(self, run_id: str, payload: TurnInput) -> TurnResult: id=turn_id, run_id=run_id, request_id=payload.request_id, + kind="turn", + rollback_target_id=None, sequence=sequence, player_input=payload.player_input, narrative=narrative, @@ -153,6 +165,8 @@ async def play(self, run_id: str, payload: TurnInput) -> TurnResult: ) return TurnResult( turn_id=turn_id, + kind="turn", + rollback_target_id=None, sequence=sequence, narrative=narrative, commands=payload.commands, @@ -163,6 +177,112 @@ async def play(self, run_id: str, payload: TurnInput) -> TurnResult: created=True, ) + async def rollback(self, run_id: str, payload: TurnRollbackInput) -> TurnResult: + async with self._session_factory() as session, session.begin(): + existing = await session.execute( + select(turns).where( + turns.c.run_id == run_id, + turns.c.request_id == payload.request_id, + ) + ) + existing_row = existing.mappings().one_or_none() + if existing_row: + if ( + existing_row["kind"] != "rollback" + or existing_row["rollback_target_id"] != payload.target_turn_id + ): + raise TurnIdempotencyConflictError( + "request_id was already used for different turn input" + ) + return _turn_result(existing_row, created=False) + + run_result = await session.execute( + select(runs.c.state_revision, worlds.c.status.label("world_status")) + .join(world_versions, world_versions.c.id == runs.c.world_version_id) + .join(worlds, worlds.c.id == world_versions.c.world_id) + .where(runs.c.id == run_id) + ) + run = run_result.mappings().one_or_none() + if run is None: + raise RunNotFoundError("run not found") + if run["world_status"] != "active": + raise RevisionConflictError("archived world cannot receive new turns") + if payload.expected_revision != run["state_revision"]: + raise RevisionConflictError( + f"expected revision {payload.expected_revision}, current revision is {run['state_revision']}" + ) + + target_result = await session.execute( + select(turns).where( + turns.c.id == payload.target_turn_id, + turns.c.run_id == run_id, + ) + ) + target = target_result.mappings().one_or_none() + if target is None: + raise RevisionConflictError("rollback target is not part of this run") + + before_revision = run["state_revision"] + after_revision = before_revision + 1 + state = deepcopy(target["after_state"]) + state["revision"] = after_revision + try: + contract_validator("run_state.schema.json").validate(state) + except ValidationError as error: + raise RevisionConflictError(f"rollback created invalid RunState: {error.message}") from error + + changed = await session.execute( + update(runs) + .where(runs.c.id == run_id, runs.c.state_revision == before_revision) + .values( + state=state, + state_revision=after_revision, + updated_at=datetime.now(UTC).replace(tzinfo=None), + ) + ) + if changed.rowcount != 1: + raise RevisionConflictError("run changed while the rollback was being applied") + + sequence = ( + await session.execute( + select(func.coalesce(func.max(turns.c.sequence), 0)).where(turns.c.run_id == run_id) + ) + ).scalar_one() + 1 + turn_id = str(uuid4()) + outcomes = [{"type": "rollback", "target_turn_id": target["id"]}] + narrative = f"Recovered the state after turn {target['sequence']}." + await session.execute( + insert(turns).values( + id=turn_id, + run_id=run_id, + request_id=payload.request_id, + kind="rollback", + rollback_target_id=target["id"], + sequence=sequence, + player_input=f"Rollback to turn {target['sequence']}", + narrative=narrative, + commands=[], + outcomes=outcomes, + before_revision=before_revision, + after_revision=after_revision, + after_state=state, + created_at=datetime.now(UTC).replace(tzinfo=None), + ) + ) + return TurnResult( + turn_id=turn_id, + kind="rollback", + rollback_target_id=target["id"], + sequence=sequence, + narrative=narrative, + commands=[], + outcomes=outcomes, + before_revision=before_revision, + after_revision=after_revision, + state=state, + created=True, + ) + async def _narrate( self, profile: ModelProfile | None, @@ -270,6 +390,8 @@ def _profile_from_row(row: Any) -> ModelProfile | None: def _turn_result(row: Any, *, created: bool) -> TurnResult: return TurnResult( turn_id=row["id"], + kind=row["kind"], + rollback_target_id=row["rollback_target_id"], sequence=row["sequence"], narrative=row["narrative"], commands=row["commands"], diff --git a/vnext/backend/src/dzmm_vnext/worlds.py b/vnext/backend/src/dzmm_vnext/worlds.py index 1ef4ffa..8b27772 100644 --- a/vnext/backend/src/dzmm_vnext/worlds.py +++ b/vnext/backend/src/dzmm_vnext/worlds.py @@ -195,6 +195,8 @@ async def load_run(self, run_id: str) -> RunSnapshot | None: turn_rows = await session.execute( select( turns.c.id, + turns.c.kind, + turns.c.rollback_target_id, turns.c.sequence, turns.c.player_input, turns.c.narrative, diff --git a/vnext/backend/tests/test_world_compose.py b/vnext/backend/tests/test_world_compose.py index 6244dfd..e50c42f 100644 --- a/vnext/backend/tests/test_world_compose.py +++ b/vnext/backend/tests/test_world_compose.py @@ -181,3 +181,59 @@ def test_three_turns_recover_after_refresh_and_sse_replays_events(migrated_clien ) assert retry.status_code == 200 assert retry.json()["state"]["revision"] == 1 + + +def test_rollback_creates_a_new_audit_turn_without_rewriting_history(migrated_client) -> None: + client, _ = migrated_client + created = client.post("/api/v2/worlds:compose", json=compose_payload("rollback-world")).json() + run_id = created["run_id"] + first = client.post( + f"/api/v2/runs/{run_id}/turns", + json={ + "request_id": "rollback-turn-1", + "expected_revision": 0, + "player_input": "I walk to the lighthouse.", + "commands": [{"type": "move", "payload": {"location_id": "lighthouse"}}], + }, + ).json() + assert first["kind"] == "turn" + second = client.post( + f"/api/v2/runs/{run_id}/turns", + json={ + "request_id": "rollback-turn-2", + "expected_revision": 1, + "player_input": "I take a lantern.", + "commands": [ + {"type": "inventory_change", "payload": {"item_id": "lantern", "delta": 1}} + ], + }, + ) + assert second.status_code == 201 + + rollback_payload = { + "request_id": "rollback-1", + "expected_revision": 2, + "target_turn_id": first["turn_id"], + } + rollback = client.post(f"/api/v2/runs/{run_id}/rollbacks", json=rollback_payload) + assert rollback.status_code == 201 + assert rollback.json()["kind"] == "rollback" + assert rollback.json()["rollback_target_id"] == first["turn_id"] + assert rollback.json()["state"]["revision"] == 3 + assert rollback.json()["state"]["location_id"] == "lighthouse" + assert rollback.json()["state"]["inventory"] == [] + + retry = client.post(f"/api/v2/runs/{run_id}/rollbacks", json=rollback_payload) + assert retry.status_code == 200 + assert retry.json()["turn_id"] == rollback.json()["turn_id"] + + recovered = client.get(f"/api/v2/runs/{run_id}").json() + assert recovered["state"] == rollback.json()["state"] + assert [(turn["sequence"], turn["kind"]) for turn in recovered["turns"]] == [ + (1, "turn"), + (2, "turn"), + (3, "rollback"), + ] + assert recovered["turns"][1]["outcomes"] == [ + {"type": "inventory_change", "item_id": "lantern", "delta": 1} + ] From edf4ac16148ba1ed1b2fa249ec40e35eb4154fac Mon Sep 17 00:00:00 2001 From: "yiqian.zhang" Date: Sun, 16 Aug 2026 23:21:18 +0800 Subject: [PATCH 014/126] feat: expose vnext turn rollback in desktop --- vnext/desktop/src/App.vue | 30 +++++++++++++++++++++++++++++- vnext/desktop/src/api.ts | 10 ++++++++++ vnext/desktop/src/style.css | 1 + 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/vnext/desktop/src/App.vue b/vnext/desktop/src/App.vue index cfbe43f..b29c411 100644 --- a/vnext/desktop/src/App.vue +++ b/vnext/desktop/src/App.vue @@ -1,7 +1,15 @@