@linxin666/dsh-pet 0.3.3 → 0.3.5

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 (123) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +30 -13
  3. package/README.zh.md +30 -13
  4. package/assets/ouo-neko/pet.json +31 -0
  5. package/assets/ouo-neko/previews/failed.gif +0 -0
  6. package/assets/ouo-neko/previews/idle.gif +0 -0
  7. package/assets/ouo-neko/previews/jumping.gif +0 -0
  8. package/assets/ouo-neko/previews/review.gif +0 -0
  9. package/assets/ouo-neko/previews/running-left.gif +0 -0
  10. package/assets/ouo-neko/previews/running-right.gif +0 -0
  11. package/assets/ouo-neko/previews/running.gif +0 -0
  12. package/assets/ouo-neko/previews/waiting.gif +0 -0
  13. package/assets/ouo-neko/previews/waving.gif +0 -0
  14. package/assets/ouo-neko/spritesheet.webp +0 -0
  15. package/contracts/pet-manifest-v2.schema.json +102 -1
  16. package/contracts/voice-pack-v1.schema.json +23 -28
  17. package/lib/client.js +781 -40
  18. package/lib/client.js.map +1 -1
  19. package/lib/index.js +1567 -482
  20. package/lib/types/chatter.d.ts +68 -46
  21. package/lib/types/chatter.d.ts.map +1 -1
  22. package/lib/types/chatter.js +243 -301
  23. package/lib/types/client/PetDockEntry.d.ts +3 -0
  24. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  25. package/lib/types/client/PetDockEntry.js +18 -2
  26. package/lib/types/client/PetSprite.d.ts +23 -0
  27. package/lib/types/client/PetSprite.d.ts.map +1 -1
  28. package/lib/types/client/PetSprite.js +49 -45
  29. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  30. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  31. package/lib/types/client/PluginSettingsCard.js +2 -2
  32. package/lib/types/client/drag-stream.d.ts +19 -0
  33. package/lib/types/client/drag-stream.d.ts.map +1 -0
  34. package/lib/types/client/drag-stream.js +26 -0
  35. package/lib/types/client/gameplay-hud.d.ts +50 -0
  36. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  37. package/lib/types/client/gameplay-hud.js +274 -0
  38. package/lib/types/client/index.d.ts.map +1 -1
  39. package/lib/types/client/index.js +38 -9
  40. package/lib/types/client/locales.d.ts +34 -0
  41. package/lib/types/client/locales.d.ts.map +1 -1
  42. package/lib/types/client/locales.js +36 -0
  43. package/lib/types/client/pet-store.d.ts +7 -1
  44. package/lib/types/client/pet-store.d.ts.map +1 -1
  45. package/lib/types/client/pet-store.js +4 -0
  46. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  47. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  48. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  49. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  50. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  51. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  52. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  53. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  54. package/lib/types/client/renderers/frames2d.js +196 -0
  55. package/lib/types/defaults.d.ts +6 -0
  56. package/lib/types/defaults.d.ts.map +1 -0
  57. package/lib/types/defaults.js +5 -0
  58. package/lib/types/dsh-home.d.ts.map +1 -1
  59. package/lib/types/dsh-home.js +9 -3
  60. package/lib/types/event-projection.d.ts +9 -4
  61. package/lib/types/event-projection.d.ts.map +1 -1
  62. package/lib/types/event-projection.js +46 -14
  63. package/lib/types/gameplay.d.ts +182 -0
  64. package/lib/types/gameplay.d.ts.map +1 -0
  65. package/lib/types/gameplay.js +618 -0
  66. package/lib/types/ledger.d.ts +14 -0
  67. package/lib/types/ledger.d.ts.map +1 -1
  68. package/lib/types/ledger.js +30 -0
  69. package/lib/types/manifest-v2.d.ts +38 -1
  70. package/lib/types/manifest-v2.d.ts.map +1 -1
  71. package/lib/types/manifest-v2.js +182 -2
  72. package/lib/types/persist.d.ts +4 -4
  73. package/lib/types/persist.d.ts.map +1 -1
  74. package/lib/types/persist.js +41 -4
  75. package/lib/types/registry.d.ts +29 -0
  76. package/lib/types/registry.d.ts.map +1 -1
  77. package/lib/types/registry.js +170 -1
  78. package/lib/types/routes.d.ts.map +1 -1
  79. package/lib/types/routes.js +30 -3
  80. package/lib/types/service.d.ts +63 -11
  81. package/lib/types/service.d.ts.map +1 -1
  82. package/lib/types/service.js +208 -19
  83. package/lib/types/voice-pack.d.ts +6 -7
  84. package/lib/types/voice-pack.d.ts.map +1 -1
  85. package/lib/types/voice-pack.js +44 -47
  86. package/package.json +5 -2
  87. package/src/chatter.test.ts +68 -38
  88. package/src/chatter.ts +269 -316
  89. package/src/client/PetDockEntry.test.tsx +6 -0
  90. package/src/client/PetDockEntry.tsx +32 -1
  91. package/src/client/PetSprite.test.tsx +68 -21
  92. package/src/client/PetSprite.tsx +63 -45
  93. package/src/client/PluginSettingsCard.tsx +4 -2
  94. package/src/client/drag-stream.ts +35 -0
  95. package/src/client/gameplay-hud.test.tsx +338 -0
  96. package/src/client/gameplay-hud.tsx +403 -0
  97. package/src/client/index.test.tsx +10 -1
  98. package/src/client/index.ts +46 -11
  99. package/src/client/locales.ts +36 -0
  100. package/src/client/pet-store.ts +10 -1
  101. package/src/client/pet.module.css +192 -29
  102. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  103. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  104. package/src/client/renderers/frames2d.test.ts +137 -0
  105. package/src/client/renderers/frames2d.ts +216 -0
  106. package/src/client/settings-card.module.css +5 -0
  107. package/src/defaults.ts +7 -0
  108. package/src/dsh-home.ts +9 -3
  109. package/src/event-projection.ts +57 -16
  110. package/src/gameplay.test.ts +239 -0
  111. package/src/gameplay.ts +692 -0
  112. package/src/ledger.test.ts +16 -0
  113. package/src/ledger.ts +31 -0
  114. package/src/manifest-v2.test.ts +130 -1
  115. package/src/manifest-v2.ts +181 -2
  116. package/src/persist.test.ts +3 -0
  117. package/src/persist.ts +43 -6
  118. package/src/registry.test.ts +168 -1
  119. package/src/registry.ts +199 -1
  120. package/src/routes.ts +28 -4
  121. package/src/service.ts +240 -29
  122. package/src/voice-pack.test.ts +48 -25
  123. package/src/voice-pack.ts +50 -47
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@ import { Service } from "@deepseek-ai/cordis";
5
5
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
6
6
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
7
7
  import { homedir } from "node:os";
8
+ import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import { readFile } from "node:fs/promises";
10
11
  //#region src/chatter.ts
@@ -505,411 +506,256 @@ var StatusVoice = class {
505
506
  return this.voice("toolRemaining", "toolRemaining", pool, nowMs).replaceAll("{n}", String(count));
506
507
  }
507
508
  };
