@linxin666/dsh-pet 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +13 -10
  3. package/README.zh.md +13 -10
  4. package/contracts/voice-pack-v1.schema.json +53 -28
  5. package/cordis.patch.yml +1 -1
  6. package/lib/client.js +166 -30
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +400 -478
  9. package/lib/invariant.js +1 -1
  10. package/lib/{state-DrMX22GL.js → state-NvDEoCln.js} +5 -2
  11. package/lib/types/access.d.ts +5 -4
  12. package/lib/types/access.d.ts.map +1 -1
  13. package/lib/types/access.js +2 -12
  14. package/lib/types/affinity.d.ts +2 -0
  15. package/lib/types/affinity.d.ts.map +1 -1
  16. package/lib/types/affinity.js +12 -0
  17. package/lib/types/chatter.d.ts +68 -46
  18. package/lib/types/chatter.d.ts.map +1 -1
  19. package/lib/types/chatter.js +243 -301
  20. package/lib/types/client/PetSprite.d.ts.map +1 -1
  21. package/lib/types/client/PetSprite.js +23 -18
  22. package/lib/types/client/index.d.ts.map +1 -1
  23. package/lib/types/client/index.js +26 -9
  24. package/lib/types/client/locales.d.ts +18 -0
  25. package/lib/types/client/locales.d.ts.map +1 -1
  26. package/lib/types/client/locales.js +18 -0
  27. package/lib/types/client/renderers/frames2d.d.ts +12 -0
  28. package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
  29. package/lib/types/client/renderers/frames2d.js +143 -18
  30. package/lib/types/event-projection.d.ts +9 -4
  31. package/lib/types/event-projection.d.ts.map +1 -1
  32. package/lib/types/event-projection.js +46 -14
  33. package/lib/types/ledger.d.ts +4 -2
  34. package/lib/types/ledger.d.ts.map +1 -1
  35. package/lib/types/ledger.js +4 -4
  36. package/lib/types/pair-access.d.ts +35 -0
  37. package/lib/types/pair-access.d.ts.map +1 -0
  38. package/lib/types/pair-access.js +19 -0
  39. package/lib/types/remarks.d.ts +1 -1
  40. package/lib/types/remarks.d.ts.map +1 -1
  41. package/lib/types/remarks.js +11 -2
  42. package/lib/types/routes.d.ts.map +1 -1
  43. package/lib/types/routes.js +8 -3
  44. package/lib/types/service.d.ts +7 -11
  45. package/lib/types/service.d.ts.map +1 -1
  46. package/lib/types/service.js +31 -20
  47. package/lib/types/voice-pack.d.ts +11 -7
  48. package/lib/types/voice-pack.d.ts.map +1 -1
  49. package/lib/types/voice-pack.js +80 -49
  50. package/package.json +1 -1
  51. package/src/access.ts +7 -27
  52. package/src/affinity.ts +13 -0
  53. package/src/chatter.test.ts +68 -38
  54. package/src/chatter.ts +269 -316
  55. package/src/client/PetSprite.test.tsx +50 -15
  56. package/src/client/PetSprite.tsx +30 -25
  57. package/src/client/index.test.tsx +10 -1
  58. package/src/client/index.ts +29 -10
  59. package/src/client/locales.ts +18 -0
  60. package/src/client/renderers/frames2d.test.ts +101 -0
  61. package/src/client/renderers/frames2d.ts +138 -18
  62. package/src/event-projection.ts +57 -16
  63. package/src/ledger.ts +6 -4
  64. package/src/pair-access.ts +49 -0
  65. package/src/remarks.test.ts +10 -0
  66. package/src/remarks.ts +9 -2
  67. package/src/routes.ts +9 -4
  68. package/src/service.ts +36 -30
  69. package/src/voice-pack.test.ts +87 -25
  70. package/src/voice-pack.ts +87 -49
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: 515f3af940b587c685e194b5467ca91020af36a2
6
- README.zh.md: f3dc4a805673d04289018dab31a9a4f0d16112fb
5
+ README.md: 2501ae7db35246c4348ed08fd169ee58a7569a95
6
+ README.zh.md: a490c37210773c3da3eec1d9c61807864f56b6aa
package/README.md CHANGED
@@ -23,8 +23,8 @@ Re-implemented from the pet feature of the Codex desktop app, as an official DSH
23
23
  | Dragging | Hold and drag the pet to reposition; position persisted |
24
24
  | Hide/Summon | The hover panel sits below the pet (lifted above the status bubbles when there is no room below) and provides 隐藏 (Hide); after hiding, a 召唤{name} (Summon {name}) button appears |
25
25
  | Witty remarks | Built-in remark library (10 lines per event) plus per-pet custom lines; success lines rotate by persisted success counts and cooldown lines by persisted rejection counts |
