@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.
- package/README.i18n.yaml +2 -2
- package/README.md +30 -13
- package/README.zh.md +30 -13
- package/assets/ouo-neko/pet.json +31 -0
- package/assets/ouo-neko/previews/failed.gif +0 -0
- package/assets/ouo-neko/previews/idle.gif +0 -0
- package/assets/ouo-neko/previews/jumping.gif +0 -0
- package/assets/ouo-neko/previews/review.gif +0 -0
- package/assets/ouo-neko/previews/running-left.gif +0 -0
- package/assets/ouo-neko/previews/running-right.gif +0 -0
- package/assets/ouo-neko/previews/running.gif +0 -0
- package/assets/ouo-neko/previews/waiting.gif +0 -0
- package/assets/ouo-neko/previews/waving.gif +0 -0
- package/assets/ouo-neko/spritesheet.webp +0 -0
- package/contracts/pet-manifest-v2.schema.json +102 -1
- package/contracts/voice-pack-v1.schema.json +23 -28
- package/lib/client.js +781 -40
- package/lib/client.js.map +1 -1
- package/lib/index.js +1567 -482
- package/lib/types/chatter.d.ts +68 -46
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +243 -301
- package/lib/types/client/PetDockEntry.d.ts +3 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +18 -2
- package/lib/types/client/PetSprite.d.ts +23 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +49 -45
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +2 -2
- package/lib/types/client/drag-stream.d.ts +19 -0
- package/lib/types/client/drag-stream.d.ts.map +1 -0
- package/lib/types/client/drag-stream.js +26 -0
- package/lib/types/client/gameplay-hud.d.ts +50 -0
- package/lib/types/client/gameplay-hud.d.ts.map +1 -0
- package/lib/types/client/gameplay-hud.js +274 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +38 -9
- package/lib/types/client/locales.d.ts +34 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -0
- package/lib/types/client/pet-store.d.ts +7 -1
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
- package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
- package/lib/types/client/renderers/frames2d.d.ts +36 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
- package/lib/types/client/renderers/frames2d.js +196 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/defaults.js +5 -0
- package/lib/types/dsh-home.d.ts.map +1 -1
- package/lib/types/dsh-home.js +9 -3
- package/lib/types/event-projection.d.ts +9 -4
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +46 -14
- package/lib/types/gameplay.d.ts +182 -0
- package/lib/types/gameplay.d.ts.map +1 -0
- package/lib/types/gameplay.js +618 -0
- package/lib/types/ledger.d.ts +14 -0
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +30 -0
- package/lib/types/manifest-v2.d.ts +38 -1
- package/lib/types/manifest-v2.d.ts.map +1 -1
- package/lib/types/manifest-v2.js +182 -2
- package/lib/types/persist.d.ts +4 -4
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +41 -4
- package/lib/types/registry.d.ts +29 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +170 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +30 -3
- package/lib/types/service.d.ts +63 -11
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +208 -19
- package/lib/types/voice-pack.d.ts +6 -7
- package/lib/types/voice-pack.d.ts.map +1 -1
- package/lib/types/voice-pack.js +44 -47
- package/package.json +5 -2
- package/src/chatter.test.ts +68 -38
- package/src/chatter.ts +269 -316
- package/src/client/PetDockEntry.test.tsx +6 -0
- package/src/client/PetDockEntry.tsx +32 -1
- package/src/client/PetSprite.test.tsx +68 -21
- package/src/client/PetSprite.tsx +63 -45
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/drag-stream.ts +35 -0
- package/src/client/gameplay-hud.test.tsx +338 -0
- package/src/client/gameplay-hud.tsx +403 -0
- package/src/client/index.test.tsx +10 -1
- package/src/client/index.ts +46 -11
- package/src/client/locales.ts +36 -0
- package/src/client/pet-store.ts +10 -1
- package/src/client/pet.module.css +192 -29
- package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
- package/src/client/renderers/PetRendererSwitch.tsx +28 -1
- package/src/client/renderers/frames2d.test.ts +137 -0
- package/src/client/renderers/frames2d.ts +216 -0
- package/src/client/settings-card.module.css +5 -0
- package/src/defaults.ts +7 -0
- package/src/dsh-home.ts +9 -3
- package/src/event-projection.ts +57 -16
- package/src/gameplay.test.ts +239 -0
- package/src/gameplay.ts +692 -0
- package/src/ledger.test.ts +16 -0
- package/src/ledger.ts +31 -0
- package/src/manifest-v2.test.ts +130 -1
- package/src/manifest-v2.ts +181 -2
- package/src/persist.test.ts +3 -0
- package/src/persist.ts +43 -6
- package/src/registry.test.ts +168 -1
- package/src/registry.ts +199 -1
- package/src/routes.ts +28 -4
- package/src/service.ts +240 -29
- package/src/voice-pack.test.ts +48 -25
- 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
|
-
/**
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
"
|
|
513
|
-
"
|
|
514
|
-
"
|
|
515
|
-
"
|
|
516
|
-
"
|
|
517
|
-
"
|
|
518
|
-
"
|
|
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
|
-
/**
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
845
|
-
|
|
692
|
+
categories: WHISPER_CATEGORY_POOLS,
|
|
693
|
+
results: WHISPER_RESULT_POOLS
|
|
846
694
|
}
|
|
847
695
|
};
|
|
848
696
|
/**
|
|
849
|
-
* The murmur engine (碎碎念):
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
711
|
+
categoryCooldownMs;
|
|
712
|
+
resultCooldownMs;
|
|
713
|
+
categoryCursor = /* @__PURE__ */ new Map();
|
|
714
|
+
resultCursor = /* @__PURE__ */ new Map();
|
|
863
715
|
lastWhisperAt = Number.NEGATIVE_INFINITY;
|
|
864
|
-
|
|
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.
|
|
868
|
-
this.
|
|
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
|
-
*
|
|
872
|
-
*
|
|
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
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
|
885
|
-
*
|
|
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
|
-
|
|
888
|
-
if (
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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":
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
|
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
|
|
1270
|
+
return isAbs(expanded) ? expanded : j(process.cwd(), expanded);
|
|
1342
1271
|
}
|
|
1343
|
-
return
|
|
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$
|
|
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
|
|
1542
|
-
function
|
|
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 (!
|
|
1545
|
-
onWarning("whispers.
|
|
1507
|
+
if (!isRecord$3(raw)) {
|
|
1508
|
+
onWarning("whispers.categories must be an object");
|
|
1546
1509
|
return;
|
|
1547
1510
|
}
|
|
1548
|
-
|
|
1549
|
-
const
|
|
1550
|
-
|
|
1551
|
-
|
|
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
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
-
|
|
1575
|
-
|
|
1576
|
-
pool
|
|
1577
|
-
});
|
|
1535
|
+
const pool = normalizePool(raw[key], "whisperResult", onWarning);
|
|
1536
|
+
if (pool !== void 0) pools[key] = pool;
|
|
1578
1537
|
}
|
|
1579
|
-
return
|
|
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$
|
|
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$
|
|
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$
|
|
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(["
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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 (
|
|
1698
|
-
|
|
1699
|
-
const
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
...
|
|
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 = [
|
|
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
|
-
|
|
3552
|
-
|
|
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", () =>
|
|
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 = {
|