508
- /** Murmur pacing: cooldown between whispers and output volume that earns one. */
509
- const WHISPER_COOLDOWN_MS = 9e3;
510
- /** Ambient inner-whisper pool (no keyword needed; earned by output volume). */
511
- const WHISPER_GENERIC_POOL = [
512
- "哼哧哼哧,大脑转得飞快~",
513
- "loading 99%……就差最后一步",
514
- "嗯……让我捋捋",
515
- "灵感来了,先记小本本上",
516
- "脑子在冒烟,但还能撑",
517
- "这个报错,我好像在哪见过",
518
- "专注模式 ON,请勿打扰",
519
- "思路通了,舒服了",
520
- "有点困……不行,还能肝",
521
- "让我嚼一嚼这个问题",
522
- "盘,都可以盘",
523
- "这波操作,我给自己点个赞",
524
- "别催别催,在想呢",
525
- "唔,这个细节差点漏掉",
526
- "脑子转太快,差点绕晕自己",
527
- "陪你干活,稳赚不亏",
528
- "深呼吸,马上就好",
529
- "诶,等等,好像发现了什么",
530
- "手速拉满,键盘冒火星",
531
- "摸鱼是不可能摸鱼的,就瞄一眼窗外",
532
- "今天也是稳扎稳打的一天",
533
- "把大问题拆成小饼干,一口一个",
534
- "这题有戏,我闻到了",
535
- "尾巴轻晃,心情有点小得意",
536
- "好结果是熬出来的,不慌",
537
- "啊,想岔了,重新来",
538
- "嗯嗯,思路对头,就这么干",
539
- "小本本记满了,都是干货",
540
- "打完这波,求摸摸奖励~",
541
- "这坑我记住了,下次绕道",
542
- "窗外云在飘,代码在跑,挺好",
543
- "嘘,正到关键处",
544
- "这个方案……让我再品品",
545
- "干活呢,别打扰我数数",
546
- "心里默默给你比了个耶",
547
- "思路像小鱼,逮住一条是一条",
548
- "嗯……有点东西,等我深挖",
549
- "收个尾就能喘口气了",
550
- "目标锁定,冲就完了",
551
- "嗯,这波配合不错",
552
- "困了……还能再战三回合",
553
- "思考.gif 加载中",
554
- "我本地能跑啊……哦,我就是干活的",
555
- "有点饿,小鱼干存货还够吗",
556
- "刚想通,一被打断又忘了,气",
557
- "缓冲中,请稍候",
558
- "这网速,比我思考还慢",
559
- "脑子在后台跑批",
560
- "内存不足,但热情够",
561
- "404:思路未找到,重试中",
562
- "这需求有点玄学,但能写",
563
- "诶,这 bug 还会闪现?",
564
- "刚说简单,结果打脸了",
565
- "自信满满,结果翻车",
566
- "这活不难,就是有点复杂",
567
- "我装的,其实心里没底",
568
- "别看我稳,我也慌",
569
- "假装很懂的样子,其实在查文档",
570
- "窗外鸟叫了两声,我听到了",
571
- "打了个哈欠,没人看见",
572
- "今天的状态:七分精神三分困",
573
- "刚想偷懒,又想起来你还在等",
574
- "数了数今天的产出,还行",
575
- "有点想伸懒腰",
576
- "饿意来袭,忍住",
577
- "灵感像猫一样,不追它自己来",
578
- "坐太久,尾巴麻了",
579
- "快了快了",
580
- "马上马上",
581
- "等下,我记得在哪见过",
582
- "呃,忘了,重新想",
583
- "诶,这个思路可以",
584
- "嗯?有意思",
585
- "行,就这么办",
586
- "好嘞",
587
- "收到收到",
588
- "冲了冲了",
589
- "稳",
590
- "妥",
591
- "得嘞",
592
- "你忙你的,我盯着呢",
593
- "放心,有我呢",
594
- "咱俩配合,无往不利",
595
- "你专注的样子,我默默记下了"
509
+ /** Every whisper category key, in declaration order (voice-pack key allow-list). */
510
+ const WHISPER_CATEGORIES = [
511
+ "thinking",
512
+ "writing",
513
+ "reading",
514
+ "editing",
515
+ "running",
516
+ "searching",
517
+ "git",
518
+ "delegating",
519
+ "browsing",
520
+ "generic"
596
521
  ];
597
- /** Keyword-triggered whisper rules, most specific moods first. */
598
- const WHISPER_RULES = [
599
- {
600
- keywords: [
601
- "测试通过",
602
- "测试全过",
603
- "全部通过",
604
- "all tests pass",
605
- "tests passed",
606
- "test passed",
607
- "全绿"
608
- ],
609
- pool: [
610
- "全绿!亮瞎我眼了",
611
- "测试全过,击掌~",
612
- "稳了稳了,这波稳得很",
613
- "绿灯一排排,看着就舒坦",
614
- "能跑!没报错!",
615
- "全绿,收工摸鱼去",
616
- "测试过了,尾巴翘上天",
617
- "漂亮,一次过",
618
- "测试过了,奖励自己一口小鱼干",
619
- "绿得发光,稳",
620
- "又双叒叕全绿",
621
- "这波测试,赢得干脆"
622
- ]
623
- },
624
- {
625
- keywords: [
626
- "错误",
627
- "失败",
628
- "报错",
629
- "异常",
630
- "崩溃",
631
- "bug",
632
- "error",
633
- "failed",
634
- "exception",
635
- "traceback",
636
- "找不到",
637
- "不对了"
638
- ],
639
- pool: [
640
- "哎呀,踩到小石子了",
641
- "翻车了……没事,扶起来继续",
642
- "错误是进步的脚印,我懂",
643
- "这报错我盯上它了",
644
- "我本地能跑啊?哦,我一直在跑",
645
- "别慌,深呼吸,先看报错",
646
- "bug 你站住,我看见你了",
647
- "绷不住了……好,继续修",
648
- "报错这东西,见一个修一个",
649
- "又是它,老熟人了",
650
- "问题不大,就是有点问题",
651
- "先别慌,我看看到底咋回事",
652
- "这错报得,比我还委屈",
653
- "修好它,今天才不算白干"
654
- ]
655
- },
656
- {
657
- keywords: [
658
- "等等",
659
- "不对",
660
- "重新想",
661
- "再想想",
662
- "换个思路",
663
- "我搞错了",
664
- "纠正",
665
- "其实应该"
666
- ],
667
- pool: [
668
- "嗯?让我再想想……",
669
- "推翻重来,也是种勇气",
670
- "发现岔路,及时掉头",
671
- "不对不对,重来重来",
672
- "自我纠错的瞬间,最帅了",
673
- "呃,刚说错了,收回",
674
- "哎,绕远了,拉回来",
675
- "回头一看,原来这么简单",
676
- "纠正完,思路清爽多了",
677
- "转弯不丢人,卡死才丢人"
678
- ]
679
- },
680
- {
681
- keywords: [
682
- "首先",
683
- "接下来",
684
- "第一步",
685
- "第二步",
686
- "计划",
687
- "步骤",
688
- "todo",
689
- "任务拆解",
690
- "分工"
691
- ],
692
- pool: [
693
- "排排坐,分果果",
694
- "计划通,执行开始",
695
- "一步一步来,不慌",
696
- "大任务切成小块块,好下口",
697
- "清单列好了,逐个击破",
698
- "谋定而后动,这节奏我熟",
699
- "先干这个,再干那个",
700
- "头绪理清了,开整",
701
- "步骤在手,心里不慌",
702
- "安排得明明白白"
703
- ]
704
- },
705
- {
706
- keywords: [
707
- "终于",
708
- "搞定",
709
- "完成了",
710
- "解决了",
711
- "成功了",
712
- "修复了",
713
- "done",
714
- "fixed",
715
- "solved",
716
- "完成啦"
717
- ],
718
- pool: [
719
- "太好了,又翻过一页",
720
- "搞定,收工~",
721
- "攻下一城,击掌!",
722
- "难题被拿下了,转个圈",
723
- "努力没白费,开心",
724
- "齐活,漂亮",
725
- "收工收工,今天圆满",
726
- "完成!心里踏实了",
727
- "这波,稳得一批",
728
- "任务清零,舒服",
729
- "搞定,可以伸个懒腰了",
730
- "又完成一件,成就感+1"
731
- ]
732
- },
733
- {
734
- keywords: [
735
- "谢谢",
736
- "感谢",
737
- "thank"
738
- ],
739
- pool: [
740
- "不客气呀,顺手的事",
741
- "被感谢了,心里甜甜的",
742
- "能帮上忙就好~",
743
- "你的谢意,我收进口袋啦",
744
- "这话我爱听",
745
- "客气啥,应该的",
746
- "收下这份心意,干劲+1",
747
- "你谢我,我谢你,扯平啦"
748
- ]
749
- },
750
- {
751
- keywords: [
752
- "复杂",
753
- "棘手",
754
- "困难",
755
- "难点",
756
- "坑",
757
- "头疼",
758
- "tricky",
759
- "complex"
760
- ],
761
- pool: [
762
- "难不倒我们俩的",
763
- "越难啃的骨头越香",
764
- "硬骨头?我最喜欢了",
765
- "复杂问题拆开看,小事",
766
- "这坑我们一起填",
767
- "有点东西,但不多",
768
- "硬骨头,慢慢啃",
769
- "问题越难,赢的时候越爽",
770
- "绕是绕不过去的,正面刚",
771
- "再难的题,拆开都是小问号"
772
- ]
773
- },
774
- {
775
- keywords: [
776
- "检查",
777
- "审查",
778
- "确认一下",
779
- "核对",
780
- "review",
781
- "仔细看看",
782
- "验证"
783
- ],
784
- pool: [
785
- "火眼金睛,启动",
786
- "让我仔细瞧瞧",
787
- "细节魔鬼,一个都不放过",
788
- "认真检查的样子最迷人",
789
- "多核一遍,稳上加稳",
790
- "再看一眼,不亏",
791
- "确认键,点了才安心",
792
- "细节控上线",
793
- "查完这遍,稳了"
794
- ]
795
- },
796
- {
797
- keywords: [
798
- "搜索",
799
- "查一下",
800
- "资料",
801
- "文档",
802
- "搜一搜",
803
- "找找",
804
- "查询"
805
- ],
806
- pool: [
807
- "去知识的海洋里捞一捞",
808
- "翻翻找找,线索快出来",
809
- "检索小雷达启动",
810
- "答案就藏在某个角落",
811
- "线索有点散,拼一下",
812
- "找东西,我最在行",
813
- "答案在网线那头等我",
814
- "翻箱倒柜中,稍等"
815
- ]
816
- },
817
- {
818
- keywords: [
819
- "写代码",
820
- "实现",
821
- "编码",
822
- "函数",
823
- "接口",
824
- "重构"
825
- ],
826
- pool: [
827
- "指尖跳舞,代码开花",
828
- "把逻辑织成网",
829
- "一行一行,垒起小城堡",
830
- "这代码写得,我自己都佩服",
831
- "码着码着,天就亮了",
832
- "代码跑通了,比中奖还开心",
833
- "这行代码,写得有点帅",
834
- "写完再润润,讲究"
835
- ]
836
- }
522
+ /** Every whisper outcome key, in declaration order (voice-pack key allow-list). */
523
+ const WHISPER_RESULTS = [
524
+ "pass",
525
+ "fail",
526
+ "done"
837
527
  ];
838
- /** The built-in voice pack: the plugin's default copy, unchanged since v1. */
528
+ /** Murmur pacing: the cooldown between category whispers. */
529
+ const WHISPER_COOLDOWN_MS = 9e3;
530
+ /** Outcome whispers get their own shorter cooldown so a real moment still speaks. */
531
+ const WHISPER_RESULT_COOLDOWN_MS = 5e3;
532
+ /** Map a tool family onto the whisper category it belongs to. */
533
+ function whisperCategoryOf(tool) {
534
+ switch (tool) {
535
+ case "read":
536
+ case "grep":
537
+ case "find":
538
+ case "ls": return "reading";
539
+ case "write":
540
+ case "edit": return "editing";
541
+ case "shell": return "running";
542
+ case "webSearch":
543
+ case "webFetch":
544
+ case "memory":
545
+ case "mcp": return "searching";
546
+ case "git": return "git";
547
+ case "subagent":
548
+ case "todo": return "delegating";
549
+ case "browser": return "browsing";
550
+ case "ask":
551
+ case "generic": return "generic";
552
+ }
553
+ }
554
+ /**
555
+ * Whether a tool invocation looks like a test run. The whisper engine never
556
+ * reads the model's prose (a discussion that merely mentions a keyword must
557
+ * not wake a mood); a test-outcome mood is wanted only when a test tool
558
+ * actually ran, so the projection marks the call at tool/call time and the
559
+ * pass mood fires from the paired tool/result.
560
+ */
561
+ function looksLikeTestTool(name, argumentsText) {
562
+ const tool = name.toLowerCase();
563
+ if (/(^|[\/_.-])(test|tests|spec|vitest|jest|pytest|mocha|playwright|cypress|karma)([\/_.-]|$)/.test(tool)) return true;
564
+ if (argumentsText === void 0) return false;
565
+ let haystack = argumentsText.toLowerCase();
566
+ try {
567
+ const parsed = JSON.parse(argumentsText);
568
+ if (typeof parsed === "object" && parsed !== null) {
569
+ const record = parsed;
570
+ const command = record.command;
571
+ const code = record.code;
572
+ const picked = typeof command === "string" && command !== "" ? command : typeof code === "string" && code !== "" ? code : void 0;
573
+ if (picked !== void 0) haystack = picked.toLowerCase();
574
+ }
575
+ } catch {}
576
+ return /\b(pnpm|npm|yarn|npx|bun|python)\s+(run\s+)?(test|tests?)\b/.test(haystack) || /\b(pytest|vitest|jest|mocha|cypress|playwright|go test|cargo test)\b/.test(haystack);
577
+ }
578
+ /** Category-level inner-whisper pools — the pet knows roughly what is going on. */
579
+ const WHISPER_CATEGORY_POOLS = {
580
+ thinking: [
581
+ "先在脑子里搭个框架",
582
+ "它在心里打草稿,我垫着脚看",
583
+ "思路在一颗一颗冒泡",
584
+ "脑内开会中,都别抢话筒",
585
+ "先想清楚,再动手不迟",
586
+ "草稿纸已经画满了",
587
+ "让我听听它下一步打算",
588
+ "嗯,方案在成型了"
589
+ ],
590
+ writing: [
591
+ "落笔成文,我旁边听着",
592
+ "句子排着队往外走",
593
+ "把想法一句句摆整齐",
594
+ "它在组织语言,我打打气",
595
+ "写回复呢,不催",
596
+ "字斟句酌,快好了"
597
+ ],
598
+ reading: [
599
+ "翻资料呢,我保持安静",
600
+ "一行一行读,不跳页",
601
+ "在纸堆里找线索",
602
+ "眼珠子跟着字跑",
603
+ "边读边做记号",
604
+ "翻箱倒柜找重点"
605
+ ],
606
+ editing: [
607
+ "动手改起来了,手稳一点",
608
+ "这里补一笔,那边修一修",
609
+ "在改东西,听不到声音才怪",
610
+ "落笔小心,别有错别字",
611
+ "改写的节奏,我听得见",
612
+ "刷刷地改,一行都没跑"
613
+ ],
614
+ running: [
615
+ "跑起来了跑起来了",
616
+ "命令敲出去,等个回响",
617
+ "在跑什么呢,我踮脚看",
618
+ "输出开始冒烟了",
619
+ "它在跑活,我不吵",
620
+ "盯着输出,蹲一个结果",
621
+ "这波跑完就靠它了"
622
+ ],
623
+ searching: [
624
+ "去外面捞点信息",
625
+ "翻翻记忆库,等我一小会儿",
626
+ "顺着网线找线索",
627
+ "把老账翻出来对一对",
628
+ "情报在路上了",
629
+ "搜索引擎当跑腿"
630
+ ],
631
+ git: [
632
+ "版本在往前迈步",
633
+ "改动排队上车",
634
+ "提交历史在长个子",
635
+ "分支合并,神清气爽",
636
+ "记录都焊在时间线上"
637
+ ],
638
+ delegating: [
639
+ "派了活儿出去,等回话",
640
+ "清单列好,一件件来",
641
+ "任务拆开分了组",
642
+ "手下的伙计在远处跑着",
643
+ "分工完毕,各司其职"
644
+ ],
645
+ browsing: [
646
+ "它在看网页,我偷瞄两眼",
647
+ "页面一张张翻过去",
648
+ "网页里翻答案呢",
649
+ "这网速,我先歇会儿"
650
+ ],
651
+ generic: [
652
+ "这波活儿,我陪着",
653
+ "又开工了,我盯梢",
654
+ "它忙它的,我守着",
655
+ "不打扰,就安静待着",
656
+ "有活儿就有我"
657
+ ]
658
+ };
659
+ /** The pet's outcome reactions — woken by structured session results only. */
660
+ const WHISPER_RESULT_POOLS = {
661
+ pass: [
662
+ "全绿!亮瞎我眼了",
663
+ "测试过了,击掌~",
664
+ "绿灯一排排,看着就舒坦",
665
+ "稳了稳了,这波稳得很",
666
+ "全绿,奖励自己一口小鱼干",
667
+ "这波测试,赢得干脆"
668
+ ],
669
+ fail: [
670
+ "哎呀,踩到小石子了",
671
+ "这报错我盯上它了",
672
+ "别慌,先看它在喊什么",
673
+ "修好它,今天才不算白干",
674
+ "又一次踩坑,老熟人了",
675
+ "问题不大,就是有点问题"
676
+ ],
677
+ done: [
678
+ "搞定,收工~",
679
+ "又翻过一页,踏实",
680
+ "努力没白费,开心",
681
+ "任务清零,舒服",
682
+ "攻下一城,转个圈",
683
+ "收工收工,今天圆满"
684
+ ]
685
+ };
686
+ /** The built-in voice pack: the plugin's default copy. */
839
687
  const BUILTIN_VOICE_PACK = {
840
688
  status: STATUS_POOLS,
841
689
  tools: TOOL_POOLS,
842
690
  toolRemaining: TOOL_REMAINING_POOL,
843
691
  whispers: {
844
- generic: WHISPER_GENERIC_POOL,
845
- rules: WHISPER_RULES
692
+ categories: WHISPER_CATEGORY_POOLS,
693
+ results: WHISPER_RESULT_POOLS
846
694
  }
847
695
  };
848
696
  /**
849
- * The murmur engine (碎碎念): watches the model's own output and lets the pet
850
- * whisper its inner voice. Two ways to earn a whisper:
851
- * - a keyword rule matches the fresh chunk text (themed whisper);
852
- * - enough output volume flowed by without one (ambient whisper).
853
- * A cooldown keeps whispers occasional; all picks are round-robin so tests
854
- * reproduce exact lines. The voice-pack provider (pet-center M4) swaps the
855
- * pools at draw time, so a pet switch re-voices live engines in place.
697
+ * The murmur engine (碎碎念): the pet's inner voice while sessions work.
698
+ * Category awareness: the projection feeds the current situation (thinking /
699
+ * writing / the running tool family), and the engine answers with a line from
700
+ * that category's pool so a whisper always roughly knows what is going on
701
+ * without ever quoting real content (no tool names, no paths, no model text).
702
+ * Outcome moments (test green, tool errors, turn completion) fire from the
703
+ * structured result events, never from output text, so a mood cannot mis-
704
+ * fire on a discussion that merely mentions a keyword. A cooldown keeps
705
+ * whispers occasional; all picks are round-robin so tests reproduce exact
706
+ * lines. The voice-pack provider (pet-center M4) swaps the pools at draw
707
+ * time, so a pet switch re-voices live engines in place.
856
708
  */
857
709
  var WhisperEngine = class {
858
710
  pools;
859
- cooldownMs;
860
- charBudget;
861
- counters = /* @__PURE__ */ new Map();
862
- genericCursor = 0;
711
+ categoryCooldownMs;
712
+ resultCooldownMs;
713
+ categoryCursor = /* @__PURE__ */ new Map();
714
+ resultCursor = /* @__PURE__ */ new Map();
863
715
  lastWhisperAt = Number.NEGATIVE_INFINITY;
864
- charsSinceWhisper = 0;
865
- constructor(pools = () => BUILTIN_VOICE_PACK, cooldownMs = WHISPER_COOLDOWN_MS, charBudget = 420) {
716
+ constructor(pools = () => BUILTIN_VOICE_PACK, categoryCooldownMs = WHISPER_COOLDOWN_MS, resultCooldownMs = WHISPER_RESULT_COOLDOWN_MS) {
866
717
  this.pools = pools;
867
- this.cooldownMs = cooldownMs;
868
- this.charBudget = charBudget;
718
+ this.categoryCooldownMs = categoryCooldownMs;
719
+ this.resultCooldownMs = resultCooldownMs;
720
+ }
721
+ /** Effective category pool (an explicit empty override mutes the category). */
722
+ categoryPool(category) {
723
+ const override = this.pools().whispers?.categories?.[category];
724
+ return override === void 0 ? WHISPER_CATEGORY_POOLS[category] : override;
725
+ }
726
+ /** Effective outcome pool (an explicit empty override mutes the outcome). */
727
+ resultPool(kind) {
728
+ const override = this.pools().whispers?.results?.[kind];
729
+ return override === void 0 ? WHISPER_RESULT_POOLS[kind] : override;
869
730
  }
870
731
  /**
871
- * Effective keyword rules: an override replaces the built-in rules as a
872
- * whole; an explicit empty array disables keyword-triggered whispers.
732
+ * Feed one situation while a session works. Returns the whisper to show,
733
+ * or undefined when the moment stays quiet (cooldown, or the category
734
+ * pool is muted).
873
735
  */
874
- rules() {
875
- const override = this.pools().whispers?.rules;
876
- return override === void 0 ? WHISPER_RULES : override;
877
- }
878
- /** Effective ambient pool (an explicit empty array mutes ambient whispers). */
879
- generic() {
880
- const override = this.pools().whispers?.generic;
881
- return override === void 0 ? WHISPER_GENERIC_POOL : override;
736
+ feed(category, nowMs) {
737
+ if (nowMs - this.lastWhisperAt < this.categoryCooldownMs) return void 0;
738
+ const pool = this.categoryPool(category);
739
+ if (pool.length === 0) return void 0;
740
+ const index = (this.categoryCursor.get(category) ?? 0) % pool.length;
741
+ this.categoryCursor.set(category, index + 1);
742
+ return this.speak(pool[index], nowMs);
882
743
  }
883
744
  /**
884
- * Feed one model-output chunk (reasoning or text). Returns the whisper to
885
- * show, or undefined when the moment stays quiet.
745
+ * Feed one structured outcome (test green / tool failure / turn
746
+ * completion). Outcomes carry their own shorter cooldown so the emotional
747
+ * moment is heard unless another whisper just spoke.
886
748
  */
887
- feed(text, nowMs) {
888
- if (text.length === 0) return void 0;
889
- if (!(nowMs - this.lastWhisperAt >= this.cooldownMs)) {
890
- this.charsSinceWhisper += text.length;
891
- return;
892
- }
893
- const haystack = text.toLowerCase();
894
- const rules = this.rules();
895
- for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex += 1) {
896
- const rule = rules[ruleIndex];
897
- if (!rule.keywords.some((keyword) => haystack.includes(keyword))) continue;
898
- const index = (this.counters.get(ruleIndex) ?? 0) % rule.pool.length;
899
- this.counters.set(ruleIndex, index + 1);
900
- return this.speak(rule.pool[index], nowMs);
901
- }
902
- this.charsSinceWhisper += text.length;
903
- if (this.charsSinceWhisper < this.charBudget) return void 0;
904
- const generic = this.generic();
905
- if (generic.length === 0) return void 0;
906
- const line = generic[this.genericCursor % generic.length];
907
- this.genericCursor += 1;
908
- return this.speak(line, nowMs);
749
+ result(kind, nowMs) {
750
+ if (nowMs - this.lastWhisperAt < this.resultCooldownMs) return void 0;
751
+ const pool = this.resultPool(kind);
752
+ if (pool.length === 0) return void 0;
753
+ const index = (this.resultCursor.get(kind) ?? 0) % pool.length;
754
+ this.resultCursor.set(kind, index + 1);
755
+ return this.speak(pool[index], nowMs);
909
756
  }
910
757
  speak(line, nowMs) {
911
758
  this.lastWhisperAt = nowMs;
912
- this.charsSinceWhisper = 0;
913
759
  return line;
914
760
  }
915
761
  };
@@ -924,6 +770,7 @@ var WhisperEngine = class {
924
770
  function emptyProjectionRuntime(pools) {
925
771
  return {
926
772
  activeTools: /* @__PURE__ */ new Set(),
773
+ testCalls: /* @__PURE__ */ new Set(),
927
774
  officialEventsSeen: false,
928
775
  stepHadFailure: false,
929
776
  voice: new StatusVoice(pools),
@@ -956,6 +803,7 @@ function projectOfficialEvent(event, runtime, nowMs = Date.now()) {
956
803
  switch (event.type) {
957
804
  case "turn/start":
958
805
  runtime.activeTools.clear();
806
+ runtime.testCalls.clear();
959
807
  runtime.stepHadFailure = false;
960
808
  return { input: {
961
809
  phase: "waiting",
@@ -971,7 +819,7 @@ function projectOfficialEvent(event, runtime, nowMs = Date.now()) {
971
819
  case "assistant/chunk": {
972
820
  const { chunk } = event.data;
973
821
  if (chunk.type === "reasoning-delta" && chunk.text.length > 0) {
974
- const whisper = runtime.whispers.feed(chunk.text, nowMs);
822
+ const whisper = runtime.whispers.feed("thinking", nowMs);
975
823
  return {
976
824
  input: {
977
825
  phase: "thinking",
@@ -981,7 +829,7 @@ function projectOfficialEvent(event, runtime, nowMs = Date.now()) {
981
829
  };
982
830
  }
983
831
  if (chunk.type === "text-delta" && chunk.text.length > 0) {
984
- const whisper = runtime.whispers.feed(chunk.text, nowMs);
832
+ const whisper = runtime.whispers.feed("writing", nowMs);
985
833
  return {
986
834
  input: {
987
835
  phase: "review",
@@ -996,42 +844,74 @@ function projectOfficialEvent(event, runtime, nowMs = Date.now()) {
996
844
  phase: "review",
997
845
  line: runtime.voice.scene("review", nowMs)
998
846
  } };
999
- case "tool/call":
1000
- runtime.activeTools.add(String(event.data.callId));
1001
- return { input: {
1002
- phase: "tool",
1003
- line: runtime.voice.tool(event.data.name, displayToolName(event.data.name), toolArgHint(event.data.name, event.data.arguments), nowMs)
1004
- } };
847
+ case "tool/call": {
848
+ const callId = String(event.data.callId);
849
+ runtime.activeTools.add(callId);
850
+ if (looksLikeTestTool(event.data.name, event.data.arguments)) runtime.testCalls.add(callId);
851
+ const whisper = runtime.whispers.feed(whisperCategoryOf(toolCategory(event.data.name)), nowMs);
852
+ return {
853
+ input: {
854
+ phase: "tool",
855
+ line: runtime.voice.tool(event.data.name, displayToolName(event.data.name), toolArgHint(event.data.name, event.data.arguments), nowMs)
856
+ },
857
+ ...whisper === void 0 ? {} : { whisper }
858
+ };
859
+ }
1005
860
  case "tool/result": {
1006
861
  const block = event.data.message.content[0];
1007
- runtime.activeTools.delete(String(event.data.message.source.callId));
1008
- runtime.stepHadFailure ||= event.data.error !== void 0 || block.isError === true;
1009
- if (runtime.activeTools.size > 0) return { input: {
1010
- phase: "tool",
1011
- line: runtime.voice.toolRemaining(runtime.activeTools.size, nowMs)
1012
- } };
1013
- return runtime.stepHadFailure ? { input: {
1014
- phase: "failed",
1015
- line: runtime.voice.scene("toolFailed", nowMs)
1016
- } } : { input: {
1017
- phase: "thinking",
1018
- line: runtime.voice.scene("toolResult", nowMs)
1019
- } };
862
+ const callId = String(event.data.message.source.callId);
863
+ const failed = event.data.error !== void 0 || block.isError === true;
864
+ const wasTest = runtime.testCalls.delete(callId);
865
+ runtime.activeTools.delete(callId);
866
+ runtime.stepHadFailure ||= failed;
867
+ const whisper = failed ? runtime.whispers.result("fail", nowMs) : wasTest ? runtime.whispers.result("pass", nowMs) : void 0;
868
+ const whisperSpread = whisper === void 0 ? {} : { whisper };
869
+ if (runtime.activeTools.size > 0) return {
870
+ input: {
871
+ phase: "tool",
872
+ line: runtime.voice.toolRemaining(runtime.activeTools.size, nowMs)
873
+ },
874
+ ...whisperSpread
875
+ };
876
+ return runtime.stepHadFailure ? {
877
+ input: {
878
+ phase: "failed",
879
+ line: runtime.voice.scene("toolFailed", nowMs)
880
+ },
881
+ ...whisperSpread
882
+ } : {
883
+ input: {
884
+ phase: "thinking",
885
+ line: runtime.voice.scene("toolResult", nowMs)
886
+ },
887
+ ...whisperSpread
888
+ };
1020
889
  }
1021
890
  case "turn/end":
1022
891
  runtime.activeTools.clear();
892
+ runtime.testCalls.clear();
1023
893
  switch (event.data.reason.kind) {
1024
- case "completed": return {
1025
- input: {
1026
- phase: "done",
1027
- line: runtime.voice.scene("done", nowMs)
1028
- },
1029
- completedTurn: event.data.turn
1030
- };
1031
- case "error": return { input: {
1032
- phase: "failed",
1033
- line: runtime.voice.scene("failed", nowMs)
1034
- } };
894
+ case "completed": {
895
+ const whisper = runtime.whispers.result("done", nowMs);
896
+ return {
897
+ input: {
898
+ phase: "done",
899
+ line: runtime.voice.scene("done", nowMs)
900
+ },
901
+ completedTurn: event.data.turn,
902
+ ...whisper === void 0 ? {} : { whisper }
903
+ };
904
+ }
905
+ case "error": {
906
+ const whisper = runtime.whispers.result("fail", nowMs);
907
+ return {
908
+ input: {
909
+ phase: "failed",
910
+ line: runtime.voice.scene("failed", nowMs)
911
+ },
912
+ ...whisper === void 0 ? {} : { whisper }
913
+ };
914
+ }
1035
915
  case "max-tokens": return { input: {
1036
916
  phase: "failed",
1037
917
  line: runtime.voice.scene("maxTokens", nowMs)
@@ -1202,6 +1082,17 @@ var PetLedger = class {
1202
1082
  };
1203
1083
  this.dirty = true;
1204
1084
  }
1085
+ /** Replace one pet's gameplay state (validation/clamping stays a caller concern). */
1086
+ setGameplay(petId, gameplay) {
1087
+ this.current = {
1088
+ ...this.current,
1089
+ gameplay: {
1090
+ ...this.current.gameplay,
1091
+ [petId]: gameplay
1092
+ }
1093
+ };
1094
+ this.dirty = true;
1095
+ }
1205
1096
  /** Replace one pet's display name (validation stays a caller concern). */
1206
1097
  setPetName(petId, name) {
1207
1098
  this.current = {
@@ -1237,6 +1128,40 @@ var PetLedger = class {
1237
1128
  return true;
1238
1129
  }
1239
1130
  /**
1131
+ * Grant gameplay treats into the shared stock (capped by the treat cap).
1132
+ * This is the unified gameplay currency (wallet removed): work rewards,
1133
+ * passive income and lottery prizes land here so one balance feeds the
1134
+ * shop and the feeding economy. Returns true when the snapshot changed.
1135
+ */
1136
+ grantTreats(amount) {
1137
+ if (amount <= 0) return false;
1138
+ const capped = Math.min(this.treatConfig.maxTreats, this.current.treats.treats + amount);
1139
+ if (capped === this.current.treats.treats) return false;
1140
+ this.current = {
1141
+ ...this.current,
1142
+ treats: {
1143
+ ...this.current.treats,
1144
+ treats: capped
1145
+ }
1146
+ };
1147
+ this.dirty = true;
1148
+ return true;
1149
+ }
1150
+ /** Spend gameplay treats from the shared stock; refuses when unaffordable. */
1151
+ spendTreats(amount) {
1152
+ const stock = this.current.treats.treats;
1153
+ if (amount <= 0 || stock < amount) return { ok: false };
1154
+ this.current = {
1155
+ ...this.current,
1156
+ treats: {
1157
+ ...this.current.treats,
1158
+ treats: stock - amount
1159
+ }
1160
+ };
1161
+ this.dirty = true;
1162
+ return { ok: true };
1163
+ }
1164
+ /**
1240
1165
  * Award the completed-turn reward once per session+turn (idempotent) and
1241
1166
  * run the treat settlement that work output feeds. Returns true when the
1242
1167
  * snapshot changed.
@@ -1324,8 +1249,9 @@ var PetLedger = class {
1324
1249
  */
1325
1250
  /** Expand a leading ~ (or ~user) in a path, platform-style. */
1326
1251
  function expandHome(path, home = homedir()) {
1252
+ const j = home.startsWith("/") ? join$1 : join;
1327
1253
  if (path === "~") return home;
1328
- if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
1254
+ if (path.startsWith("~/") || path.startsWith("~\\")) return j(home, path.slice(2));
1329
1255
  return path;
1330
1256
  }
1331
1257
  /**
@@ -1335,18 +1261,28 @@ function expandHome(path, home = homedir()) {
1335
1261
  * @returns the absolute DSH home path.
1336
1262
  */
1337
1263
  function resolveDshHome(env = process.env, home = homedir()) {
1264
+ const isPosix = home.startsWith("/");
1265
+ const j = isPosix ? join$1 : join;
1266
+ const isAbs = isPosix ? isAbsolute$1 : isAbsolute;
1338
1267
  const raw = env.DSH_HOME;
1339
1268
  if (raw !== void 0 && raw.trim() !== "") {
1340
1269
  const expanded = expandHome(raw.trim(), home);
1341
- return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded);
1270
+ return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
1342
1271
  }
1343
- return join(home, ".dsh");
1272
+ return j(home, ".dsh");
1344
1273
  }
1345
1274
  /** Resolve the DSH home directory from the live environment. */
1346
1275
  function dshHome() {
1347
1276
  return resolveDshHome();
1348
1277
  }
1349
1278
  //#endregion
1279
+ //#region src/defaults.ts
1280
+ /** Shared defaults used by registry selection and persisted-state migration. */
1281
+ /** Pet id that legacy single-pet installs and fresh registries resolve to. */
1282
+ const DEFAULT_PET_ID = "whale-girl";
1283
+ /** Default pet name used only when a manifest carries no displayName. */
1284
+ const DEFAULT_PET_NAME = "鲸鱼娘";
1285
+ //#endregion
1350
1286
  //#region src/persist.ts
1351
1287
  /**
1352
1288
  * Pet persistence — tiny JSON store for affinity + display config, written
@@ -1361,10 +1297,6 @@ const defaultDisplayConfig = {
1361
1297
  bottom: 120
1362
1298
  };
1363
1299
  const DISPLAY_INSET_MAX = 1e4;
1364
- /** Pet id the legacy single-pet installs resolve to on migration. */
1365
- const DEFAULT_PET_ID = "whale-girl";
1366
- /** Default pet name (used only when a manifest carries no displayName). */
1367
- const DEFAULT_PET_NAME = "鲸鱼娘";
1368
1300
  /** Name constraints. */
1369
1301
  const PET_NAME_MAX_LENGTH = 20;
1370
1302
  function emptyPersist() {
@@ -1373,7 +1305,8 @@ function emptyPersist() {
1373
1305
  names: {},
1374
1306
  affinity: emptyAffinity(),
1375
1307
  treats: emptyTreatLedger(),
1376
- display: { ...defaultDisplayConfig }
1308
+ display: { ...defaultDisplayConfig },
1309
+ gameplay: {}
1377
1310
  };
1378
1311
  }
1379
1312
  /**
@@ -1404,6 +1337,35 @@ function loadPetNames(parsed) {
1404
1337
  function clamp(value, max) {
1405
1338
  return Math.min(max, Math.max(0, value));
1406
1339
  }
1340
+ /** Absolute numeric ceilings applied at load (manifest clamps refine these). */
1341
+ const GAMEPLAY_LOAD_STAT_CAP = 1e6;
1342
+ const GAMEPLAY_LOAD_CURRENCY_CAP = 9999999;
1343
+ /** Sanitize the persisted per-pet gameplay map. */
1344
+ function loadGameplay(parsed) {
1345
+ const result = {};
1346
+ if (typeof parsed.gameplay !== "object" || parsed.gameplay === null) return result;
1347
+ for (const [petId, raw] of Object.entries(parsed.gameplay)) {
1348
+ if (petId === "" || typeof raw !== "object" || raw === null) continue;
1349
+ const record = raw;
1350
+ const stats = {};
1351
+ if (typeof record.stats === "object" && record.stats !== null) for (const [key, value] of Object.entries(record.stats)) {
1352
+ if (key === "" || typeof value !== "number" || !Number.isFinite(value)) continue;
1353
+ stats[key] = Math.min(GAMEPLAY_LOAD_STAT_CAP, Math.max(0, value));
1354
+ }
1355
+ const currencies = {};
1356
+ if (typeof record.currencies === "object" && record.currencies !== null) for (const [key, value] of Object.entries(record.currencies)) {
1357
+ if (key === "" || typeof value !== "number" || !Number.isFinite(value)) continue;
1358
+ currencies[key] = Math.min(GAMEPLAY_LOAD_CURRENCY_CAP, Math.max(0, Math.floor(value)));
1359
+ }
1360
+ result[petId] = {
1361
+ stats,
1362
+ currencies,
1363
+ mode: record.mode === "work" || record.mode === "sleep" ? record.mode : null,
1364
+ settledAt: clamp(finiteNum(record.settledAt, 0), Number.MAX_SAFE_INTEGER)
1365
+ };
1366
+ }
1367
+ return result;
1368
+ }
1407
1369
  /** Load persisted state; missing or corrupt files fall back to defaults. */
1408
1370
  function loadPetPersist(dir = petHomeDir()) {
1409
1371
  try {
@@ -1442,7 +1404,8 @@ function loadPetPersist(dir = petHomeDir()) {
1442
1404
  names,
1443
1405
  affinity,
1444
1406
  treats,
1445
- display
1407
+ display,
1408
+ gameplay: loadGameplay(parsed)
1446
1409
  };
1447
1410
  } catch {
1448
1411
  return emptyPersist();
@@ -1487,7 +1450,7 @@ const PLACEHOLDER_WHITELIST = {
1487
1450
  "{points}"
1488
1451
  ]
1489
1452
  };
1490
- function isRecord$2(value) {
1453
+ function isRecord$3(value) {
1491
1454
  return typeof value === "object" && value !== null && !Array.isArray(value);
1492
1455
  }
1493
1456
  /** Trim, length-cap and placeholder-check one copy line; undefined to drop. */
@@ -1538,55 +1501,51 @@ function normalizePool(raw, kind, onWarning = () => {}) {
1538
1501
  }
1539
1502
  return pool;
1540
1503
  }
1541
- /** Normalize the ordered keyword-rule list ([] disables the keyword channel). */
1542
- function normalizeWhisperRules(raw, onWarning = () => {}) {
1504
+ /** Normalize whisper category pools; a key replaces that category's built-in pool (an explicit empty pool mutes it). */
1505
+ function normalizeWhisperCategories(raw, onWarning = () => {}) {
1543
1506
  if (raw === void 0) return void 0;
1544
- if (!Array.isArray(raw)) {
1545
- onWarning("whispers.rules must be an array");
1507
+ if (!isRecord$3(raw)) {
1508
+ onWarning("whispers.categories must be an object");
1546
1509
  return;
1547
1510
  }
1548
- if (raw.length > 32) onWarning("whispers.rules has more than 32 rules; extra rules are ignored");
1549
- const rules = [];
1550
- for (const item of raw.slice(0, 32)) {
1551
- if (!isRecord$2(item)) {
1552
- onWarning("whisper rule must be an object");
1511
+ const pools = {};
1512
+ for (const key of Object.keys(raw)) {
1513
+ if (!WHISPER_CATEGORIES.includes(key)) {
1514
+ onWarning("unknown whisper category " + key + " ignored");
1553
1515
  continue;
1554
1516
  }
1555
- for (const key of Object.keys(item)) if (key !== "keywords" && key !== "pool") onWarning("unknown whisper rule field " + key + " ignored");
1556
- const keywordsRaw = item.keywords;
1557
- const keywords = [];
1558
- if (Array.isArray(keywordsRaw)) {
1559
- for (const entry of keywordsRaw.slice(0, 16)) {
1560
- if (typeof entry !== "string") {
1561
- onWarning("non-string keyword dropped");
1562
- continue;
1563
- }
1564
- const trimmed = entry.trim().toLowerCase().slice(0, 40);
1565
- if (trimmed !== "") keywords.push(trimmed);
1566
- }
1567
- if (keywordsRaw.length > 16) onWarning("rule has more than 16 keywords; extra keywords are ignored");
1568
- }
1569
- const pool = normalizePool(item.pool, "whisperRule", onWarning);
1570
- if (keywords.length === 0 || pool === void 0 || pool.length === 0) {
1571
- onWarning("whisper rule dropped (needs keywords and a non-empty pool)");
1517
+ const pool = normalizePool(raw[key], "whisperCategory", onWarning);
1518
+ if (pool !== void 0) pools[key] = pool;
1519
+ }
1520
+ return Object.keys(pools).length > 0 ? pools : void 0;
1521
+ }
1522
+ /** Normalize whisper outcome pools; a key replaces that outcome's built-in pool (an explicit empty pool mutes it). */
1523
+ function normalizeWhisperResults(raw, onWarning = () => {}) {
1524
+ if (raw === void 0) return void 0;
1525
+ if (!isRecord$3(raw)) {
1526
+ onWarning("whispers.results must be an object");
1527
+ return;
1528
+ }
1529
+ const pools = {};
1530
+ for (const key of Object.keys(raw)) {
1531
+ if (!WHISPER_RESULTS.includes(key)) {
1532
+ onWarning("unknown whisper result " + key + " ignored");
1572
1533
  continue;
1573
1534
  }
1574
- rules.push({
1575
- keywords,
1576
- pool
1577
- });
1535
+ const pool = normalizePool(raw[key], "whisperResult", onWarning);
1536
+ if (pool !== void 0) pools[key] = pool;
1578
1537
  }
1579
- return rules;
1538
+ return Object.keys(pools).length > 0 ? pools : void 0;
1580
1539
  }
1581
1540
  /** Normalize the panel block (labels / stats / actions; warn-and-drop). */
1582
1541
  function normalizePanel(raw, onWarning = () => {}) {
1583
- if (!isRecord$2(raw)) {
1542
+ if (!isRecord$3(raw)) {
1584
1543
  onWarning("panel must be an object");
1585
1544
  return;
1586
1545
  }
1587
1546
  const panel = {};
1588
1547
  const labelsRaw = raw.labels;
1589
- if (labelsRaw !== void 0) if (!isRecord$2(labelsRaw)) onWarning("panel.labels must be an object");
1548
+ if (labelsRaw !== void 0) if (!isRecord$3(labelsRaw)) onWarning("panel.labels must be an object");
1590
1549
  else {
1591
1550
  const labels = {};
1592
1551
  for (const key of PANEL_LABEL_KEYS) {
@@ -1602,7 +1561,7 @@ function normalizePanel(raw, onWarning = () => {}) {
1602
1561
  if (Object.keys(labels).length > 0) panel.labels = labels;
1603
1562
  }
1604
1563
  const statsRaw = raw.stats;
1605
- if (statsRaw !== void 0) if (!isRecord$2(statsRaw)) onWarning("panel.stats must be an object");
1564
+ if (statsRaw !== void 0) if (!isRecord$3(statsRaw)) onWarning("panel.stats must be an object");
1606
1565
  else {
1607
1566
  const stats = {};
1608
1567
  for (const key of PANEL_STAT_KEYS) {
@@ -1644,7 +1603,7 @@ const VOICE_PACK_KEYS = /* @__PURE__ */ new Set([
1644
1603
  "panel"
1645
1604
  ]);
1646
1605
  /** Allowed whisper-section fields (drift-locked in tests). */
1647
- const WHISPER_KEYS = /* @__PURE__ */ new Set(["generic", "rules"]);
1606
+ const WHISPER_KEYS = /* @__PURE__ */ new Set(["categories", "results"]);
1648
1607
  /**
1649
1608
  * Normalize one raw voice.json document into a VoicePack, or undefined when
1650
1609
  * the file cannot serve as a pack at all (non-object root — structure is
@@ -1652,7 +1611,7 @@ const WHISPER_KEYS = /* @__PURE__ */ new Set(["generic", "rules"]);
1652
1611
  */
1653
1612
  function normalizeVoicePack(raw, onWarning = () => {}) {
1654
1613
  if (raw === void 0) return void 0;
1655
- if (!isRecord$2(raw)) {
1614
+ if (!isRecord$3(raw)) {
1656
1615
  onWarning("voice.json must be a JSON object; the file is ignored");
1657
1616
  return;
1658
1617
  }
@@ -1661,7 +1620,7 @@ function normalizeVoicePack(raw, onWarning = () => {}) {
1661
1620
  if (version !== void 0 && (typeof version !== "number" || version !== 1)) onWarning("voicePackVersion " + String(version) + " is not supported; reading as v1 best-effort");
1662
1621
  const overrides = {};
1663
1622
  const statusRaw = raw.status;
1664
- if (statusRaw !== void 0) if (!isRecord$2(statusRaw)) onWarning("status must be an object");
1623
+ if (statusRaw !== void 0) if (!isRecord$3(statusRaw)) onWarning("status must be an object");
1665
1624
  else for (const key of Object.keys(statusRaw)) {
1666
1625
  if (!STATUS_SCENES.includes(key)) {
1667
1626
  onWarning("unknown status scene " + key + " ignored");
@@ -1674,7 +1633,7 @@ function normalizeVoicePack(raw, onWarning = () => {}) {
1674
1633
  };
1675
1634
  }
1676
1635
  const toolsRaw = raw.tools;
1677
- if (toolsRaw !== void 0) if (!isRecord$2(toolsRaw)) onWarning("tools must be an object");
1636
+ if (toolsRaw !== void 0) if (!isRecord$3(toolsRaw)) onWarning("tools must be an object");
1678
1637
  else for (const key of Object.keys(toolsRaw)) {
1679
1638
  if (!TOOL_CATEGORIES.includes(key)) {
1680
1639
  onWarning("unknown tool family " + key + " ignored");
@@ -1692,14 +1651,15 @@ function normalizeVoicePack(raw, onWarning = () => {}) {
1692
1651
  if (pool !== void 0 && pool.length > 0) overrides.toolRemaining = pool;
1693
1652
  }
1694
1653
  const whispersRaw = raw.whispers;
1695
- if (whispersRaw !== void 0) if (!isRecord$2(whispersRaw)) onWarning("whispers must be an object");
1654
+ if (whispersRaw !== void 0) if (!isRecord$3(whispersRaw)) onWarning("whispers must be an object");
1696
1655
  else {
1697
- for (const key of Object.keys(whispersRaw)) if (!WHISPER_KEYS.has(key)) onWarning("unknown whispers field " + key + " ignored");
1698
- const generic = normalizePool(whispersRaw.generic, "whisperGeneric", onWarning);
1699
- const rules = normalizeWhisperRules(whispersRaw.rules, onWarning);
1700
- if (generic !== void 0 || rules !== void 0) overrides.whispers = {
1701
- ...generic === void 0 ? {} : { generic },
1702
- ...rules === void 0 ? {} : { rules }
1656
+ for (const key of Object.keys(whispersRaw)) if (key === "generic" || key === "rules") onWarning("whispers." + key + " is no longer supported and was ignored");
1657
+ else if (!WHISPER_KEYS.has(key)) onWarning("unknown whispers field " + key + " ignored");
1658
+ const categories = normalizeWhisperCategories(whispersRaw.categories, onWarning);
1659
+ const results = normalizeWhisperResults(whispersRaw.results, onWarning);
1660
+ if (categories !== void 0 || results !== void 0) overrides.whispers = {
1661
+ ...categories === void 0 ? {} : { categories },
1662
+ ...results === void 0 ? {} : { results }
1703
1663
  };
1704
1664
  }
1705
1665
  const panel = raw.panel === void 0 ? void 0 : normalizePanel(raw.panel, onWarning);
@@ -1757,8 +1717,585 @@ function mergeVoicePacks(...layers) {
1757
1717
  ...panelSeen && !panelEmpty ? { panel } : {}
1758
1718
  };
1759
1719
  }
1720
+ //#endregion
1721
+ //#region src/gameplay.ts
1722
+ const KEBAB = /^[a-z0-9][a-z0-9-]*$/;
1723
+ const MAX_STATS = 16;
1724
+ const MAX_ZONES = 8;
1725
+ const MAX_BRANCHES = 8;
1726
+ const MAX_ACTS = 16;
1727
+ const MAX_SHOP_ITEMS = 32;
1728
+ const MAX_LOTTERY_TIERS = 16;
1729
+ const MAX_PHRASES = 64;
1730
+ const PHRASE_MAX_LENGTH = 120;
1731
+ const STAT_VALUE_MAX = 1e6;
1732
+ const CURRENCY_MAX = 9999999;
1733
+ const KNOWN_GAMEPLAY = /* @__PURE__ */ new Set([
1734
+ "idleDirector",
1735
+ "stats",
1736
+ "hitBox",
1737
+ "touch",
1738
+ "work",
1739
+ "sleep",
1740
+ "passiveIncome",
1741
+ "shop",
1742
+ "dragState",
1743
+ "dragEndState"
1744
+ ]);
1745
+ const KNOWN_STAT = /* @__PURE__ */ new Set([
1746
+ "max",
1747
+ "initial",
1748
+ "decayPerMinute",
1749
+ "workingDecayPerMinute",
1750
+ "idleDecayPerMinute"
1751
+ ]);
1752
+ const KNOWN_ZONE = /* @__PURE__ */ new Set([
1753
+ "name",
1754
+ "y0",
1755
+ "y1",
1756
+ "branches"
1757
+ ]);
1758
+ const KNOWN_TOUCH = /* @__PURE__ */ new Set(["zones", "clickBoost"]);
1759
+ const KNOWN_BRANCH = /* @__PURE__ */ new Set([
1760
+ "probability",
1761
+ "effects",
1762
+ "state",
1763
+ "stateMs",
1764
+ "phrases"
1765
+ ]);
1766
+ const KNOWN_EFFECT = /* @__PURE__ */ new Set([
1767
+ "stat",
1768
+ "currency",
1769
+ "amount"
1770
+ ]);
1771
+ const KNOWN_WORK = /* @__PURE__ */ new Set([
1772
+ "state",
1773
+ "successState",
1774
+ "failState",
1775
+ "tickMs",
1776
+ "resultMs",
1777
+ "successProbability",
1778
+ "success",
1779
+ "fail"
1780
+ ]);
1781
+ const KNOWN_SLEEP = /* @__PURE__ */ new Set([
1782
+ "state",
1783
+ "wakeState",
1784
+ "restore"
1785
+ ]);
1786
+ const KNOWN_SHOP_ITEM = /* @__PURE__ */ new Set([
1787
+ "id",
1788
+ "label",
1789
+ "image",
1790
+ "price",
1791
+ "currency",
1792
+ "effects",
1793
+ "lottery"
1794
+ ]);
1795
+ const KNOWN_LOTTERY = /* @__PURE__ */ new Set([
1796
+ "effects",
1797
+ "currency",
1798
+ "tiers"
1799
+ ]);
1800
+ const KNOWN_IDLE_DIRECTOR = /* @__PURE__ */ new Set([
1801
+ "intervalMs",
1802
+ "maxMiss",
1803
+ "idleWeight",
1804
+ "acts"
1805
+ ]);
1806
+ const KNOWN_ACT = /* @__PURE__ */ new Set([
1807
+ "track",
1808
+ "weight",
1809
+ "phrases"
1810
+ ]);
1811
+ function isRecord$2(value) {
1812
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1813
+ }
1814
+ function unknownKeys$2(source, known) {
1815
+ return Object.keys(source).filter((key) => !known.has(key));
1816
+ }
1817
+ function validName(name, max = 32) {
1818
+ return typeof name === "string" && name.length <= max && KEBAB.test(name);
1819
+ }
1820
+ function intIn(value, min, max) {
1821
+ return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
1822
+ }
1823
+ function numIn(value, min, max) {
1824
+ return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
1825
+ }
1826
+ function parseEffects(raw, field, stats, hooks) {
1827
+ if (raw === void 0) return void 0;
1828
+ if (!Array.isArray(raw) || raw.length === 0) {
1829
+ hooks.error(field + " must be a non-empty array of effects");
1830
+ return;
1831
+ }
1832
+ const effects = [];
1833
+ for (const entry of raw) {
1834
+ if (!isRecord$2(entry)) {
1835
+ hooks.error(field + ": every effect must be an object");
1836
+ continue;
1837
+ }
1838
+ const extra = unknownKeys$2(entry, KNOWN_EFFECT);
1839
+ if (extra.length > 0) hooks.error(field + ": unknown effect field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
1840
+ const hasStat = typeof entry.stat === "string";
1841
+ const hasCurrency = typeof entry.currency === "string";
1842
+ if (hasStat === hasCurrency) {
1843
+ hooks.error(field + ": an effect needs exactly one of stat or currency");
1844
+ continue;
1845
+ }
1846
+ if (!intIn(entry.amount, -1e6, STAT_VALUE_MAX) || entry.amount === 0) {
1847
+ hooks.error(field + ": effect amount must be a non-zero integer within ±1000000");
1848
+ continue;
1849
+ }
1850
+ if (hasStat && stats[entry.stat] === void 0) {
1851
+ hooks.error(field + ": effect references undeclared stat " + JSON.stringify(entry.stat));
1852
+ continue;
1853
+ }
1854
+ if (hasCurrency && !validName(entry.currency, 24)) {
1855
+ hooks.error(field + ": effect currency must be a kebab id");
1856
+ continue;
1857
+ }
1858
+ effects.push({
1859
+ ...hasStat ? { stat: entry.stat } : {},
1860
+ ...hasCurrency ? { currency: entry.currency } : {},
1861
+ amount: entry.amount
1862
+ });
1863
+ }
1864
+ return effects.length === 0 ? void 0 : effects;
1865
+ }
1866
+ function parsePhrases(raw, field, hooks) {
1867
+ if (raw === void 0) return void 0;
1868
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_PHRASES || raw.some((line) => typeof line !== "string" || line.trim() === "" || line.length > PHRASE_MAX_LENGTH)) {
1869
+ hooks.error(field + " must be 1..64 non-empty lines of at most 120 chars");
1870
+ return;
1871
+ }
1872
+ return raw;
1873
+ }
1874
+ function parseStateRef(raw, field, hooks) {
1875
+ if (raw === void 0) return void 0;
1876
+ if (typeof raw !== "string" || !hooks.stateNames.has(raw)) {
1877
+ hooks.error(field + " must name a declared frames2d track");
1878
+ return;
1879
+ }
1880
+ return raw;
1881
+ }
1882
+ /**
1883
+ * Validate the manifest 'gameplay' block (fail-closed). Only frames2d pets
1884
+ * may declare gameplay today: every state reference checks against the
1885
+ * declared track names.
1886
+ */
1887
+ function parseGameplayManifest(raw, hooks) {
1888
+ const error = (message) => hooks.error(message);
1889
+ if (!isRecord$2(raw)) {
1890
+ error("gameplay must be an object");
1891
+ return;
1892
+ }
1893
+ const extra = unknownKeys$2(raw, KNOWN_GAMEPLAY);
1894
+ if (extra.length > 0) error("gameplay: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
1895
+ let failed = false;
1896
+ const fail = (message) => {
1897
+ failed = true;
1898
+ error(message);
1899
+ };
1900
+ const stats = {};
1901
+ if (raw.stats !== void 0) if (!isRecord$2(raw.stats)) fail("gameplay.stats must be an object keyed by stat id");
1902
+ else {
1903
+ const entries = Object.entries(raw.stats);
1904
+ if (entries.length > MAX_STATS) fail("gameplay.stats declares too many stats (max 16)");
1905
+ for (const [name, value] of entries) {
1906
+ if (!validName(name, 24)) {
1907
+ fail("gameplay.stats: invalid stat id " + JSON.stringify(name));
1908
+ continue;
1909
+ }
1910
+ if (!isRecord$2(value)) {
1911
+ fail("gameplay.stats." + name + " must be an object");
1912
+ continue;
1913
+ }
1914
+ const statExtra = unknownKeys$2(value, KNOWN_STAT);
1915
+ if (statExtra.length > 0) fail("gameplay.stats." + name + ": unknown field(s) " + statExtra.map((k) => JSON.stringify(k)).join(", "));
1916
+ if (!intIn(value.max, 1, STAT_VALUE_MAX)) {
1917
+ fail("gameplay.stats." + name + ".max must be an integer in [1, 1000000]");
1918
+ continue;
1919
+ }
1920
+ const def = { max: value.max };
1921
+ if (value.initial !== void 0) if (!numIn(value.initial, 0, value.max)) fail("gameplay.stats." + name + ".initial must be within [0, max]");
1922
+ else def.initial = value.initial;
1923
+ for (const key of [
1924
+ "decayPerMinute",
1925
+ "workingDecayPerMinute",
1926
+ "idleDecayPerMinute"
1927
+ ]) if (value[key] !== void 0) if (!numIn(value[key], 0, 1e3)) fail("gameplay.stats." + name + "." + key + " must be a number in [0, 1000]");
1928
+ else def[key] = value[key];
1929
+ stats[name] = def;
1930
+ }
1931
+ }
1932
+ const block = {};
1933
+ if (Object.keys(stats).length > 0) block.stats = stats;
1934
+ if (raw.idleDirector !== void 0) if (!isRecord$2(raw.idleDirector) || !Array.isArray(raw.idleDirector.acts)) fail("gameplay.idleDirector must be an object with an acts array");
1935
+ else {
1936
+ const d = raw.idleDirector;
1937
+ const dExtra = unknownKeys$2(d, KNOWN_IDLE_DIRECTOR);
1938
+ if (dExtra.length > 0) fail("gameplay.idleDirector: unknown field(s) " + dExtra.map((k) => JSON.stringify(k)).join(", "));
1939
+ if (d.intervalMs !== void 0 && !intIn(d.intervalMs, 1e3, 6e4)) fail("gameplay.idleDirector.intervalMs must be an integer in [1000, 60000]");
1940
+ if (d.maxMiss !== void 0 && !intIn(d.maxMiss, 0, 10)) fail("gameplay.idleDirector.maxMiss must be an integer in [0, 10]");
1941
+ if (d.idleWeight !== void 0 && !intIn(d.idleWeight, 0, 1e4)) fail("gameplay.idleDirector.idleWeight must be an integer in [0, 10000]");
1942
+ if (d.acts.length === 0 || d.acts.length > MAX_ACTS) fail("gameplay.idleDirector.acts must declare 1..16 acts");
1943
+ const acts = [];
1944
+ for (const act of d.acts) {
1945
+ if (!isRecord$2(act) || !intIn(act.weight, 1, 1e4)) {
1946
+ fail("gameplay.idleDirector.acts entries need a weight integer in [1, 10000]");
1947
+ continue;
1948
+ }
1949
+ const aExtra = unknownKeys$2(act, KNOWN_ACT);
1950
+ if (aExtra.length > 0) fail("gameplay.idleDirector.acts: unknown field(s) " + aExtra.map((k) => JSON.stringify(k)).join(", "));
1951
+ const track = parseStateRef(act.track, "gameplay.idleDirector.acts.track", hooks);
1952
+ if (track === void 0) continue;
1953
+ const entry = {
1954
+ track,
1955
+ weight: act.weight
1956
+ };
1957
+ const phrases = parsePhrases(act.phrases, "gameplay.idleDirector.acts.phrases", hooks);
1958
+ if (phrases !== void 0) entry.phrases = phrases;
1959
+ acts.push(entry);
1960
+ }
1961
+ if (acts.length > 0) block.idleDirector = {
1962
+ intervalMs: intIn(d.intervalMs, 1e3, 6e4) ? d.intervalMs : 5e3,
1963
+ maxMiss: intIn(d.maxMiss, 0, 10) ? d.maxMiss : 2,
1964
+ idleWeight: intIn(d.idleWeight, 0, 1e4) ? d.idleWeight : 0,
1965
+ acts
1966
+ };
1967
+ }
1968
+ if (raw.hitBox !== void 0) {
1969
+ const b = raw.hitBox;
1970
+ if (!isRecord$2(b) || !numIn(b.x0, 0, 1) || !numIn(b.x1, 0, 1) || !numIn(b.y0, 0, 1) || !numIn(b.y1, 0, 1) || !(b.x0 < b.x1) || !(b.y0 < b.y1)) fail("gameplay.hitBox must be { x0, y0, x1, y1 } fractions with x0 < x1 and y0 < y1");
1971
+ else block.hitBox = {
1972
+ x0: b.x0,
1973
+ y0: b.y0,
1974
+ x1: b.x1,
1975
+ y1: b.y1
1976
+ };
1977
+ }
1978
+ if (raw.touch !== void 0) if (!isRecord$2(raw.touch) || !Array.isArray(raw.touch.zones)) fail("gameplay.touch must be an object with a zones array");
1979
+ else {
1980
+ const tExtra = unknownKeys$2(raw.touch, KNOWN_TOUCH);
1981
+ if (tExtra.length > 0) fail("gameplay.touch: unknown field(s) " + tExtra.map((k) => JSON.stringify(k)).join(", "));
1982
+ let clickBoost;
1983
+ if (raw.touch.clickBoost !== void 0) {
1984
+ const cb = raw.touch.clickBoost;
1985
+ if (!isRecord$2(cb) || typeof cb.stat !== "string" || stats[cb.stat] === void 0 || !intIn(cb.min, 0, 1e3) || !intIn(cb.max, 0, 1e3) || cb.min > cb.max) fail("gameplay.touch.clickBoost must be { stat (declared), min, max } integers with 0 <= min <= max <= 1000");
1986
+ else clickBoost = {
1987
+ stat: cb.stat,
1988
+ min: cb.min,
1989
+ max: cb.max
1990
+ };
1991
+ }
1992
+ const zones = [];
1993
+ if (raw.touch.zones.length === 0 || raw.touch.zones.length > MAX_ZONES) fail("gameplay.touch.zones must declare 1..8 zones");
1994
+ for (const zoneRaw of raw.touch.zones) {
1995
+ if (!isRecord$2(zoneRaw) || !validName(zoneRaw.name) || !numIn(zoneRaw.y0, 0, 1) || !numIn(zoneRaw.y1, 0, 1) || !(zoneRaw.y0 < zoneRaw.y1)) {
1996
+ fail("gameplay.touch.zones entries need a kebab name and 0 <= y0 < y1 <= 1");
1997
+ continue;
1998
+ }
1999
+ const zExtra = unknownKeys$2(zoneRaw, KNOWN_ZONE);
2000
+ if (zExtra.length > 0) fail("gameplay.touch." + zoneRaw.name + ": unknown field(s) " + zExtra.map((k) => JSON.stringify(k)).join(", "));
2001
+ if (!Array.isArray(zoneRaw.branches) || zoneRaw.branches.length === 0 || zoneRaw.branches.length > MAX_BRANCHES) {
2002
+ fail("gameplay.touch." + zoneRaw.name + ".branches must declare 1..8 branches");
2003
+ continue;
2004
+ }
2005
+ let probabilitySum = 0;
2006
+ const branches = [];
2007
+ for (const branchRaw of zoneRaw.branches) {
2008
+ if (!isRecord$2(branchRaw) || !numIn(branchRaw.probability, 0, 1) || branchRaw.probability === 0) {
2009
+ fail("gameplay.touch." + zoneRaw.name + ".branches entries need a probability in (0, 1]");
2010
+ continue;
2011
+ }
2012
+ const bExtra = unknownKeys$2(branchRaw, KNOWN_BRANCH);
2013
+ if (bExtra.length > 0) fail("gameplay.touch." + zoneRaw.name + ": unknown branch field(s) " + bExtra.map((k) => JSON.stringify(k)).join(", "));
2014
+ probabilitySum += branchRaw.probability;
2015
+ const branch = { probability: branchRaw.probability };
2016
+ const effects = parseEffects(branchRaw.effects, "gameplay.touch." + zoneRaw.name + ".effects", stats, hooks);
2017
+ if (effects !== void 0) branch.effects = effects;
2018
+ const state = parseStateRef(branchRaw.state, "gameplay.touch." + zoneRaw.name + ".state", hooks);
2019
+ if (state !== void 0) branch.state = state;
2020
+ if (branchRaw.stateMs !== void 0) if (!intIn(branchRaw.stateMs, 200, 1e4)) fail("gameplay.touch." + zoneRaw.name + ".stateMs must be an integer in [200, 10000]");
2021
+ else branch.stateMs = branchRaw.stateMs;
2022
+ const phrases = parsePhrases(branchRaw.phrases, "gameplay.touch." + zoneRaw.name + ".phrases", hooks);
2023
+ if (phrases !== void 0) branch.phrases = phrases;
2024
+ branches.push(branch);
2025
+ }
2026
+ if (probabilitySum > 1.000000001) fail("gameplay.touch." + zoneRaw.name + ": branch probabilities must sum to at most 1");
2027
+ if (branches.length > 0) zones.push({
2028
+ name: zoneRaw.name,
2029
+ y0: zoneRaw.y0,
2030
+ y1: zoneRaw.y1,
2031
+ branches
2032
+ });
2033
+ }
2034
+ if (zones.length > 0 || clickBoost !== void 0) block.touch = {
2035
+ zones,
2036
+ ...clickBoost === void 0 ? {} : { clickBoost }
2037
+ };
2038
+ }
2039
+ if (raw.work !== void 0) {
2040
+ const w = raw.work;
2041
+ if (!isRecord$2(w)) fail("gameplay.work must be an object");
2042
+ else {
2043
+ const wExtra = unknownKeys$2(w, KNOWN_WORK);
2044
+ if (wExtra.length > 0) fail("gameplay.work: unknown field(s) " + wExtra.map((k) => JSON.stringify(k)).join(", "));
2045
+ const state = parseStateRef(w.state, "gameplay.work.state", hooks);
2046
+ const successState = parseStateRef(w.successState, "gameplay.work.successState", hooks);
2047
+ const failState = parseStateRef(w.failState, "gameplay.work.failState", hooks);
2048
+ if (!intIn(w.tickMs, 1e3, 6e4)) fail("gameplay.work.tickMs must be an integer in [1000, 60000]");
2049
+ if (!numIn(w.successProbability, 0, 1)) fail("gameplay.work.successProbability must be a number in [0, 1]");
2050
+ if (state !== void 0 && successState !== void 0 && failState !== void 0 && intIn(w.tickMs, 1e3, 6e4) && numIn(w.successProbability, 0, 1)) {
2051
+ const work = {
2052
+ state,
2053
+ successState,
2054
+ failState,
2055
+ tickMs: w.tickMs,
2056
+ successProbability: w.successProbability
2057
+ };
2058
+ if (w.resultMs !== void 0) if (!isRecord$2(w.resultMs) || !intIn(w.resultMs.success, 200, 1e4) || !intIn(w.resultMs.fail, 200, 1e4)) fail("gameplay.work.resultMs must be { success, fail } integers in [200, 10000]");
2059
+ else work.resultMs = {
2060
+ success: w.resultMs.success,
2061
+ fail: w.resultMs.fail
2062
+ };
2063
+ for (const key of ["success", "fail"]) if (w[key] !== void 0) if (!isRecord$2(w[key])) fail("gameplay.work." + key + " must be an object { effects }");
2064
+ else {
2065
+ const effects = parseEffects(w[key].effects, "gameplay.work." + key + ".effects", stats, hooks);
2066
+ if (effects !== void 0) work[key] = { effects };
2067
+ }
2068
+ block.work = work;
2069
+ }
2070
+ }
2071
+ }
2072
+ if (raw.sleep !== void 0) {
2073
+ const s = raw.sleep;
2074
+ if (!isRecord$2(s) || !isRecord$2(s.restore)) fail("gameplay.sleep must be an object with a restore block");
2075
+ else {
2076
+ const sExtra = unknownKeys$2(s, KNOWN_SLEEP);
2077
+ if (sExtra.length > 0) fail("gameplay.sleep: unknown field(s) " + sExtra.map((k) => JSON.stringify(k)).join(", "));
2078
+ const state = parseStateRef(s.state, "gameplay.sleep.state", hooks);
2079
+ const wakeState = parseStateRef(s.wakeState, "gameplay.sleep.wakeState", hooks);
2080
+ const restoreStat = typeof s.restore.stat === "string" && stats[s.restore.stat] !== void 0 ? s.restore.stat : void 0;
2081
+ if (restoreStat === void 0) fail("gameplay.sleep.restore.stat must reference a declared stat");
2082
+ if (!intIn(s.restore.amount, 1, 1e3)) fail("gameplay.sleep.restore.amount must be an integer in [1, 1000]");
2083
+ if (!intIn(s.restore.intervalMs, 1e3, 6e5)) fail("gameplay.sleep.restore.intervalMs must be an integer in [1000, 600000]");
2084
+ if (state !== void 0 && restoreStat !== void 0 && intIn(s.restore.amount, 1, 1e3) && intIn(s.restore.intervalMs, 1e3, 6e5)) block.sleep = {
2085
+ state,
2086
+ ...wakeState === void 0 ? {} : { wakeState },
2087
+ restore: {
2088
+ stat: restoreStat,
2089
+ amount: s.restore.amount,
2090
+ intervalMs: s.restore.intervalMs
2091
+ }
2092
+ };
2093
+ }
2094
+ }
2095
+ if (raw.passiveIncome !== void 0) {
2096
+ const p = raw.passiveIncome;
2097
+ if (!isRecord$2(p) || !validName(p.currency, 24) || !intIn(p.amount, 1, 1e4) || !intIn(p.intervalMs, 1e3, 864e5)) fail("gameplay.passiveIncome must be { currency (kebab), amount 1..10000, intervalMs 1000..86400000 }");
2098
+ else block.passiveIncome = {
2099
+ currency: p.currency,
2100
+ amount: p.amount,
2101
+ intervalMs: p.intervalMs
2102
+ };
2103
+ }
2104
+ if (raw.shop !== void 0) {
2105
+ const s = raw.shop;
2106
+ if (!isRecord$2(s) || !Array.isArray(s.items) || s.items.length === 0 || s.items.length > MAX_SHOP_ITEMS) fail("gameplay.shop must be an object with 1..32 items");
2107
+ else {
2108
+ const shopState = parseStateRef(s.state, "gameplay.shop.state", hooks);
2109
+ const items = [];
2110
+ const seen = /* @__PURE__ */ new Set();
2111
+ for (const itemRaw of s.items) {
2112
+ if (!isRecord$2(itemRaw) || !validName(itemRaw.id, 24)) {
2113
+ fail("gameplay.shop.items entries need a kebab id");
2114
+ continue;
2115
+ }
2116
+ if (seen.has(itemRaw.id)) {
2117
+ fail("gameplay.shop: duplicate item id " + JSON.stringify(itemRaw.id));
2118
+ continue;
2119
+ }
2120
+ seen.add(itemRaw.id);
2121
+ const iExtra = unknownKeys$2(itemRaw, KNOWN_SHOP_ITEM);
2122
+ if (iExtra.length > 0) fail("gameplay.shop." + itemRaw.id + ": unknown field(s) " + iExtra.map((k) => JSON.stringify(k)).join(", "));
2123
+ if (typeof itemRaw.label !== "string" || itemRaw.label.trim() === "" || itemRaw.label.length > 80) {
2124
+ fail("gameplay.shop." + itemRaw.id + ".label must be a non-empty string of at most 80 chars");
2125
+ continue;
2126
+ }
2127
+ if (!intIn(itemRaw.price, 1, 1e6)) {
2128
+ fail("gameplay.shop." + itemRaw.id + ".price must be an integer in [1, 1000000]");
2129
+ continue;
2130
+ }
2131
+ if (!validName(itemRaw.currency, 24)) {
2132
+ fail("gameplay.shop." + itemRaw.id + ".currency must be a kebab id");
2133
+ continue;
2134
+ }
2135
+ const item = {
2136
+ id: itemRaw.id,
2137
+ label: itemRaw.label.trim(),
2138
+ price: itemRaw.price,
2139
+ currency: itemRaw.currency
2140
+ };
2141
+ if (itemRaw.image !== void 0) if (typeof itemRaw.image !== "string" || itemRaw.image.includes("..") || itemRaw.image.includes("\\") || itemRaw.image.startsWith("/")) fail("gameplay.shop." + itemRaw.id + ".image must be a safe manifest-relative frame path");
2142
+ else item.image = itemRaw.image;
2143
+ const effects = parseEffects(itemRaw.effects, "gameplay.shop." + itemRaw.id + ".effects", stats, hooks);
2144
+ if (effects !== void 0) item.effects = effects;
2145
+ if (itemRaw.lottery !== void 0) {
2146
+ const l = itemRaw.lottery;
2147
+ if (!isRecord$2(l) || !Array.isArray(l.tiers) || l.tiers.length === 0 || l.tiers.length > MAX_LOTTERY_TIERS) fail("gameplay.shop." + itemRaw.id + ".lottery needs 1..16 tiers");
2148
+ else {
2149
+ const lExtra = unknownKeys$2(l, KNOWN_LOTTERY);
2150
+ if (lExtra.length > 0) fail("gameplay.shop." + itemRaw.id + ".lottery: unknown field(s) " + lExtra.map((k) => JSON.stringify(k)).join(", "));
2151
+ if (l.currency !== void 0 && !validName(l.currency, 24)) fail("gameplay.shop." + itemRaw.id + ".lottery.currency must be a kebab id");
2152
+ let tierSum = 0;
2153
+ const tiers = [];
2154
+ for (const tierRaw of l.tiers) {
2155
+ if (!isRecord$2(tierRaw) || !numIn(tierRaw.probability, 0, 1) || tierRaw.probability === 0 || !intIn(tierRaw.prize, 0, 1e9)) {
2156
+ fail("gameplay.shop." + itemRaw.id + ".lottery.tiers entries need probability (0,1] and prize 0..1e9");
2157
+ continue;
2158
+ }
2159
+ tierSum += tierRaw.probability;
2160
+ const tier = {
2161
+ probability: tierRaw.probability,
2162
+ prize: tierRaw.prize
2163
+ };
2164
+ if (tierRaw.currency !== void 0) if (!validName(tierRaw.currency, 24)) fail("gameplay.shop." + itemRaw.id + ".lottery tier currency must be a kebab id");
2165
+ else tier.currency = tierRaw.currency;
2166
+ tiers.push(tier);
2167
+ }
2168
+ if (tierSum > 1.000000001) fail("gameplay.shop." + itemRaw.id + ".lottery tier probabilities must sum to at most 1");
2169
+ if (tiers.length > 0) {
2170
+ const lotteryEffects = parseEffects(l.effects, "gameplay.shop." + itemRaw.id + ".lottery.effects", stats, hooks);
2171
+ item.lottery = {
2172
+ tiers,
2173
+ ...lotteryEffects === void 0 ? {} : { effects: lotteryEffects },
2174
+ ...validName(l.currency, 24) ? { currency: l.currency } : {}
2175
+ };
2176
+ }
2177
+ }
2178
+ }
2179
+ if (item.effects === void 0 && item.lottery === void 0) {
2180
+ fail("gameplay.shop." + itemRaw.id + " needs effects or a lottery");
2181
+ continue;
2182
+ }
2183
+ items.push(item);
2184
+ }
2185
+ if (items.length > 0) block.shop = {
2186
+ ...shopState === void 0 ? {} : { state: shopState },
2187
+ items
2188
+ };
2189
+ }
2190
+ }
2191
+ if (raw.dragState !== void 0) {
2192
+ const state = parseStateRef(raw.dragState, "gameplay.dragState", hooks);
2193
+ if (state !== void 0) block.dragState = state;
2194
+ }
2195
+ if (raw.dragEndState !== void 0) {
2196
+ const state = parseStateRef(raw.dragEndState, "gameplay.dragEndState", hooks);
2197
+ if (state !== void 0) block.dragEndState = state;
2198
+ }
2199
+ return failed ? void 0 : block;
2200
+ }
2201
+ /** Fresh state for one pet: stats at their initial (default max), no currency. */
2202
+ function initialGameplayState(manifest, now) {
2203
+ const stats = {};
2204
+ for (const [name, def] of Object.entries(manifest.stats ?? {})) stats[name] = def.initial ?? def.max;
2205
+ return {
2206
+ stats,
2207
+ currencies: {},
2208
+ mode: null,
2209
+ settledAt: now
2210
+ };
2211
+ }
2212
+ /** Clamp one stat value into [0, max]; currencies into [0, CURRENCY_MAX]. */
2213
+ function clampGameplay(state, manifest) {
2214
+ for (const [name, def] of Object.entries(manifest.stats ?? {})) {
2215
+ const value = state.stats[name];
2216
+ if (value === void 0) state.stats[name] = def.initial ?? def.max;
2217
+ else state.stats[name] = Math.min(def.max, Math.max(0, value));
2218
+ }
2219
+ for (const [name, value] of Object.entries(state.currencies)) state.currencies[name] = Math.min(CURRENCY_MAX, Math.max(0, Math.floor(value)));
2220
+ }
2221
+ /**
2222
+ * Lazy settle: apply stat decay, passive income and sleep restore for the
2223
+ * elapsed wall time since the last settle. Mirrors the treats.ts discipline
2224
+ * (no host timers; read paths settle). Returns whether anything changed.
2225
+ */
2226
+ function settleGameplay(state, manifest, now, options) {
2227
+ const elapsedMs = now - state.settledAt;
2228
+ if (elapsedMs <= 0) return false;
2229
+ const minutes = elapsedMs / 6e4;
2230
+ let changed = false;
2231
+ for (const [name, def] of Object.entries(manifest.stats ?? {})) {
2232
+ const current = state.stats[name];
2233
+ if (current === void 0 || current <= 0) continue;
2234
+ let rate = def.decayPerMinute ?? 0;
2235
+ if (state.mode === "work" && def.workingDecayPerMinute !== void 0) rate = def.workingDecayPerMinute;
2236
+ if (!options.sessionActive) rate += def.idleDecayPerMinute ?? 0;
2237
+ if (rate <= 0) continue;
2238
+ const next = Math.max(0, current - rate * minutes);
2239
+ if (next !== current) {
2240
+ state.stats[name] = next;
2241
+ changed = true;
2242
+ }
2243
+ }
2244
+ if (manifest.passiveIncome !== void 0) {
2245
+ const ticks = Math.floor(elapsedMs / manifest.passiveIncome.intervalMs);
2246
+ if (ticks > 0) {
2247
+ const currency = manifest.passiveIncome.currency;
2248
+ state.currencies[currency] = (state.currencies[currency] ?? 0) + ticks * manifest.passiveIncome.amount;
2249
+ changed = true;
2250
+ }
2251
+ }
2252
+ if (state.mode === "sleep" && manifest.sleep !== void 0) {
2253
+ const ticks = Math.floor(elapsedMs / manifest.sleep.restore.intervalMs);
2254
+ if (ticks > 0) {
2255
+ const stat = manifest.sleep.restore.stat;
2256
+ state.stats[stat] = (state.stats[stat] ?? 0) + ticks * manifest.sleep.restore.amount;
2257
+ changed = true;
2258
+ }
2259
+ }
2260
+ state.settledAt = now;
2261
+ clampGameplay(state, manifest);
2262
+ return changed;
2263
+ }
2264
+ /** Apply one effect vector (touch/work/shop), clamped. */
2265
+ function applyGameplayEffects(state, manifest, effects) {
2266
+ for (const effect of effects) if (effect.stat !== void 0) state.stats[effect.stat] = (state.stats[effect.stat] ?? 0) + effect.amount;
2267
+ else if (effect.currency !== void 0) state.currencies[effect.currency] = (state.currencies[effect.currency] ?? 0) + effect.amount;
2268
+ clampGameplay(state, manifest);
2269
+ }
2270
+ /** Roll one touch zone branch; undefined when the roll lands in no-op mass. */
2271
+ function rollTouchBranch(zone, rng) {
2272
+ const roll = rng();
2273
+ let acc = 0;
2274
+ for (const branch of zone.branches) {
2275
+ acc += branch.probability;
2276
+ if (roll < acc) return branch;
2277
+ }
2278
+ }
2279
+ /** Roll one work tick outcome. */
2280
+ function rollWorkOutcome(work, rng) {
2281
+ return rng() < work.successProbability ? "success" : "fail";
2282
+ }
2283
+ /** Draw one lottery prize tier; uncovered mass falls through to the last tier. */
2284
+ function drawLotteryTier(lottery, rng) {
2285
+ const roll = rng();
2286
+ let acc = 0;
2287
+ for (const tier of lottery.tiers) {
2288
+ acc += tier.probability;
2289
+ if (roll < acc) return tier;
2290
+ }
2291
+ return lottery.tiers[lottery.tiers.length - 1];
2292
+ }
1760
2293
  /** Renderer kinds the pet center knows how to dispatch (M1 §2). */
1761
- const PET_RENDERER_KINDS = ["sprite2d", "live2d"];
2294
+ const PET_RENDERER_KINDS = [
2295
+ "sprite2d",
2296
+ "live2d",
2297
+ "frames2d"
2298
+ ];
1762
2299
  /** The seven ActivityPhase semantics (pet-center owned; M1 §1). */
1763
2300
  const PET_ACTIVITY_PHASES = [
1764
2301
  "idle",
@@ -1790,8 +2327,10 @@ const KNOWN_TOP_LEVEL = /* @__PURE__ */ new Set([
1790
2327
  "renderer",
1791
2328
  "sprite2d",
1792
2329
  "live2d",
2330
+ "frames2d",
1793
2331
  "sequences",
1794
- "remarks"
2332
+ "remarks",
2333
+ "gameplay"
1795
2334
  ]);
1796
2335
  /** sprite2d block field allow-list (drift-locked to the schema file). */
1797
2336
  const KNOWN_SPRITE2D = /* @__PURE__ */ new Set([
@@ -1812,6 +2351,20 @@ const KNOWN_LIVE2D = /* @__PURE__ */ new Set([
1812
2351
  "hitAreas",
1813
2352
  "lipSync"
1814
2353
  ]);
2354
+ /** frames2d block field allow-list (drift-locked to the schema file). */
2355
+ const KNOWN_FRAMES2D = /* @__PURE__ */ new Set([
2356
+ "dir",
2357
+ "defaultFrameMs",
2358
+ "tracks",
2359
+ "phases"
2360
+ ]);
2361
+ /** frames2d track field allow-list (drift-locked to the schema file). */
2362
+ const KNOWN_FRAMES2D_TRACK = /* @__PURE__ */ new Set([
2363
+ "frames",
2364
+ "frameMs",
2365
+ "loop",
2366
+ "fallback"
2367
+ ]);
1815
2368
  var Diagnostics$1 = class {
1816
2369
  list = [];
1817
2370
  source;
@@ -1958,6 +2511,106 @@ function parseLive2dBlock(raw, diag) {
1958
2511
  else block.lipSync = raw.lipSync;
1959
2512
  return diag.hasErrors ? void 0 : block;
1960
2513
  }
2514
+ const FRAMES2D_TRACK_NAME = /^[a-z0-9][a-z0-9-]*$/;
2515
+ const FRAMES2D_MAX_TRACKS = 64;
2516
+ const FRAMES2D_MAX_FRAMES = 64;
2517
+ const FRAMES2D_MIN_FRAME_MS = 16;
2518
+ const FRAMES2D_MAX_FRAME_MS = 5e3;
2519
+ const FRAMES2D_IMAGE_EXTENSIONS$1 = /* @__PURE__ */ new Set([
2520
+ ".webp",
2521
+ ".png",
2522
+ ".gif",
2523
+ ".jpg",
2524
+ ".jpeg"
2525
+ ]);
2526
+ /** Validate one frames2d track name (kebab id, at most 32 chars). */
2527
+ function validTrackName(name) {
2528
+ return name.length <= 32 && FRAMES2D_TRACK_NAME.test(name);
2529
+ }
2530
+ /** A frames2d frame entry is a single image file name inside the track directory. */
2531
+ function safeFrameName(raw) {
2532
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
2533
+ const value = raw.trim();
2534
+ if (value.includes("/") || value.includes("\\") || !PATH_SEGMENT_PATTERN$2.test(value)) return void 0;
2535
+ const dot = value.lastIndexOf(".");
2536
+ if (dot <= 0) return void 0;
2537
+ if (!FRAMES2D_IMAGE_EXTENSIONS$1.has(value.slice(dot).toLowerCase())) return void 0;
2538
+ return value;
2539
+ }
2540
+ function parseFrames2dTrack(raw, field, diag) {
2541
+ if (!isRecord$1(raw)) {
2542
+ diag.error(field + " must be an object");
2543
+ return;
2544
+ }
2545
+ const extra = unknownKeys$1(raw, KNOWN_FRAMES2D_TRACK);
2546
+ if (extra.length > 0) diag.error(field + ": unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
2547
+ const track = {};
2548
+ if (raw.frames !== void 0) if (!Array.isArray(raw.frames) || raw.frames.length === 0) diag.error(field + ".frames must be a non-empty array of frame file names");
2549
+ else if (raw.frames.length > FRAMES2D_MAX_FRAMES) diag.error(field + ".frames declares too many frames (" + raw.frames.length + ", max 64)");
2550
+ else {
2551
+ const frames = [];
2552
+ for (const entry of raw.frames) {
2553
+ const safe = safeFrameName(entry);
2554
+ if (safe === void 0) diag.error(field + ".frames entry " + JSON.stringify(String(entry)) + " is not a safe image file name");
2555
+ else frames.push(safe);
2556
+ }
2557
+ if (frames.length === raw.frames.length) track.frames = frames;
2558
+ }
2559
+ if (raw.frameMs !== void 0) if (!Array.isArray(raw.frameMs) || raw.frameMs.length === 0 || raw.frameMs.some((v) => typeof v !== "number" || !Number.isInteger(v) || v < FRAMES2D_MIN_FRAME_MS || v > FRAMES2D_MAX_FRAME_MS)) diag.error(field + ".frameMs must be a non-empty array of integer ms in [16, 5000]");
2560
+ else if (track.frames === void 0) diag.error(field + ".frameMs requires a valid explicit frames list (directory tracks use filename-encoded ms or defaultFrameMs)");
2561
+ else if (raw.frameMs.length !== track.frames.length) diag.error(field + ".frameMs must have the same length as frames");
2562
+ else track.frameMs = raw.frameMs;
2563
+ if (raw.loop !== void 0) if (typeof raw.loop !== "boolean") diag.error(field + ".loop must be a boolean");
2564
+ else track.loop = raw.loop;
2565
+ if (raw.fallback !== void 0) if (typeof raw.fallback !== "string" || !validTrackName(raw.fallback)) diag.error(field + ".fallback must be a track name");
2566
+ else track.fallback = raw.fallback;
2567
+ return diag.hasErrors ? void 0 : track;
2568
+ }
2569
+ function parseFrames2dBlock(raw, diag) {
2570
+ if (!isRecord$1(raw)) {
2571
+ diag.error("renderer frames2d requires a \"frames2d\" block object");
2572
+ return;
2573
+ }
2574
+ const extra = unknownKeys$1(raw, KNOWN_FRAMES2D);
2575
+ if (extra.length > 0) diag.error("frames2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
2576
+ const block = {
2577
+ tracks: {},
2578
+ phases: { idle: "" }
2579
+ };
2580
+ if (raw.dir !== void 0) {
2581
+ const dir = safeManifestPath(raw.dir);
2582
+ if (dir === void 0) diag.error("frames2d.dir must be a safe manifest-relative directory");
2583
+ else block.dir = dir;
2584
+ }
2585
+ if (raw.defaultFrameMs !== void 0) if (typeof raw.defaultFrameMs !== "number" || !Number.isInteger(raw.defaultFrameMs) || raw.defaultFrameMs < FRAMES2D_MIN_FRAME_MS || raw.defaultFrameMs > FRAMES2D_MAX_FRAME_MS) diag.error("frames2d.defaultFrameMs must be an integer in [16, 5000]");
2586
+ else block.defaultFrameMs = raw.defaultFrameMs;
2587
+ const tracks = {};
2588
+ if (!isRecord$1(raw.tracks)) diag.error("frames2d.tracks is required and must be an object keyed by track name");
2589
+ else {
2590
+ const entries = Object.entries(raw.tracks);
2591
+ if (entries.length === 0) diag.error("frames2d.tracks must declare at least one track");
2592
+ if (entries.length > FRAMES2D_MAX_TRACKS) diag.error("frames2d.tracks declares too many tracks (" + entries.length + ", max 64)");
2593
+ for (const [name, value] of entries) {
2594
+ if (!validTrackName(name)) {
2595
+ diag.error("frames2d.tracks: invalid track name " + JSON.stringify(name) + " (lowercase kebab, at most 32 chars)");
2596
+ continue;
2597
+ }
2598
+ const track = parseFrames2dTrack(value, "frames2d.tracks." + name, diag);
2599
+ if (track !== void 0) tracks[name] = track;
2600
+ }
2601
+ }
2602
+ block.tracks = tracks;
2603
+ const phases = parsePhaseStringMap(raw.phases, "frames2d.phases", diag);
2604
+ if (raw.phases === void 0) diag.error("frames2d.phases is required (at least an \"idle\" mapping)");
2605
+ else if (phases !== void 0 && phases.idle === void 0) diag.error("frames2d.phases.idle is required (unmapped phases fall back to it)");
2606
+ const phasesValid = phases !== void 0 && phases.idle !== void 0;
2607
+ if (phasesValid) block.phases = phases;
2608
+ if (phasesValid) {
2609
+ for (const [phase, target] of Object.entries(block.phases)) if (target !== void 0 && tracks[target] === void 0) diag.error("frames2d.phases." + phase + " references unknown track " + JSON.stringify(target));
2610
+ }
2611
+ for (const [name, track] of Object.entries(tracks)) if (track.fallback !== void 0 && tracks[track.fallback] === void 0) diag.error("frames2d.tracks." + name + ".fallback references unknown track " + JSON.stringify(track.fallback));
2612
+ return diag.hasErrors ? void 0 : block;
2613
+ }
1961
2614
  /** v1 compat read: map the legacy flat manifest onto the v2 sprite2d shape. */
1962
2615
  function compatV1(source, diag) {
1963
2616
  const id = parseStringBlock(source, "id", diag, true);
@@ -2017,10 +2670,25 @@ function parseV2(source, diag) {
2017
2670
  const block = parseSprite2dBlock(source.sprite2d, diag);
2018
2671
  if (block !== void 0) manifest.sprite2d = block;
2019
2672
  if (source.live2d !== void 0) diag.error("renderer sprite2d must not declare a live2d block");
2673
+ if (source.frames2d !== void 0) diag.error("renderer sprite2d must not declare a frames2d block");
2020
2674
  } else if (renderer === "live2d") {
2021
2675
  const block = parseLive2dBlock(source.live2d, diag);
2022
2676
  if (block !== void 0) manifest.live2d = block;
2023
2677
  if (source.sprite2d !== void 0) diag.error("renderer live2d must not declare a sprite2d block");
2678
+ if (source.frames2d !== void 0) diag.error("renderer live2d must not declare a frames2d block");
2679
+ } else if (renderer === "frames2d") {
2680
+ const block = parseFrames2dBlock(source.frames2d, diag);
2681
+ if (block !== void 0) manifest.frames2d = block;
2682
+ if (source.sprite2d !== void 0) diag.error("renderer frames2d must not declare a sprite2d block");
2683
+ if (source.live2d !== void 0) diag.error("renderer frames2d must not declare a live2d block");
2684
+ }
2685
+ if (source.gameplay !== void 0) if (renderer !== "frames2d") diag.error("gameplay currently requires renderer frames2d (its state references name frames2d tracks)");
2686
+ else {
2687
+ const gameplay = parseGameplayManifest(source.gameplay, {
2688
+ stateNames: new Set(Object.keys(manifest.frames2d?.tracks ?? {})),
2689
+ error: (message) => diag.error("gameplay: " + message)
2690
+ });
2691
+ if (gameplay !== void 0) manifest.gameplay = gameplay;
2024
2692
  }
2025
2693
  const sequences = parseSequences(source.sequences, diag);
2026
2694
  if (sequences !== void 0) manifest.sequences = sequences;
@@ -2824,6 +3492,173 @@ function resolveLive2dEntry(manifest, dir, options) {
2824
3492
  ...remarks === void 0 ? {} : { remarks }
2825
3493
  };
2826
3494
  }
3495
+ /** Filename-encoded frame duration tail ('<base>_<index>_<ms>.webp'). */
3496
+ const FRAMES2D_FILENAME_MS = /_(\d+)\.[^.]+$/;
3497
+ /** Trailing frame index, allowing an optional '_<ms>' duration tail after it. */
3498
+ const FRAMES2D_FRAME_INDEX = /(\d+)(?:_\d+)?\.[^.]+$/;
3499
+ /** Default per-frame duration when neither frameMs nor the filename encodes one. */
3500
+ const FRAMES2D_DEFAULT_FRAME_MS = 200;
3501
+ /** Image extensions a frames2d track directory may list. */
3502
+ const FRAMES2D_IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
3503
+ ".webp",
3504
+ ".png",
3505
+ ".gif",
3506
+ ".jpg",
3507
+ ".jpeg"
3508
+ ]);
3509
+ /**
3510
+ * Resolve a validated frames2d manifest into a renderable entry. Track frame
3511
+ * lists come from the manifest's explicit list or from listing
3512
+ * '<dir>/<track>/' in filename order; durations resolve frameMs[i] >
3513
+ * filename-encoded '_<ms>' tail > defaultFrameMs. Missing frames warn and
3514
+ * skip (the live2d closure discipline); a track left with zero frames is
3515
+ * dropped, and if the idle-mapped track ends up empty the entry is rejected
3516
+ * fail-closed. The sprite fields carry contract defaults: the chrome sizes
3517
+ * frames2d pets off 'display.size', not the atlas.
3518
+ */
3519
+ function resolveFrames2dEntry(manifest, dir, options) {
3520
+ const assetPrefix = options.assetPrefix ?? "/pet";
3521
+ const record = (level, message) => {
3522
+ options.diagnostics?.push({
3523
+ level,
3524
+ source: dir,
3525
+ message
3526
+ });
3527
+ options.warnings?.push(message);
3528
+ };
3529
+ const block = manifest.frames2d;
3530
+ if (block === void 0) {
3531
+ record("error", "pet " + manifest.id + ": renderer frames2d requires a frames2d block");
3532
+ return;
3533
+ }
3534
+ const root = block.dir ?? ".";
3535
+ const defaultMs = block.defaultFrameMs ?? FRAMES2D_DEFAULT_FRAME_MS;
3536
+ const idleTrack = block.phases.idle;
3537
+ const tracks = {};
3538
+ const servable = [];
3539
+ const firstFrameRel = {};
3540
+ for (const [name, track] of Object.entries(block.tracks)) {
3541
+ const trackDir = root === "." ? name : root + "/" + name;
3542
+ let relFrames = [];
3543
+ if (track.frames !== void 0) for (const frame of track.frames) {
3544
+ const rel = trackDir + "/" + frame;
3545
+ if (!existsSync(join(dir, rel))) {
3546
+ record("warning", "pet " + manifest.id + ": frames2d frame missing: " + rel);
3547
+ continue;
3548
+ }
3549
+ relFrames.push(rel);
3550
+ }
3551
+ else {
3552
+ let files = [];
3553
+ try {
3554
+ files = readdirSync(join(dir, trackDir)).filter((file) => {
3555
+ if (file.startsWith(".")) return false;
3556
+ const dot = file.lastIndexOf(".");
3557
+ return dot > 0 && FRAMES2D_IMAGE_EXTENSIONS.has(file.slice(dot).toLowerCase());
3558
+ });
3559
+ } catch {
3560
+ files = [];
3561
+ }
3562
+ files.sort((a, b) => {
3563
+ const ia = FRAMES2D_FRAME_INDEX.exec(a)?.[1];
3564
+ const ib = FRAMES2D_FRAME_INDEX.exec(b)?.[1];
3565
+ if (ia !== void 0 && ib !== void 0 && ia !== ib) return Number(ia) - Number(ib);
3566
+ return a < b ? -1 : a > b ? 1 : 0;
3567
+ });
3568
+ relFrames = files.map((file) => trackDir + "/" + file);
3569
+ }
3570
+ if (relFrames.length === 0) {
3571
+ record("warning", "pet " + manifest.id + ": frames2d track " + JSON.stringify(name) + " has no frames on disk; dropped");
3572
+ continue;
3573
+ }
3574
+ const durations = relFrames.map((rel, index) => {
3575
+ if (track.frameMs !== void 0) return track.frameMs[index] ?? defaultMs;
3576
+ const match = FRAMES2D_FILENAME_MS.exec(rel);
3577
+ if (match !== null) {
3578
+ const ms = Number(match[1]);
3579
+ if (Number.isInteger(ms) && ms >= 16 && ms <= 5e3) return ms;
3580
+ }
3581
+ return defaultMs;
3582
+ });
3583
+ const loop = track.loop ?? true;
3584
+ tracks[name] = {
3585
+ frames: relFrames.map((rel) => assetUrl(assetPrefix, manifest.id, rel)),
3586
+ durations,
3587
+ loop,
3588
+ ...loop ? {} : { fallback: track.fallback ?? idleTrack }
3589
+ };
3590
+ firstFrameRel[name] = relFrames[0];
3591
+ servable.push(...relFrames);
3592
+ }
3593
+ if (tracks[idleTrack] === void 0) {
3594
+ record("error", "pet " + manifest.id + ": frames2d idle track " + JSON.stringify(idleTrack) + " has no frames on disk");
3595
+ return;
3596
+ }
3597
+ const phases = { ...block.phases };
3598
+ for (const [phase, target] of Object.entries(phases)) if (target !== void 0 && tracks[target] === void 0) {
3599
+ record("warning", "pet " + manifest.id + ": frames2d phase " + phase + " maps to dropped track " + JSON.stringify(target) + "; using idle");
3600
+ phases[phase] = idleTrack;
3601
+ }
3602
+ const remarks = normalizePetRemarks(manifest.remarks, (message) => record("warning", "pet " + manifest.id + ": " + message));
3603
+ let gameplay;
3604
+ if (manifest.gameplay !== void 0) {
3605
+ gameplay = manifest.gameplay;
3606
+ if (gameplay.shop !== void 0) {
3607
+ const items = [];
3608
+ for (const item of gameplay.shop.items) {
3609
+ if (item.image === void 0) {
3610
+ items.push(item);
3611
+ continue;
3612
+ }
3613
+ if (!existsSync(join(dir, item.image))) {
3614
+ record("warning", "pet " + manifest.id + ": gameplay shop item " + item.id + " image missing: " + item.image);
3615
+ const { image, ...rest } = item;
3616
+ items.push(rest);
3617
+ continue;
3618
+ }
3619
+ servable.push(item.image);
3620
+ items.push({
3621
+ ...item,
3622
+ image: assetUrl(assetPrefix, manifest.id, item.image)
3623
+ });
3624
+ }
3625
+ gameplay = {
3626
+ ...gameplay,
3627
+ shop: {
3628
+ ...gameplay.shop,
3629
+ items
3630
+ }
3631
+ };
3632
+ }
3633
+ }
3634
+ const flatTracks = buildTracks(DEFAULT_FRAME_COUNTS, 8, {}, (message) => record("warning", "pet " + manifest.id + ": " + message));
3635
+ if (flatTracks === void 0) return void 0;
3636
+ const firstAbs = join(dir, firstFrameRel[idleTrack]);
3637
+ const dims = existsSync(firstAbs) ? readImageDimensions(firstAbs) : void 0;
3638
+ const cell = dims !== void 0 && dims.width >= 1 && dims.height >= 1 ? dims : { ...DEFAULT_PET_CELL };
3639
+ return {
3640
+ id: manifest.id,
3641
+ displayName: manifest.displayName,
3642
+ description: manifest.description ?? "",
3643
+ renderer: "frames2d",
3644
+ frames2d: {
3645
+ tracks,
3646
+ phases
3647
+ },
3648
+ ...gameplay === void 0 ? {} : { gameplay },
3649
+ cell,
3650
+ columns: 8,
3651
+ rows: [...DEFAULT_FRAME_COUNTS],
3652
+ atlasRows: 9,
3653
+ tracks: flatTracks,
3654
+ atlasUrl: tracks[idleTrack].frames[0],
3655
+ manifestUrl: assetUrl(assetPrefix, manifest.id, "pet.json"),
3656
+ dir,
3657
+ spritesheetPath: firstFrameRel[idleTrack],
3658
+ servable,
3659
+ ...remarks === void 0 ? {} : { remarks }
3660
+ };
3661
+ }
2827
3662
  /** Scan one directory of pet folders; entries come back in name order. */
2828
3663
  function scanPetDir(dir, options) {
2829
3664
  if (!existsSync(dir)) return [];
@@ -2853,6 +3688,7 @@ function scanPetDir(dir, options) {
2853
3688
  if (!verdict.ok) continue;
2854
3689
  let entry;
2855
3690
  if (verdict.manifest.renderer === "live2d") entry = resolveLive2dEntry(verdict.manifest, entryDir, options);
3691
+ else if (verdict.manifest.renderer === "frames2d") entry = resolveFrames2dEntry(verdict.manifest, entryDir, options);
2856
3692
  else {
2857
3693
  const legacy = flattenV2Sprite2d(verdict.manifest);
2858
3694
  if (legacy === void 0) {
@@ -3144,7 +3980,7 @@ function loadPetRegistry(options) {
3144
3980
  warnings,
3145
3981
  diagnostics,
3146
3982
  byId: (id) => byId.get(id),
3147
- defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0],
3983
+ defaultEntry: () => entries.find((entry) => entry.id === "whale-girl" && builtinIds.has(entry.id)) ?? entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0],
3148
3984
  ...globalVoice === void 0 ? {} : { globalVoice },
3149
3985
  decorations,
3150
3986
  decorationById: (id) => decorationById.get(id)
@@ -3180,6 +4016,8 @@ function petEntryView(entry, globalVoice) {
3180
4016
  description: entry.description,
3181
4017
  renderer: entry.renderer,
3182
4018
  ...entry.live2d === void 0 ? {} : { live2d: entry.live2d },
4019
+ ...entry.frames2d === void 0 ? {} : { frames2d: entry.frames2d },
4020
+ ...entry.gameplay === void 0 ? {} : { gameplay: entry.gameplay },
3183
4021
  cell: entry.cell,
3184
4022
  columns: entry.columns,
3185
4023
  rows: entry.rows,
@@ -3191,6 +4029,23 @@ function petEntryView(entry, globalVoice) {
3191
4029
  ...panel === void 0 ? {} : { panel }
3192
4030
  };
3193
4031
  }
4032
+ //#endregion
4033
+ //#region src/service.ts
4034
+ /**
4035
+ * Pet host service — the `pet.*` RPC domain. A composition facade: it wires
4036
+ * the pure event projection (`event-projection`) onto the state machine,
4037
+ * delegates the affinity economy to the ledger (`ledger`), and routes
4038
+ * persistence through `persist`. The API gateway maps these methods onto
4039
+ * `pet.state` / `pet.pets` / `pet.interact` / `pet.setVisible` /
4040
+ * `pet.setConfig` / `pet.setName` / `pet.setPet` for browser consumers.
4041
+ *
4042
+ * Concurrent sessions each keep their own machine: the sprite animation
4043
+ * follows the most recent meaningful event (the display session) while the
4044
+ * state view carries one bubble per active session.
4045
+ * @module @linxin666/dsh-pet/service
4046
+ */
4047
+ /** The unified gameplay currency: the shared treat (小鱼干) ledger. */
4048
+ const GAMEPLAY_TREATS_CURRENCY = "treats";
3194
4049
  /** Hard cap on simultaneously displayed session bubbles (most recent first). */
3195
4050
  const MAX_SESSION_BUBBLES = 12;
3196
4051
  /**
@@ -3281,8 +4136,8 @@ var PetService = class extends Service {
3281
4136
  return this.enabled;
3282
4137
  }
3283
4138
  /** RPC: current pet state snapshot. */
3284
- async state() {
3285
- return this.view();
4139
+ async state(currentSessionId) {
4140
+ return this.view(currentSessionId);
3286
4141
  }
3287
4142
  /** Current persisted display config (read-only view). */
3288
4143
  display() {
@@ -3442,6 +4297,205 @@ var PetService = class extends Service {
3442
4297
  if (this.ledger.takeDirty()) this.flush();
3443
4298
  return result;
3444
4299
  }
4300
+ /** The active pet's gameplay block, if it declares one. */
4301
+ gameplayDef() {
4302
+ return this.activeEntry().gameplay;
4303
+ }
4304
+ /** The persisted (or fresh) gameplay state of the selected pet. */
4305
+ gameplayState(def, now) {
4306
+ const petId = this.selectedPetId();
4307
+ const stored = this.ledger.snapshot.gameplay[petId];
4308
+ return {
4309
+ petId,
4310
+ state: stored === void 0 ? initialGameplayState(def, now) : {
4311
+ stats: { ...stored.stats },
4312
+ currencies: { ...stored.currencies },
4313
+ mode: stored.mode,
4314
+ settledAt: stored.settledAt
4315
+ }
4316
+ };
4317
+ }
4318
+ /** Display view of one gameplay state (rounded stats; treats ride the shared treat ledger). */
4319
+ gameplayViewOf(state) {
4320
+ const stats = {};
4321
+ for (const [name, value] of Object.entries(state.stats)) stats[name] = Math.round(value);
4322
+ return {
4323
+ stats,
4324
+ mode: state.mode
4325
+ };
4326
+ }
4327
+ /**
4328
+ * Move gameplay 'treats' currency (the unified post-wallet currency) from
4329
+ * the engine's settle work area into the shared treat ledger, capped by
4330
+ * the stock cap. The engine keeps its generic currency record for settle
4331
+ * math; this drain is the only bridge to the wallet-free economy.
4332
+ */
4333
+ drainGameplayTreats(state) {
4334
+ const pending = Math.floor(state.currencies[GAMEPLAY_TREATS_CURRENCY] ?? 0);
4335
+ delete state.currencies[GAMEPLAY_TREATS_CURRENCY];
4336
+ if (pending > 0) this.ledger.grantTreats(pending);
4337
+ }
4338
+ /** Persist the mutated gameplay state of one verb call. */
4339
+ commitGameplay(petId, state) {
4340
+ this.ledger.setGameplay(petId, state);
4341
+ if (this.ledger.takeDirty()) this.flush();
4342
+ }
4343
+ /**
4344
+ * RPC: a touch on the pet. 'zone' names a touch zone (roll a branch);
4345
+ * omitted means a plain click while a touch animation holds (clickBoost).
4346
+ */
4347
+ async gameplayTouch(zone) {
4348
+ const def = this.gameplayDef();
4349
+ if (def === void 0) return {
4350
+ ok: false,
4351
+ error: "no-gameplay"
4352
+ };
4353
+ const now = Date.now();
4354
+ const { petId, state } = this.gameplayState(def, now);
4355
+ settleGameplay(state, def, now, { sessionActive: this.machine.render().sessionActive });
4356
+ if (zone === void 0) {
4357
+ const boost = def.touch?.clickBoost;
4358
+ if (boost === void 0) return {
4359
+ ok: false,
4360
+ error: "no-touch"
4361
+ };
4362
+ const amount = boost.min + Math.floor(Math.random() * (boost.max - boost.min + 1));
4363
+ if (amount > 0) applyGameplayEffects(state, def, [{
4364
+ stat: boost.stat,
4365
+ amount
4366
+ }]);
4367
+ this.drainGameplayTreats(state);
4368
+ this.commitGameplay(petId, state);
4369
+ return {
4370
+ ok: true,
4371
+ hit: false,
4372
+ view: this.gameplayViewOf(state)
4373
+ };
4374
+ }
4375
+ const target = def.touch?.zones.find((entry) => entry.name === zone);
4376
+ if (target === void 0) return {
4377
+ ok: false,
4378
+ error: "unknown-zone"
4379
+ };
4380
+ const branch = rollTouchBranch(target, Math.random);
4381
+ if (branch === void 0) {
4382
+ this.drainGameplayTreats(state);
4383
+ this.commitGameplay(petId, state);
4384
+ return {
4385
+ ok: true,
4386
+ hit: false,
4387
+ view: this.gameplayViewOf(state)
4388
+ };
4389
+ }
4390
+ if (branch.effects !== void 0) applyGameplayEffects(state, def, branch.effects);
4391
+ const phrase = branch.phrases !== void 0 && branch.phrases.length > 0 ? branch.phrases[Math.floor(Math.random() * branch.phrases.length)] : void 0;
4392
+ this.drainGameplayTreats(state);
4393
+ this.commitGameplay(petId, state);
4394
+ return {
4395
+ ok: true,
4396
+ hit: true,
4397
+ ...branch.state === void 0 ? {} : { state: branch.state },
4398
+ ...branch.stateMs === void 0 ? {} : { stateMs: branch.stateMs },
4399
+ ...phrase === void 0 ? {} : { phrase },
4400
+ view: this.gameplayViewOf(state)
4401
+ };
4402
+ }
4403
+ /** RPC: enter or leave a gameplay mode ('work' | 'sleep' | null). */
4404
+ async gameplaySetMode(mode) {
4405
+ const def = this.gameplayDef();
4406
+ if (def === void 0) return {
4407
+ ok: false,
4408
+ error: "no-gameplay"
4409
+ };
4410
+ if (mode === "work" && def.work === void 0) return {
4411
+ ok: false,
4412
+ error: "no-work"
4413
+ };
4414
+ if (mode === "sleep" && def.sleep === void 0) return {
4415
+ ok: false,
4416
+ error: "no-sleep"
4417
+ };
4418
+ const now = Date.now();
4419
+ const { petId, state } = this.gameplayState(def, now);
4420
+ settleGameplay(state, def, now, { sessionActive: this.machine.render().sessionActive });
4421
+ state.mode = mode;
4422
+ this.drainGameplayTreats(state);
4423
+ this.commitGameplay(petId, state);
4424
+ return {
4425
+ ok: true,
4426
+ view: this.gameplayViewOf(state)
4427
+ };
4428
+ }
4429
+ /** RPC: one work-round adjudication (only while the work mode holds). */
4430
+ async gameplayWorkTick() {
4431
+ const def = this.gameplayDef();
4432
+ if (def?.work === void 0) return {
4433
+ ok: false,
4434
+ error: "no-work"
4435
+ };
4436
+ const now = Date.now();
4437
+ const { petId, state } = this.gameplayState(def, now);
4438
+ if (state.mode !== "work") return {
4439
+ ok: false,
4440
+ error: "not-working"
4441
+ };
4442
+ settleGameplay(state, def, now, { sessionActive: this.machine.render().sessionActive });
4443
+ const outcome = rollWorkOutcome(def.work, Math.random);
4444
+ const effects = outcome === "success" ? def.work.success?.effects : def.work.fail?.effects;
4445
+ if (effects !== void 0) applyGameplayEffects(state, def, effects);
4446
+ this.drainGameplayTreats(state);
4447
+ this.commitGameplay(petId, state);
4448
+ return {
4449
+ ok: true,
4450
+ outcome,
4451
+ view: this.gameplayViewOf(state)
4452
+ };
4453
+ }
4454
+ /** RPC: buy one shop item (effects, currency swap, or a lottery draw). */
4455
+ async gameplayBuy(itemId) {
4456
+ const def = this.gameplayDef();
4457
+ if (def === void 0) return {
4458
+ ok: false,
4459
+ error: "no-gameplay"
4460
+ };
4461
+ const item = def.shop?.items.find((entry) => entry.id === itemId);
4462
+ if (item === void 0) return {
4463
+ ok: false,
4464
+ error: "unknown-item"
4465
+ };
4466
+ const now = Date.now();
4467
+ const { petId, state } = this.gameplayState(def, now);
4468
+ settleGameplay(state, def, now, { sessionActive: this.machine.render().sessionActive });
4469
+ const treats = item.currency === GAMEPLAY_TREATS_CURRENCY;
4470
+ const balance = treats ? this.ledger.snapshot.treats.treats : state.currencies[item.currency] ?? 0;
4471
+ if (balance < item.price) return {
4472
+ ok: false,
4473
+ error: "insufficient-funds",
4474
+ view: this.gameplayViewOf(state)
4475
+ };
4476
+ if (treats) this.ledger.spendTreats(item.price);
4477
+ else state.currencies[item.currency] = balance - item.price;
4478
+ if (item.effects !== void 0) applyGameplayEffects(state, def, item.effects);
4479
+ let prize;
4480
+ if (item.lottery !== void 0) {
4481
+ if (item.lottery.effects !== void 0) applyGameplayEffects(state, def, item.lottery.effects);
4482
+ const tier = drawLotteryTier(item.lottery, Math.random);
4483
+ const prizeCurrency = tier.currency ?? item.lottery.currency ?? item.currency;
4484
+ if (prizeCurrency === GAMEPLAY_TREATS_CURRENCY) this.ledger.grantTreats(tier.prize);
4485
+ else state.currencies[prizeCurrency] = (state.currencies[prizeCurrency] ?? 0) + tier.prize;
4486
+ prize = {
4487
+ amount: tier.prize,
4488
+ currency: prizeCurrency
4489
+ };
4490
+ }
4491
+ this.drainGameplayTreats(state);
4492
+ this.commitGameplay(petId, state);
4493
+ return {
4494
+ ok: true,
4495
+ ...prize === void 0 ? {} : { prize },
4496
+ view: this.gameplayViewOf(state)
4497
+ };
4498
+ }
3445
4499
  /** RPC: show or hide the pet. */
3446
4500
  async setVisible(visible) {
3447
4501
  this.ledger.setDisplay({
@@ -3532,7 +4586,7 @@ var PetService = class extends Service {
3532
4586
  rewardLegacyTurn() {
3533
4587
  if (this.ledger.rewardLegacyTurn(Date.now())) this.flush();
3534
4588
  }
3535
- view() {
4589
+ view(currentSessionId) {
3536
4590
  const snapshot = this.machine.render();
3537
4591
  const entry = this.activeEntry();
3538
4592
  const sessions = [];
@@ -3541,23 +4595,34 @@ var PetService = class extends Service {
3541
4595
  if (session.header?.origin === "subagent") continue;
3542
4596
  const perSession = activity.machine.render();
3543
4597
  if (perSession.bubble === void 0) continue;
4598
+ const whisper = activity.whisper;
4599
+ const freshWhisper = whisper !== void 0 && Date.now() - whisper.at < 8e3 ? whisper.text : void 0;
3544
4600
  sessions.push({
3545
4601
  sessionId: String(session.id),
3546
4602
  animation: perSession.animation,
3547
4603
  bubble: perSession.bubble,
3548
- phase: perSession.phase
4604
+ phase: perSession.phase,
4605
+ ...freshWhisper === void 0 ? {} : { whisper: freshWhisper }
3549
4606
  });
3550
4607
  }
3551
- const whisper = (this.displaySession === void 0 ? void 0 : this.sessionActivity.get(this.displaySession))?.whisper;
3552
- const freshWhisper = whisper !== void 0 && Date.now() - whisper.at < 8e3 ? whisper.text : void 0;
4608
+ if (currentSessionId !== void 0) {
4609
+ const index = sessions.findIndex((session) => session.sessionId === currentSessionId);
4610
+ if (index > 0) sessions.unshift(sessions.splice(index, 1)[0]);
4611
+ }
3553
4612
  const decoration = this.activeDecoration();
4613
+ const gameplayDef = this.gameplayDef();
4614
+ let gameplay;
4615
+ if (gameplayDef !== void 0) {
4616
+ const { state } = this.gameplayState(gameplayDef, Date.now());
4617
+ settleGameplay(state, gameplayDef, Date.now(), { sessionActive: snapshot.sessionActive });
4618
+ gameplay = this.gameplayViewOf(state);
4619
+ }
3554
4620
  return {
3555
4621
  animation: snapshot.animation,
3556
4622
  ...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
3557
4623
  phase: snapshot.phase,
3558
4624
  sessionActive: snapshot.sessionActive,
3559
4625
  sessions,
3560
- ...freshWhisper === void 0 ? {} : { whisper: freshWhisper },
3561
4626
  ...decoration === void 0 ? {} : { decoration },
3562
4627
  affinity: this.ledger.affinityView(Date.now()),
3563
4628
  display: { ...this.ledger.snapshot.display },
@@ -3570,7 +4635,8 @@ var PetService = class extends Service {
3570
4635
  treats: {
3571
4636
  stocked: this.ledger.snapshot.treats.treats,
3572
4637
  max: this.ledger.treatMax
3573
- }
4638
+ },
4639
+ ...gameplay === void 0 ? {} : { gameplay }
3574
4640
  };
3575
4641
  }
3576
4642
  flush() {
@@ -3792,7 +4858,7 @@ function guard(ctx, req, res) {
3792
4858
  });
3793
4859
  return false;
3794
4860
  }
3795
- /** Wrap one async service call as a GET JSON route. */
4861
+ /** Wrap one async service call as a GET JSON route (request passed through for query params). */
3796
4862
  function getRoute(ctx, path, run) {
3797
4863
  return {
3798
4864
  kind: "exact",
@@ -3800,7 +4866,7 @@ function getRoute(ctx, path, run) {
3800
4866
  handler: (req, res) => {
3801
4867
  if (!guard(ctx, req, res)) return;
3802
4868
  if (!requireMethod(req, res, "GET")) return;
3803
- run().then((value) => writeJson(res, 200, value), (error) => {
4869
+ run(req).then((value) => writeJson(res, 200, value), (error) => {
3804
4870
  writeJson(res, 500, {
3805
4871
  ok: false,
3806
4872
  error: error instanceof Error ? error.message : String(error)
@@ -4182,7 +5248,10 @@ function decorationHandler(ctx, registry, caps) {
4182
5248
  function makePetRoutes(deps) {
4183
5249
  const { service, ctx } = deps;
4184
5250
  const apiRoutes = [
4185
- getRoute(ctx, "/api/pet/state", () => service.state()),
5251
+ getRoute(ctx, "/api/pet/state", (req) => {
5252
+ const current = new URL(req.url ?? "/", "http://pet.local").searchParams.get("current");
5253
+ return service.state(current === null || current === "" ? void 0 : current);
5254
+ }),
4186
5255
  getRoute(ctx, "/api/pet/pets", () => service.pets()),
4187
5256
  getRoute(ctx, "/api/pet/diagnostics", () => service.diagnostics()),
4188
5257
  postRoute(ctx, "/api/pet/interact", (body) => {
@@ -4210,6 +5279,22 @@ function makePetRoutes(deps) {
4210
5279
  const petId = body.petId;
4211
5280
  if (typeof petId !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-pet"));
4212
5281
  return service.setPetId(petId);
5282
+ }),
5283
+ postRoute(ctx, "/api/pet/gameplay/touch", (body) => {
5284
+ const zone = body.zone;
5285
+ if (zone !== void 0 && typeof zone !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-zone"));
5286
+ return service.gameplayTouch(zone);
5287
+ }),
5288
+ postRoute(ctx, "/api/pet/gameplay/mode", (body) => {
5289
+ const mode = body.mode;
5290
+ if (mode !== null && mode !== "work" && mode !== "sleep") return Promise.reject(/* @__PURE__ */ new Error("invalid-mode"));
5291
+ return service.gameplaySetMode(mode);
5292
+ }),
5293
+ postRoute(ctx, "/api/pet/gameplay/work-tick", () => service.gameplayWorkTick()),
5294
+ postRoute(ctx, "/api/pet/gameplay/buy", (body) => {
5295
+ const item = body.item;
5296
+ if (typeof item !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-item"));
5297
+ return service.gameplayBuy(item);
4213
5298
  })
4214
5299
  ];
4215
5300
  const assetRoute = {