26
- | Status bubbles | Only the most recently active top-level session speaks by default — when several sessions run at once, the rest collapse behind a +N badge on the main bubble instead of stacking a tall column; hover the bubble (or tap the badge, for touch) to fan every session's bubble out above it and click one to jump to its session; subagent sessions report through their spawning conversation and never occupy a bubble of their own; transient interaction feedback temporarily takes priority. Bubble copy comes from generous rotating pools per scene (waiting / thinking / writing / done / failed...), tool calls map onto per-family witty lines carrying the real argument hint (e.g. 跑跑 npm test), and a long-lived scene re-phrases itself every few seconds |
27
- | Inner whispers | 碎碎念: while the model streams, the pet occasionally speaks its inner voice through its own bubble — a fresh whisper takes over the display session's bubble and marks it with 「」 quotes — sharing the same DeepSeek-blue glass as every status bubble, so stacked bubbles never clash — instead of stacking a second bubble — keyword moods woken by the model output (errors, test greens, plans, victories...) plus ambient whispers earned by output volume; paced by a cooldown, the status copy returns after a few seconds |
26
+ | Status bubbles | Only one top-level session speaks by default — the session the GUI is currently on when it is reported, otherwise the most recently active one — and the rest collapse behind a +N badge on the main bubble instead of stacking a tall column; hover the bubble (or tap the badge, for touch) to fan every session's bubble out above it and click one to jump to its session; subagent sessions report through their spawning conversation and never occupy a bubble of their own; transient interaction feedback temporarily takes priority. Bubble copy comes from generous rotating pools per scene (waiting / thinking / writing / done / failed...), tool calls map onto per-family witty lines carrying the real argument hint (e.g. 跑跑 npm test), and a long-lived scene re-phrases itself every few seconds |
27
+ | Inner whispers | 碎碎念: while a session streams, the pet occasionally speaks its inner voice through that session's own bubble — a fresh whisper takes over the bubble and marks it with 「」 quotes — sharing the same DeepSeek-blue glass as every status bubble, so stacked bubbles never clash — instead of stacking a second bubble — category lines woken by the SITUATION (thinking / writing / the running tool family) plus outcome lines woken only by structured results (test green from a passed test tool, error from a failed tool result, completion from a completed turn) never from the model's words, so a discussion that merely mentions a keyword cannot wake a mood, and a whisper never quotes real content (no tool names, paths or model text); paced by cooldowns (9 s category, 5 s outcome) and an 8 s display TTL, the status copy returns after a few seconds |
28
28
  | Multi-session activity | The pet is host-global: the most recent meaningful event drives the sprite animation while every active top-level session reports its own state in a separate bubble; completed turns from every session (subagents included) contribute affinity and treats |
29
29
  | Voice packs and panel DIY | A per-pet voice.json plus the global $DSH_HOME/pets/.voice.json override replace every bubble word and the hover panel (button labels, stat formats, button visibility); merge precedence per-pet > global > built-in, broken packs warn and never reject a pet |
30
30
 
@@ -102,11 +102,14 @@ Every word in the thought bubble (status / tool / whisper copy) and the hover pa
102
102
  "shell": ["Running {hint}", "Hit enter: {hint}"]
103
103
  },
104
104
  "toolRemaining": ["{n} helpers still at work"], // {n} allowed
105
- "whispers": { // murmur pools; each section replaces the built-in one
106
- "generic": ["On it", "Almost there"], // ambient pool; an explicit empty array mutes it
107
- "rules": [ // ordered keyword rules; given rules replace the built-ins
108
- { "keywords": ["all tests pass"], "pool": ["All green!"] }
109
- ]
105
+ "whispers": { // murmur pools; keys replace the built-in pools
106
+ "categories": { // situation pools; an explicit empty array mutes that category
107
+ "thinking": ["Let me think..."],
108
+ "running": ["It's running now"]
109
+ },
110
+ "results": { // outcome pools (test green / error / completion)
111
+ "pass": ["All green!"]
112
+ }
110
113
  },
111
114
  "panel": { // hover panel; unset slots keep the plugin i18n copy
112
115
  "labels": { "feed": "Treat", "hide": "Dive", "rename": "Rename me", "confirm": "Sure" },
@@ -116,11 +119,11 @@ Every word in the thought bubble (status / tool / whisper copy) and the hover pa
116
119
  }
117
120
  ```
118
121
 
119
- - Merge precedence (per slot): the pet voice.json > the global .voice.json > built-in copy. status/tools merge per key, whispers replace per section, panel merges per slot; any slot a layer misses falls through.
122
+ - Merge precedence (per slot): the pet voice.json > the global .voice.json > built-in copy. status/tools/whispers merge per key, panel merges per slot; any slot a layer misses falls through.
120
123
  - Placeholder whitelist: tools accept {tool} / {hint}; toolRemaining accepts {n}; panel.stats accept {rank} / {n} / {points}; status, whisper and panel-label lines accept no placeholders (lines carrying one are dropped with a warning).
121
- - Caps (warn-and-drop): at most 64 lines per pool and 160 characters per line; at most 32 rules and 16 keywords (40 characters) per rule; panel labels 40 and stats 80 characters.
124
+ - Caps (warn-and-drop): at most 64 lines per pool and 160 characters per line; panel labels 40 and stats 80 characters.
122
125
  - A broken pack never breaks the pet: voice.json that is not valid JSON or whose root is not an object is ignored with a warning; every other issue drops its slot only. Diagnostics appear under Settings > Pet directory diagnostics. node scripts/dsh-pet validate <dir> fails installs on structure errors and lists content issues as warnings.
123
- - Semantics: an empty status/tools pool falls back to the built-in copy (a scene line always renders); an explicit empty whisper pool mutes that channel; an empty panel actions array hides all three buttons; uncovered buttons and stats keep the plugin bilingual dictionary.
126
+ - Semantics: an empty status/tools pool falls back to the built-in copy (a scene line always renders); an explicit empty whisper pool mutes that channel; an empty panel actions array hides all three buttons; uncovered buttons and stats keep the plugin bilingual dictionary. Legacy whispers.generic / whispers.rules fields are no longer supported and are ignored with a warning.
124
127
 
125
128
  ## Live2D pets (renderer: live2d)
126
129
 
package/README.zh.md CHANGED
@@ -23,8 +23,8 @@
23
23
  | 拖动 | 按住拖动宠物换位置;位置持久化 |
24
24
  | 隐藏/召唤 | 悬浮面板位于宠物下方(下方空间不足时上移到状态气泡之上)并提供 隐藏;隐藏后出现 召唤{name} 按钮 |
25
25
  | 妙语库 | 内置默认妙语库(每类事件 10 句)+ 宠物自定义台词;成功文案按持久化成功次数轮换,冷却文案按持久化拒绝次数轮换 |
26
- | 状态气泡 | 默认只有最近活动的顶层会话说话——多会话并行时,其余会话收进主气泡右上角的 +N 角标,不再叠出一长列;悬停气泡(触屏点按角标)即可向上展开所有会话的气泡,点击任一气泡跳转到对应会话;子代理会话借由其发起会话体现,不占用独立气泡;瞬时互动反馈临时优先。气泡文案按场景准备了大量轮换词库(等待 / 思考 / 整理 / 完成 / 失败……),工具调用按工具族映射俏皮文案并带上真实参数(如 跑跑 npm test),同一场景持续数秒会自动换一种说法 |
27
- | 碎碎念 | 模型流式输出期间,宠物会偶尔借自己的气泡说出内心独白——新鲜的碎碎念会接管展示会话的气泡并以「」引号标记——与状态气泡共用同一片 DeepSeek 蓝黑玻璃,气泡栈内颜色统一不再色差——不再叠出第二只气泡——由模型输出里的关键词触发对应心境(报错、测试全绿、做计划、打胜仗……),输出量累积也会换来日常碎碎念;有冷却节制,数秒后气泡恢复状态文案 |
26
+ | 状态气泡 | 默认只有一个顶层会话说话——上报 GUI 当前会话时优先它,否则是最近活动的一个——其余会话收进主气泡右上角的 +N 角标,不再叠出一长列;悬停气泡(触屏点按角标)即可向上展开所有会话的气泡,点击任一气泡跳转到对应会话;子代理会话借由其发起会话体现,不占用独立气泡;瞬时互动反馈临时优先。气泡文案按场景准备了大量轮换词库(等待 / 思考 / 整理 / 完成 / 失败……),工具调用按工具族映射俏皮文案并带上真实参数(如 跑跑 npm test),同一场景持续数秒会自动换一种说法 |
27
+ | 碎碎念 | 会话流式输出期间,宠物会偶尔借该会话自己的气泡说出内心独白——新鲜的碎碎念接管所属会话的气泡并以「」引号标记——与状态气泡共用同一片 DeepSeek 蓝黑玻璃,气泡栈内颜色统一不再色差——不再叠出第二只气泡——分类台词由「正在干什么」触发(思考 / 写回复 / 正在跑的工具族),结果台词只由结构化结果触发(测试工具通过 → 全绿、工具失败 → 报错、回合完成 → 收工)——决不读模型说的话,讨论里提到关键词也不会误触发心境;碎碎念也从不引用真实内容(无工具名、路径、模型原文);分类冷却 9 秒、结果冷却 5 秒,展示 8 秒后气泡恢复状态文案 |
28
28
  | 多会话活动 | 宠物是宿主全局的:最近一次有意义事件驱动精灵动画,同时每个活动的顶层会话用自己的气泡报告各自状态;每个会话(含子代理)完成的轮次都计入亲密度与小鱼干 |
29
29
  | 语音包与面板 DIY | 宠物目录 voice.json + 全局 $DSH_HOME/pets/.voice.json 覆盖气泡全部文案与悬浮面板(按钮标签/统计格式/按钮显隐);合并优先级 宠物自带 > 全局 > 内置,坏包警告不拒载 |
30
30
 
@@ -102,11 +102,14 @@ node scripts/dsh-pet install <dir> --force # 覆盖同名已安装宠物
102
102
  "shell": ["跑跑 {hint}", "敲回车!{hint}"]
103
103
  },
104
104
  "toolRemaining": ["后台还有 {n} 位小助手"], // 允许 {n}
105
- "whispers": { // 碎碎念:按节替换(非逐条合并)
106
- "generic": ["冲了冲了", "稳"], // 环境池;显式空数组 = 静音
107
- "rules": [ // 关键词规则(有序;给出即整体替换内置规则)
108
- { "keywords": ["测试通过"], "pool": ["全绿!"] }
109
- ]
105
+ "whispers": { // 碎碎念:按键替换内置池
106
+ "categories": { // 分类池;显式空数组 = 静音该分类
107
+ "thinking": ["让我想想……"],
108
+ "running": ["正在跑,我盯着"]
109
+ },
110
+ "results": { // 结果池(测试全绿 / 报错 / 完成)
111
+ "pass": ["全绿!"]
112
+ }
110
113
  },
111
114
  "panel": { // 悬浮面板(每槽未声明时回落插件 i18n 文案)
112
115
  "labels": { "feed": "投喂", "hide": "藏起来", "rename": "起名字", "confirm": "好的" },
@@ -116,11 +119,11 @@ node scripts/dsh-pet install <dir> --force # 覆盖同名已安装宠物
116
119
  }
117
120
  ```
118
121
 
119
- - 合并优先级(逐槽):宠物自带 voice.json > 全局 .voice.json > 内置文案。status/tools 逐键合并、whispers 按节替换、panel 逐槽合并,任何层缺失的槽位回落下一层。
122
+ - 合并优先级(逐槽):宠物自带 voice.json > 全局 .voice.json > 内置文案。status/tools/whispers 逐键合并、panel 逐槽合并,任何层缺失的槽位回落下一层。
120
123
  - 占位符白名单:tools 允许 {tool} / {hint};toolRemaining 允许 {n};panel.stats 允许 {rank} / {n} / {points};status、碎碎念与面板标签不允许任何占位符(含非法占位符的行被警告丢弃)。
121
- - 上限(warn-and-drop):每池 64 行以内、每行 160 字符以内;规则 32 条以内、每规则关键词 16 条以内(40 字符以内);面板标签 40 以内、统计 80 字符以内。
124
+ - 上限(warn-and-drop):每池 64 行以内、每行 160 字符以内;面板标签 40 以内、统计 80 字符以内。
122
125
  - 坏包不影响宠物:voice.json 不是合法 JSON 或根不是对象 → 警告并整体忽略;其余问题逐槽警告丢弃。诊断显示在设置 → 宠物目录诊断。node scripts/dsh-pet validate <dir> 会把结构错误判为安装失败、内容问题列为警告。
123
- - 语义细节:status/tools 的空池回落内置文案(场景行始终有话说);whispers 的显式空数组是静音(关掉该通道);面板 actions 为空数组 = 三个按钮全部隐藏;未覆盖的按钮/统计继续使用插件双语字典。
126
+ - 语义细节:status/tools 的空池回落内置文案(场景行始终有话说);whispers 的显式空数组是静音(关掉该通道);面板 actions 为空数组 = 三个按钮全部隐藏;未覆盖的按钮/统计继续使用插件双语字典。旧的 whispers.generic / whispers.rules 字段已不再支持,会被忽略并给出警告。
124
127
 
125
128
  ## Live2D 宠物(renderer: live2d)
126
129
 
@@ -118,36 +118,31 @@
118
118
  "whispers": {
119
119
  "type": "object",
120
120
  "additionalProperties": false,
121
- "description": "Murmur pools; each section replaces the built-in one as a whole. An explicit empty array mutes that channel.",
121
+ "description": "Murmur pools: category pools keyed by the ten WhisperCategory ids plus outcome pools keyed by pass / fail / done. A key replaces that key's built-in pool; an explicit empty array mutes that channel. Legacy generic / rules fields are ignored with a warning.",
122
122
  "properties": {
123
- "generic": {
124
- "$ref": "#/definitions/pool"
123
+ "categories": {
124
+ "type": "object",
125
+ "additionalProperties": false,
126
+ "properties": {
127
+ "thinking": { "$ref": "#/definitions/pool" },
128
+ "writing": { "$ref": "#/definitions/pool" },
129
+ "reading": { "$ref": "#/definitions/pool" },
130
+ "editing": { "$ref": "#/definitions/pool" },
131
+ "running": { "$ref": "#/definitions/pool" },
132
+ "searching": { "$ref": "#/definitions/pool" },
133
+ "git": { "$ref": "#/definitions/pool" },
134
+ "delegating": { "$ref": "#/definitions/pool" },
135
+ "browsing": { "$ref": "#/definitions/pool" },
136
+ "generic": { "$ref": "#/definitions/pool" }
137
+ }
125
138
  },
126
- "rules": {
127
- "type": "array",
128
- "maxItems": 32,
129
- "items": {
130
- "type": "object",
131
- "additionalProperties": false,
132
- "required": [
133
- "keywords",
134
- "pool"
135
- ],
136
- "properties": {
137
- "keywords": {
138
- "type": "array",
139
- "maxItems": 16,
140
- "items": {
141
- "type": "string",
142
- "maxLength": 40,
143
- "minLength": 1
144
- },
145
- "description": "Lowercase substrings matched against the model's output."
146
- },
147
- "pool": {
148
- "$ref": "#/definitions/pool"
149
- }
150
- }
139
+ "results": {
140
+ "type": "object",
141
+ "additionalProperties": false,
142
+ "properties": {
143
+ "pass": { "$ref": "#/definitions/pool" },
144
+ "fail": { "$ref": "#/definitions/pool" },
145
+ "done": { "$ref": "#/definitions/pool" }
151
146
  }
152
147
  }
153
148
  }
@@ -211,6 +206,36 @@
211
206
  "description": "Subset of actions to render in canonical order; absent = all three, [] = none."
212
207
  }
213
208
  }
209
+ },
210
+ "remarks": {
211
+ "type": "object",
212
+ "additionalProperties": false,
213
+ "description": "Pat/feed interaction remark pools (issue #1226). Keyed by pet/petCooldown/feed/feedCooldown/noTreats.",
214
+ "properties": {
215
+ "pet": {
216
+ "$ref": "#/definitions/pool"
217
+ },
218
+ "petCooldown": {
219
+ "$ref": "#/definitions/pool"
220
+ },
221
+ "feed": {
222
+ "$ref": "#/definitions/pool"
223
+ },
224
+ "feedCooldown": {
225
+ "$ref": "#/definitions/pool"
226
+ },
227
+ "noTreats": {
228
+ "$ref": "#/definitions/pool"
229
+ }
230
+ }
231
+ },
232
+ "ranks": {
233
+ "type": "object",
234
+ "additionalProperties": {
235
+ "type": "string",
236
+ "maxLength": 80
237
+ },
238
+ "description": "Affinity rank name overrides (issue #1226), keyed by tier minimum points or original rank name."
214
239
  }
215
240
  },
216
241
  "definitions": {
package/cordis.patch.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-pet bundle patch: inserts its plugin row into the web plugin roster.
2
2
  # The bundle is the official dsh plugin shape — install it into a dsh profile
3
- # (`dsh plugin --profile <name> add link:/Users/zcl/code/dsh-web-ui/packages/dsh-pet`). The single
3
+ # (`dsh plugin --profile <name> add link:/Users/zcl/code/dsh-web/packages/dsh-pet`). The single
4
4
  # row mounts BOTH halves: the host side (pet registry, state machine,
5
5
  # affinity score, pet.* RPC service) and the browser side (the modules node
6
6
  # half scans dsh.client packages into the web plugin roster, served from this
package/lib/client.js CHANGED
@@ -517,8 +517,7 @@ window.__ModuleLoader__.load({
517
517
  const stackOpen = stackPeek || stackPinned;
518
518
  const visibleSessions = !stackOpen && sessionBubbles.length > 1 ? sessionBubbles.slice(0, 1) : sessionBubbles;
519
519
  const statusBubble = feedback === null && sessionBubbles.length === 0 ? snapshot?.bubble : void 0;
520
- const whisper = feedback === null ? snapshot?.whisper : void 0;
521
- const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== void 0 || whisper !== void 0;
520
+ const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== void 0;
522
521
  const displayName = snapshot?.name ?? definition.displayName;
523
522
  const decoration = snapshot?.decoration;
524
523
  (0, react.useEffect)(() => {
@@ -615,13 +614,13 @@ window.__ModuleLoader__.load({
615
614
  className: clsx(pet_module_css_default.bubble, feedback.kind === "feed" ? pet_module_css_default.bubbleFeed : pet_module_css_default.bubblePet),
616
615
  children: feedback.text
617
616
  }, feedback.at),
618
- feedback === null && (sessionBubbles.length > 0 || statusBubble !== void 0 || whisper !== void 0) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
617
+ feedback === null && (sessionBubbles.length > 0 || statusBubble !== void 0) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
619
618
  ref: bubbleRef,
620
619
  className: pet_module_css_default.bubbleStack,
621
620
  onPointerEnter: () => setStackPeek(true),
622
621
  onPointerLeave: () => setStackPeek(false),
623
622
  children: [visibleSessions.map((session, index) => {
624
- const speaksWhisper = index === 0 && whisper !== void 0;
623
+ const speaksWhisper = session.whisper !== void 0;
625
624
  const bubble = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
626
625
  type: "button",
627
626
  className: clsx(pet_module_css_default.bubble, pet_module_css_default.bubbleStatus, pet_module_css_default.bubbleClickable, speaksWhisper && pet_module_css_default.bubbleWhisper),
@@ -632,8 +631,8 @@ window.__ModuleLoader__.load({
632
631
  children: [index === 0 && !speaksWhisper && decoration !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusOrnament, {
633
632
  decoration,
634
633
  phase
635
- }), speaksWhisper ? whisper : session.bubble]
636
- }, speaksWhisper ? "whisper:" + whisper : session.sessionId);
634
+ }), session.whisper ?? session.bubble]
635
+ }, speaksWhisper ? "whisper:" + session.whisper : session.sessionId);
637
636
  if (index !== 0 || sessionBubbles.length <= 1) return bubble;
638
637
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
639
638
  className: pet_module_css_default.bubbleAnchor,
@@ -650,15 +649,15 @@ window.__ModuleLoader__.load({
650
649
  children: stackOpen ? "×" : "+" + String(sessionBubbles.length - 1)
651
650
  })]
652
651
  }, "primary");
653
- }), sessionBubbles.length === 0 && (statusBubble !== void 0 || whisper !== void 0) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
654
- className: clsx(pet_module_css_default.bubble, pet_module_css_default.bubbleStatus, whisper !== void 0 && pet_module_css_default.bubbleWhisper),
652
+ }), sessionBubbles.length === 0 && statusBubble !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
653
+ className: clsx(pet_module_css_default.bubble, pet_module_css_default.bubbleStatus),
655
654
  role: "status",
656
655
  "aria-live": "polite",
657
- children: [whisper === void 0 && decoration !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusOrnament, {
656
+ children: [decoration !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusOrnament, {
658
657
  decoration,
659
658
  phase
660
- }), whisper ?? statusBubble]
661
- }, whisper === void 0 ? "status" : "whisper:" + whisper)]
659
+ }), statusBubble]
660
+ }, "status")]
662
661
  }),
663
662
  hovered && dragRef.current === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
664
663
  ref: panelRef,
@@ -713,7 +712,12 @@ window.__ModuleLoader__.load({
713
712
  children: displayName
714
713
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
715
714
  className: pet_module_css_default.statRank,
716
- children: panelStat("rank", "pet.rank", { rank: snapshot?.affinity.rank ?? "?" })
715
+ children: (() => {
716
+ const rawRank = snapshot?.affinity.rank ?? "?";
717
+ const rankKey = `pet.rank.name.${rawRank}`;
718
+ const localized = props.t(rankKey);
719
+ return panelStat("rank", "pet.rank", { rank: localized !== rankKey ? localized : rawRank });
720
+ })()
717
721
  })]
718
722
  }),
719
723
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1812,6 +1816,18 @@ window.__ModuleLoader__.load({
1812
1816
  * phase-mapped track. Rendering never throws: a broken track list degrades
1813
1817
  * to the first decodable frame, and the 1.2 s stall watchdog re-kicks the
1814
1818
  * playback chain after timer throttling.
1819
+ *
1820
+ * Frame presentation has two modes picked once at mount by capability
1821
+ * probing:
1822
+ * - Canvas bitmap buffer (default where createImageBitmap/fetch/2D context
1823
+ * exist): every frame is decoded exactly once into an ImageBitmap during
1824
+ * the warm pass and drawn onto one <canvas> - steady-state playback issues
1825
+ * zero DOM mutations and zero re-decodes (measured hotspot: swapping
1826
+ * <img>.src per frame drove image decode + invalidation every tick).
1827
+ * - Classic <img> fallback (jsdom/tests or missing APIs): identical to the
1828
+ * historical behavior - cache-warm Image elements plus guarded src swaps,
1829
+ * so environments without modern decoding keep working unchanged.
1830
+ *
1815
1831
  * @module @linxin666/dsh-pet/client/renderers/frames2d
1816
1832
  */
1817
1833
  /** Stall watchdog period: a looping track stuck longer re-kicks its chain. */
@@ -1845,19 +1861,79 @@ window.__ModuleLoader__.load({
1845
1861
  validateConfig: validateFrames2dConfig,
1846
1862
  mount(ctx, config) {
1847
1863
  const reducedMotion = typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1848
- const img = document.createElement("img");
1849
- img.dataset.dshPetFrames2d = ctx.petId;
1850
- img.alt = "";
1851
- img.draggable = false;
1852
- img.style.width = "100%";
1853
- img.style.height = "100%";
1854
- img.style.objectFit = "contain";
1855
- img.style.pointerEvents = "none";
1856
- ctx.container.appendChild(img);
1857
- for (const track of Object.values(config.tracks)) for (const url of track.frames) {
1858
- const pre = new Image();
1859
- pre.src = url;
1864
+ let canvas = null;
1865
+ let context2d = null;
1866
+ let img = null;
1867
+ try {
1868
+ if (typeof createImageBitmap === "function" && typeof fetch === "function") {
1869
+ const probe = document.createElement("canvas");
1870
+ const c2d = probe.getContext("2d");
1871
+ if (c2d !== null) {
1872
+ canvas = probe;
1873
+ context2d = c2d;
1874
+ }
1875
+ }
1876
+ } catch {
1877
+ canvas = null;
1878
+ context2d = null;
1879
+ }
1880
+ if (canvas !== null && context2d !== null) {
1881
+ canvas.dataset.dshPetFrames2d = ctx.petId;
1882
+ canvas.draggable = false;
1883
+ canvas.style.width = "100%";
1884
+ canvas.style.height = "100%";
1885
+ canvas.style.objectFit = "contain";
1886
+ canvas.style.pointerEvents = "none";
1887
+ ctx.container.appendChild(canvas);
1888
+ } else {
1889
+ img = document.createElement("img");
1890
+ img.dataset.dshPetFrames2d = ctx.petId;
1891
+ img.alt = "";
1892
+ img.draggable = false;
1893
+ img.style.width = "100%";
1894
+ img.style.height = "100%";
1895
+ img.style.objectFit = "contain";
1896
+ img.style.pointerEvents = "none";
1897
+ ctx.container.appendChild(img);
1860
1898
  }
1899
+ const decoding = /* @__PURE__ */ new Map();
1900
+ const decodedAll = [];
1901
+ const loadFrame = (url) => {
1902
+ const cached = decoding.get(url);
1903
+ if (cached !== void 0) return cached;
1904
+ const job = (async () => {
1905
+ try {
1906
+ const response = await fetch(url);
1907
+ if (!response.ok) throw new Error("http " + response.status);
1908
+ const bitmap = await createImageBitmap(await response.blob());
1909
+ return {
1910
+ source: bitmap,
1911
+ width: bitmap.width,
1912
+ height: bitmap.height
1913
+ };
1914
+ } catch {
1915
+ return await new Promise((resolve) => {
1916
+ try {
1917
+ const pre = new Image();
1918
+ pre.onload = () => {
1919
+ resolve(pre.naturalWidth > 0 ? {
1920
+ source: pre,
1921
+ width: pre.naturalWidth,
1922
+ height: pre.naturalHeight
1923
+ } : void 0);
1924
+ };
1925
+ pre.onerror = () => resolve(void 0);
1926
+ pre.src = url;
1927
+ } catch {
1928
+ resolve(void 0);
1929
+ }
1930
+ });
1931
+ }
1932
+ })();
1933
+ decoding.set(url, job);
1934
+ decodedAll.push(job.then(() => void 0, () => void 0));
1935
+ return job;
1936
+ };
1861
1937
  let disposed = false;
1862
1938
  let timer;
1863
1939
  let watchdog;
@@ -1865,13 +1941,35 @@ window.__ModuleLoader__.load({
1865
1941
  let frameIndex = 0;
1866
1942
  let lastAdvance = Date.now();
1867
1943
  let override;
1944
+ let drawToken = 0;
1945
+ let lastDrawnUrl;
1868
1946
  const trackForPhase = (phase) => {
1869
1947
  const mapped = config.phases[phase];
1870
1948
  return mapped !== void 0 && config.tracks[mapped] !== void 0 ? mapped : config.phases.idle;
1871
1949
  };
1950
+ /** Canvas path: paints the newest requested frame; stale draws drop out. */
1951
+ const paintCanvas = (url) => {
1952
+ if (context2d === null || canvas === null) return;
1953
+ const myToken = ++drawToken;
1954
+ loadFrame(url).then((frame) => {
1955
+ if (disposed || frame === void 0 || myToken !== drawToken) return;
1956
+ if (lastDrawnUrl === url) return;
1957
+ lastDrawnUrl = url;
1958
+ if (canvas.width !== frame.width || canvas.height !== frame.height) {
1959
+ canvas.width = frame.width;
1960
+ canvas.height = frame.height;
1961
+ } else context2d.clearRect(0, 0, canvas.width, canvas.height);
1962
+ context2d.drawImage(frame.source, 0, 0);
1963
+ }).catch(() => {});
1964
+ };
1872
1965
  const show = (trackId, index) => {
1873
1966
  const url = config.tracks[trackId]?.frames[index];
1874
- if (url !== void 0 && img.getAttribute("src") !== url) img.src = url;
1967
+ if (url === void 0) return;
1968
+ if (img !== null) {
1969
+ if (img.getAttribute("src") !== url) img.src = url;
1970
+ return;
1971
+ }
1972
+ paintCanvas(url);
1875
1973
  };
1876
1974
  const schedule = (ms) => {
1877
1975
  if (disposed) return;
@@ -1923,6 +2021,7 @@ window.__ModuleLoader__.load({
1923
2021
  const expected = (def.durations[frameIndex] ?? 200) + WATCHDOG_MS;
1924
2022
  if (Date.now() - lastAdvance > expected) tick();
1925
2023
  }, WATCHDOG_MS);
2024
+ for (const warmTrack of Object.values(config.tracks)) for (const warmUrl of warmTrack.frames) loadFrame(warmUrl);
1926
2025
  play(track);
1927
2026
  let disposedOnce = false;
1928
2027
  const dispose = () => {
@@ -1932,7 +2031,17 @@ window.__ModuleLoader__.load({
1932
2031
  unsubscribe();
1933
2032
  if (timer !== void 0) clearTimeout(timer);
1934
2033
  if (watchdog !== void 0) clearInterval(watchdog);
1935
- img.remove();
2034
+ Promise.allSettled(decodedAll).then(() => {
2035
+ for (const job of decoding.values()) job.then((frame) => {
2036
+ try {
2037
+ const maybeClose = (frame?.source)?.close;
2038
+ if (typeof maybeClose === "function" && frame !== void 0) maybeClose.call(frame.source);
2039
+ } catch {}
2040
+ }).catch(() => {});
2041
+ decoding.clear();
2042
+ });
2043
+ canvas?.remove();
2044
+ img?.remove();
1936
2045
  };
1937
2046
  ctx.onCleanup(dispose);
1938
2047
  return {
@@ -3082,6 +3191,15 @@ window.__ModuleLoader__.load({
3082
3191
  "pet.namePlaceholder": "输入新名字",
3083
3192
  "pet.summon": "召唤{name}",
3084
3193
  "pet.rank": "亲密度 {rank}",
3194
+ "pet.rank.name.幼鲸": "幼鲸",
3195
+ "pet.rank.name.伙伴": "伙伴",
3196
+ "pet.rank.name.挚友": "挚友",
3197
+ "pet.rank.name.深海羁绊": "深海羁绊",
3198
+ "pet.rank.name.心有灵犀": "心有灵犀",
3199
+ "pet.rank.name.传说羁绊": "传说羁绊",
3200
+ "pet.rank.name.神话羁绊": "神话羁绊",
3201
+ "pet.rank.name.永恒之契": "永恒之契",
3202
+ "pet.rank.name.鲸生共渡": "鲸生共渡",
3085
3203
  "pet.points": "{points} 点",
3086
3204
  "pet.treats": "小鱼干 ×{n}",
3087
3205
  "pet.state.loading": "宠物正在赶来…",
@@ -3152,6 +3270,15 @@ window.__ModuleLoader__.load({
3152
3270
  "pet.namePlaceholder": "Enter a new name",
3153
3271
  "pet.summon": "Summon {name}",
3154
3272
  "pet.rank": "Affinity {rank}",
3273
+ "pet.rank.name.幼鲸": "Baby Whale",
3274
+ "pet.rank.name.伙伴": "Companion",
3275
+ "pet.rank.name.挚友": "Close Friend",
3276
+ "pet.rank.name.深海羁绊": "Deep Sea Bond",
3277
+ "pet.rank.name.心有灵犀": "Kindred Spirit",
3278
+ "pet.rank.name.传说羁绊": "Legendary Bond",
3279
+ "pet.rank.name.神话羁绊": "Mythic Bond",
3280
+ "pet.rank.name.永恒之契": "Eternal Covenant",
3281
+ "pet.rank.name.鲸生共渡": "Lifelong Companion",
3155
3282
  "pet.points": "{points} pts",
3156
3283
  "pet.treats": "Treats ×{n}",
3157
3284
  "pet.state.loading": "The pet is on its way…",
@@ -3243,7 +3370,7 @@ window.__ModuleLoader__.load({
3243
3370
  /** The building package's version, when the bundle carries it. */
3244
3371
  function bakedVersion() {
3245
3372
  try {
3246
- return "0.3.4";
3373
+ return "0.3.6";
3247
3374
  } catch {
3248
3375
  return;
3249
3376
  }
@@ -3319,7 +3446,7 @@ window.__ModuleLoader__.load({
3319
3446
  }
3320
3447
  /** The live host API instance (always defined; failures surface per call). */
3321
3448
  const petApi = {
3322
- state: () => petFetch("/api/pet/state"),
3449
+ state: (currentSessionId) => petFetch("/api/pet/state" + (currentSessionId === void 0 ? "" : "?current=" + encodeURIComponent(currentSessionId))),
3323
3450
  pets: () => petFetch("/api/pet/pets"),
3324
3451
  interact: (kind) => petFetch("/api/pet/interact", { kind }),
3325
3452
  setVisible: (visible) => petFetch("/api/pet/set-visible", { visible }),
@@ -3406,6 +3533,11 @@ window.__ModuleLoader__.load({
3406
3533
  const setPets = petStore.actions.setPets;
3407
3534
  const setState = petStore.actions.setState;
3408
3535
  const setFeedback = petStore.actions.setFeedback;
3536
+ const sessions = ctx.sessions;
3537
+ const currentSessionId = () => {
3538
+ const current = sessions.list.getSnapshot().current;
3539
+ return current === void 0 ? void 0 : String(current);
3540
+ };
3409
3541
  let petsLoaded = false;
3410
3542
  let stateSeq = 0;
3411
3543
  const pollNow = () => {
@@ -3415,7 +3547,7 @@ window.__ModuleLoader__.load({
3415
3547
  }, () => {});
3416
3548
  const seq = stateSeq + 1;
3417
3549
  stateSeq = seq;
3418
- petApi.state().then((snapshot) => {
3550
+ petApi.state(currentSessionId()).then((snapshot) => {
3419
3551
  if (seq !== stateSeq) return;
3420
3552
  setSnapshot(snapshot);
3421
3553
  }, () => {
@@ -3447,7 +3579,11 @@ window.__ModuleLoader__.load({
3447
3579
  document.removeEventListener("visibilitychange", onVisibility);
3448
3580
  };
3449
3581
  }, "pet: poll");
3450
- const sessions = ctx.sessions;
3582
+ ctx.effect(() => {
3583
+ return sessions.list.subscribe(() => {
3584
+ if (document.visibilityState === "visible") pollNow();
3585
+ });
3586
+ }, "pet: current-session watch");
3451
3587
  const openSession = (sessionId) => {
3452
3588
  if (sessions.list.getSnapshot().byId[sessionId] === void 0) return;
3453
3589
  sessions.open(sessionId);