@sdd330dev/jy-skill 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/AGENTS.md +148 -0
  2. package/LICENSE +21 -0
  3. package/README.md +177 -0
  4. package/SKILL.md +208 -0
  5. package/assets/characters/0.json +56 -0
  6. package/assets/characters/1.json +60 -0
  7. package/assets/characters/10.json +56 -0
  8. package/assets/characters/100.json +38 -0
  9. package/assets/characters/101.json +38 -0
  10. package/assets/characters/102.json +38 -0
  11. package/assets/characters/103.json +38 -0
  12. package/assets/characters/104.json +38 -0
  13. package/assets/characters/105.json +38 -0
  14. package/assets/characters/106.json +38 -0
  15. package/assets/characters/107.json +38 -0
  16. package/assets/characters/108.json +38 -0
  17. package/assets/characters/109.json +38 -0
  18. package/assets/characters/11.json +56 -0
  19. package/assets/characters/110.json +38 -0
  20. package/assets/characters/12.json +52 -0
  21. package/assets/characters/13.json +56 -0
  22. package/assets/characters/14.json +52 -0
  23. package/assets/characters/15.json +56 -0
  24. package/assets/characters/16.json +56 -0
  25. package/assets/characters/17.json +52 -0
  26. package/assets/characters/18.json +43 -0
  27. package/assets/characters/19.json +56 -0
  28. package/assets/characters/2.json +56 -0
  29. package/assets/characters/20.json +56 -0
  30. package/assets/characters/200.json +48 -0
  31. package/assets/characters/201.json +48 -0
  32. package/assets/characters/202.json +56 -0
  33. package/assets/characters/21.json +52 -0
  34. package/assets/characters/22.json +52 -0
  35. package/assets/characters/23.json +56 -0
  36. package/assets/characters/24.json +43 -0
  37. package/assets/characters/25.json +52 -0
  38. package/assets/characters/26.json +56 -0
  39. package/assets/characters/27.json +52 -0
  40. package/assets/characters/28.json +52 -0
  41. package/assets/characters/29.json +52 -0
  42. package/assets/characters/3.json +60 -0
  43. package/assets/characters/30.json +52 -0
  44. package/assets/characters/300.json +38 -0
  45. package/assets/characters/301.json +38 -0
  46. package/assets/characters/31.json +56 -0
  47. package/assets/characters/32.json +47 -0
  48. package/assets/characters/33.json +47 -0
  49. package/assets/characters/34.json +52 -0
  50. package/assets/characters/35.json +56 -0
  51. package/assets/characters/36.json +52 -0
  52. package/assets/characters/37.json +52 -0
  53. package/assets/characters/38.json +56 -0
  54. package/assets/characters/39.json +52 -0
  55. package/assets/characters/4.json +56 -0
  56. package/assets/characters/40.json +56 -0
  57. package/assets/characters/41.json +52 -0
  58. package/assets/characters/42.json +52 -0
  59. package/assets/characters/43.json +52 -0
  60. package/assets/characters/44.json +52 -0
  61. package/assets/characters/45.json +56 -0
  62. package/assets/characters/46.json +56 -0
  63. package/assets/characters/47.json +47 -0
  64. package/assets/characters/48.json +52 -0
  65. package/assets/characters/49.json +38 -0
  66. package/assets/characters/5.json +60 -0
  67. package/assets/characters/50.json +38 -0
  68. package/assets/characters/51.json +38 -0
  69. package/assets/characters/52.json +38 -0
  70. package/assets/characters/53.json +38 -0
  71. package/assets/characters/54.json +38 -0
  72. package/assets/characters/55.json +38 -0
  73. package/assets/characters/56.json +38 -0
  74. package/assets/characters/57.json +38 -0
  75. package/assets/characters/58.json +38 -0
  76. package/assets/characters/59.json +38 -0
  77. package/assets/characters/6.json +56 -0
  78. package/assets/characters/60.json +38 -0
  79. package/assets/characters/61.json +38 -0
  80. package/assets/characters/62.json +38 -0
  81. package/assets/characters/63.json +38 -0
  82. package/assets/characters/64.json +38 -0
  83. package/assets/characters/65.json +38 -0
  84. package/assets/characters/66.json +38 -0
  85. package/assets/characters/67.json +38 -0
  86. package/assets/characters/68.json +38 -0
  87. package/assets/characters/69.json +38 -0
  88. package/assets/characters/7.json +64 -0
  89. package/assets/characters/70.json +38 -0
  90. package/assets/characters/71.json +38 -0
  91. package/assets/characters/72.json +38 -0
  92. package/assets/characters/73.json +38 -0
  93. package/assets/characters/74.json +38 -0
  94. package/assets/characters/75.json +38 -0
  95. package/assets/characters/76.json +38 -0
  96. package/assets/characters/77.json +38 -0
  97. package/assets/characters/78.json +38 -0
  98. package/assets/characters/79.json +38 -0
  99. package/assets/characters/8.json +60 -0
  100. package/assets/characters/80.json +38 -0
  101. package/assets/characters/81.json +38 -0
  102. package/assets/characters/82.json +38 -0
  103. package/assets/characters/83.json +38 -0
  104. package/assets/characters/84.json +38 -0
  105. package/assets/characters/85.json +38 -0
  106. package/assets/characters/86.json +38 -0
  107. package/assets/characters/87.json +38 -0
  108. package/assets/characters/88.json +38 -0
  109. package/assets/characters/89.json +38 -0
  110. package/assets/characters/9.json +56 -0
  111. package/assets/characters/90.json +38 -0
  112. package/assets/characters/91.json +38 -0
  113. package/assets/characters/92.json +38 -0
  114. package/assets/characters/93.json +38 -0
  115. package/assets/characters/94.json +38 -0
  116. package/assets/characters/95.json +38 -0
  117. package/assets/characters/96.json +38 -0
  118. package/assets/characters/97.json +38 -0
  119. package/assets/characters/98.json +38 -0
  120. package/assets/characters/99.json +38 -0
  121. package/assets/characters/index.json +584 -0
  122. package/assets/game-config.json +855 -0
  123. package/assets/items.json +1060 -0
  124. package/assets/skills.json +829 -0
  125. package/assets/templates.json +127 -0
  126. package/package.json +80 -0
  127. package/references/agent-handbook.md +308 -0
  128. package/references/game-design.md +124 -0
  129. package/references/player-guide.md +176 -0
  130. package/scripts/config-loader.ts +408 -0
  131. package/scripts/game-engine.ts +617 -0
  132. package/scripts/game-logic.ts +153 -0
  133. package/scripts/game-types.ts +46 -0
  134. package/scripts/install-skill.mjs +115 -0
  135. package/scripts/persistence.ts +135 -0
@@ -0,0 +1,176 @@
1
+ # 金庸群侠传 · 玩家手册
2
+
3
+ 面向新玩家的完整说明。在 Cursor 里用自然语言对话即可游玩,**无需记忆任何指令**。
4
+
5
+ ---
6
+
7
+ ## 30 秒上手
8
+
9
+ 1. 安装 skill:`npm install @sdd330dev/jy-skill --save-dev` 后执行 `npx jy-skill install`(详见 [README.md](../README.md))。
10
+ 2. 在对话中说:**「jy」** 或 **「开始游戏」** —— 有存档则继续,无存档则从小村开始新冒险。
11
+ 3. 用日常中文描述你想做的事,例如「去平安镇」「和村长聊聊」「买把铁剑」「攻击山贼」。
12
+ 4. 进度会在每次操作后**自动保存**,下次再说「jy」即可续玩。
13
+
14
+ 需要帮助时说:**「帮助」**、**「怎么玩」** 或 **「jy 帮助」**。
15
+
16
+ ---
17
+
18
+ ## 新游戏初始状态
19
+
20
+ | 项目 | 内容 |
21
+ |------|------|
22
+ | 起始地点 | 小村 |
23
+ | 银两 | 100 |
24
+ | 武功 | 基本拳法 |
25
+ | 背包 | 金创药 ×5、干粮 ×3 |
26
+ | 生命 / 内力 / 体力 | 满值(随等级提升上限) |
27
+
28
+ ### 属性说明
29
+
30
+ - **生命**:降为 0 则江湖路断,需重新开始。
31
+ - **内力**:施展高级武功时消耗。
32
+ - **体力**:移动与战斗会消耗;可吃干粮恢复。
33
+ - **经验 / 等级**:战斗胜利获得经验,升级后生命、内力、攻防提升。
34
+ - **周数**:每次移动推进一周;中毒、受伤状态下每周会额外掉血。
35
+ - **中毒 / 受伤**:数值越高掉血越多,可用解毒丸或休息缓解。
36
+
37
+ ---
38
+
39
+ ## 你可以说什么
40
+
41
+ | 想做什么 | 示例说法 |
42
+ |----------|----------|
43
+ | 移动 | 去平安镇、前往山洞、随便走走 |
44
+ | 对话 | 和村长聊聊、找商人、拜访王重阳 |
45
+ | 购物 | 买一把铁剑、来瓶金创药、要件皮甲 |
46
+ | 使用物品 | 用金创药、吃干粮 |
47
+ | 装备 | 装备铁剑、穿上布衣 |
48
+ | 学武功 | 学习六脉神剑(需先满足剧情或 NPC 条件) |
49
+ | 战斗 | 攻击山贼、和老虎一战 |
50
+ | 查看信息 | 查看状态、看看背包、我会哪些武功 |
51
+ | 恢复 | 休息 |
52
+ | 帮助 | 帮助、怎么玩、指令说明 |
53
+
54
+ 说「随便走走」时,会随机前往当前地点相连的一个方向。
55
+
56
+ ---
57
+
58
+ ## 世界地图
59
+
60
+ 各地点只能前往**直接相连**的邻居,不能一步跨越大半个江湖。
61
+
62
+ ```
63
+ 华山 桃花岛 全真教 光明顶
64
+ \ | / /
65
+ \ | / /
66
+ 小村 — 平安镇 — (枢纽)
67
+ |
68
+ 山洞
69
+ ```
70
+
71
+ (除小村↔山洞、小村↔平安镇外,各门派均需经平安镇中转。)
72
+
73
+ ### 地点详情
74
+
75
+ | 地点 | 可前往 | NPC | 商店 |
76
+ |------|--------|-----|------|
77
+ | **小村** | 平安镇、山洞 | 村长、商人 | 金创药、小还丹、干粮 |
78
+ | **平安镇** | 小村、华山、桃花岛、全真教、光明顶 | 守卫、商店老板、客栈老板 | 铁剑、钢刀、布衣、皮甲、金创药、小还丹 |
79
+ | **山洞** | 小村 | 神秘人 | 无(探险区域,可能遇敌) |
80
+ | **华山** | 平安镇 | 王重阳 | 无 |
81
+ | **桃花岛** | 平安镇 | 黄药师、黄蓉 | 无 |
82
+ | **全真教** | 平安镇 | 王重阳、周伯通 | 无 |
83
+ | **光明顶** | 平安镇 | 张无忌 | 无 |
84
+
85
+ ### 新人推荐路线
86
+
87
+ 1. **小村**:与村长对话,了解江湖概况;向商人补充药品。
88
+ 2. **平安镇**:购买武器(铁剑/钢刀)和防具(布衣/皮甲),再考虑远行。
89
+ 3. **门派地点**:拜访 NPC,触发对话、学习更高阶武功。
90
+ 4. **山洞**:实力不足时慎入,洞内可能有强敌。
91
+
92
+ ---
93
+
94
+ ## 战斗入门
95
+
96
+ ### 流程
97
+
98
+ 1. 你说要打的敌人(如山贼、老虎),或在进入**山洞**等险地时可能**自动遇敌**(移动后须应战)。
99
+ 2. **你的回合**:普通攻击,或施展已学会的武功。
100
+ 3. **敌人回合**:敌人反击,可能多人轮流出手。
101
+ 4. 重复直至一方全灭或你倒下。
102
+
103
+ ### 要点
104
+
105
+ - **普通攻击**消耗体力,不耗内力。
106
+ - **武功**消耗内力;内力不足时只能用普攻。初学「基本拳法」不耗内力。
107
+ - 可遭遇的敌人包括:**山贼**、**强盗**、**武林高手**、**老虎**、**毒蛇**等;部分敌人可能多人同场。
108
+ - 战胜敌人获得**经验**;经验足够则**升级**,各项能力上升。
109
+ - 装备武器、防具后再战,会明显更轻松。
110
+
111
+ ### 简单策略
112
+
113
+ - 战前确保生命、内力、体力充足;不足则休息或使用背包药品。
114
+ - 银两充裕时在平安镇备足金创药、小还丹。
115
+ - 感觉吃力时先升级、换装备,再挑战强敌。
116
+
117
+ ---
118
+
119
+ ## 生存技巧
120
+
121
+ - **移动**每次消耗 5 点体力,并推进 1 周;规划路线,避免体力耗尽困在途中。
122
+ - **干粮**恢复体力,**金创药**恢复生命,**小还丹**恢复内力;**解毒丸**可解除中毒。生命/内力/体力已满时不会浪费药品。
123
+ - **休息**一次满血满内力满体力,并清除中毒、受伤(在任意地点可用)。
124
+ - 中毒、受伤状态下每移动一周会持续掉血,宜尽快治疗或休息。
125
+ - 死亡后当前存档清除,需从头开始;重要进度请多探索、少鲁莽涉险。
126
+
127
+ ---
128
+
129
+ ## 状态栏说明
130
+
131
+ 每次回复末尾会显示当前状态,例如:
132
+
133
+ ```
134
+ 👤 主角 | Lv.1 | 经验: 0/150
135
+ ❤️ 100/100 | 💠 50/50 | ⚡ 95/100
136
+ 💰 100 | 📍 小村 | 📅 第1周
137
+ ```
138
+
139
+ 若有中毒、受伤,会额外显示 🧪 中毒、💊 受伤 行。
140
+
141
+ ---
142
+
143
+ ## 常见问题
144
+
145
+ **如何继续上次的游戏?**
146
+ 直接再说「jy」或「开始游戏」,会自动读取存档。
147
+
148
+ **如何重新开始?**
149
+ 说「重新开始」「删除存档」或「新游戏」,Agent 会调用 `restartGame()` 清除存档并创建新角色。
150
+
151
+ **满级后经验怎么显示?**
152
+ 达到 Lv.100 后,状态栏显示「经验: N(已满级)」,不再显示升级所需经验。
153
+
154
+ **为什么去不了某个地方?**
155
+ 只能走到与当前地点**相连**的地图。例如在小村不能直接到华山,需先到平安镇。
156
+
157
+ **为什么买不到某样东西?**
158
+ 每个地点的商店货品不同;部分物品只在平安镇出售。银两不足也无法购买。
159
+
160
+ **战斗中途可以逃跑吗?**
161
+ 当前版本以叙述式回合战斗为主,建议战前备好药品;力有不逮时可先休息、补给再来。
162
+
163
+ **需要记命令吗?**
164
+ 不需要。用中文描述意图即可,例如「去平安镇买铁剑」比记固定指令更自然。
165
+
166
+ **存档存在哪?**
167
+ 项目目录下 `save/game-state.json`,自动创建与更新。
168
+
169
+ ---
170
+
171
+ ## 更多资料
172
+
173
+ - 开发者与 Agent 说明:[SKILL.md](../SKILL.md)、[AGENTS.md](../AGENTS.md)、[agent-handbook.md](agent-handbook.md)
174
+ - 设计与公式细节:[game-design.md](game-design.md)
175
+
176
+ 祝你在金庸武侠世界中,闯出一段属于自己的传说。
@@ -0,0 +1,408 @@
1
+ /**
2
+ * 配置加载器 — 从 assets/*.json 单源加载游戏数据
3
+ */
4
+
5
+ import { readFileSync, readdirSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
10
+ const ASSETS_DIR = join(ROOT_DIR, 'assets');
11
+
12
+ // ============================================================================
13
+ // 类型
14
+ // ============================================================================
15
+
16
+ export interface MapInfo {
17
+ npcs: string[];
18
+ shops: string[];
19
+ connections: string[];
20
+ npcDialogs: Record<string, string>;
21
+ encounterRate?: number;
22
+ encounterEnemies?: string[];
23
+ }
24
+
25
+ export interface ItemConfig {
26
+ id: number;
27
+ name: string;
28
+ desc: string;
29
+ type: number;
30
+ equipmentType: number;
31
+ price: number;
32
+ addAttack: number;
33
+ addDefence: number;
34
+ useAddHp: number;
35
+ useAddMp: number;
36
+ useAddStamina: number;
37
+ useDePoison: number;
38
+ }
39
+
40
+ export interface SkillConfig {
41
+ id: number;
42
+ name: string;
43
+ desc: string;
44
+ mpCost: number;
45
+ damageType: number;
46
+ levels: number[][];
47
+ }
48
+
49
+ export interface CharacterConfig {
50
+ id: number;
51
+ name: string;
52
+ maxHp: number;
53
+ attack: number;
54
+ defence: number;
55
+ skills: number[][];
56
+ source: string;
57
+ }
58
+
59
+ export interface DialogConfig {
60
+ id: string;
61
+ speaker: string;
62
+ text: string;
63
+ }
64
+
65
+ export interface EnemyTemplate {
66
+ hp: number;
67
+ attack: number;
68
+ defence: number;
69
+ solo?: boolean;
70
+ }
71
+
72
+ export interface GameTemplates {
73
+ defaultCharacter: {
74
+ name: string;
75
+ skills: string[];
76
+ };
77
+ defaultInventory: {
78
+ silver: number;
79
+ items: Array<{ id: string; name: string; count: number }>;
80
+ };
81
+ startLocation: string;
82
+ enemies: Record<string, EnemyTemplate | { characterId: number }>;
83
+ maps?: Record<
84
+ string,
85
+ {
86
+ npcs: string[];
87
+ shops: string[];
88
+ connections: string[];
89
+ encounters?: number | { rate: number; enemies: string[] };
90
+ }
91
+ >;
92
+ }
93
+
94
+ function parseEncounters(
95
+ encounters?: number | { rate: number; enemies: string[] },
96
+ ): Pick<MapInfo, 'encounterRate' | 'encounterEnemies'> {
97
+ if (encounters == null) return {};
98
+ if (typeof encounters === 'number') {
99
+ return { encounterRate: encounters, encounterEnemies: ['山贼', '强盗'] };
100
+ }
101
+ return { encounterRate: encounters.rate, encounterEnemies: [...encounters.enemies] };
102
+ }
103
+
104
+ // ============================================================================
105
+ // 加载
106
+ // ============================================================================
107
+
108
+ function loadJson<T>(relativePath: string): T {
109
+ const path = join(ASSETS_DIR, relativePath);
110
+ return JSON.parse(readFileSync(path, 'utf-8')) as T;
111
+ }
112
+
113
+ const mapsByName = new Map<string, MapInfo>();
114
+ const itemsByName = new Map<string, ItemConfig>();
115
+ const itemsById = new Map<number, ItemConfig>();
116
+ const skillsByName = new Map<string, SkillConfig>();
117
+ const charactersById = new Map<number, CharacterConfig>();
118
+ const charactersByName = new Map<string, CharacterConfig>();
119
+ const dialogsById = new Map<string, DialogConfig>();
120
+ let templates: GameTemplates;
121
+ let initialized = false;
122
+
123
+ function resolveEnemyTemplate(
124
+ entry: EnemyTemplate | { characterId: number },
125
+ ): EnemyTemplate | undefined {
126
+ if ('characterId' in entry) {
127
+ const char = charactersById.get(entry.characterId);
128
+ if (!char) return undefined;
129
+ return { hp: char.maxHp, attack: char.attack, defence: char.defence };
130
+ }
131
+ return entry;
132
+ }
133
+
134
+ function buildMapsFromConfig(): void {
135
+ const gameConfig = loadJson<{
136
+ maps: Array<{
137
+ id: number;
138
+ name: string;
139
+ npcs: Array<{
140
+ roleId: number;
141
+ dialogId: string;
142
+ isShop: boolean;
143
+ shopItems: number[];
144
+ }>;
145
+ connections: Array<{ targetMapId: number }>;
146
+ }>;
147
+ dialogs: Record<string, DialogConfig>;
148
+ }>('game-config.json');
149
+
150
+ const mapIdToName = new Map<number, string>();
151
+ for (const map of gameConfig.maps) {
152
+ mapIdToName.set(map.id, map.name);
153
+ }
154
+
155
+ for (const [id, dialog] of Object.entries(gameConfig.dialogs)) {
156
+ dialogsById.set(id, dialog);
157
+ }
158
+
159
+ const templateMaps = templates.maps ?? {};
160
+
161
+ for (const map of gameConfig.maps) {
162
+ const templateMap = templateMaps[map.name as keyof typeof templateMaps];
163
+ const npcDialogs: Record<string, string> = {};
164
+ const npcNames: string[] = [];
165
+ const shopNames = new Set<string>();
166
+
167
+ for (const npc of map.npcs) {
168
+ const char = charactersById.get(npc.roleId);
169
+ if (!char) continue;
170
+ npcNames.push(char.name);
171
+ npcDialogs[char.name] = npc.dialogId;
172
+ if (npc.isShop) {
173
+ for (const itemId of npc.shopItems) {
174
+ const item = itemsById.get(itemId);
175
+ if (item) shopNames.add(item.name);
176
+ }
177
+ }
178
+ }
179
+
180
+ const connections =
181
+ templateMap && 'connections' in templateMap
182
+ ? [...templateMap.connections]
183
+ : map.connections
184
+ .map((c) => mapIdToName.get(c.targetMapId))
185
+ .filter((name): name is string => Boolean(name));
186
+
187
+ const shops =
188
+ templateMap && templateMap.shops.length > 0 ? [...templateMap.shops] : [...shopNames];
189
+
190
+ const encounterConfig = parseEncounters(templateMap?.encounters);
191
+
192
+ mapsByName.set(map.name, {
193
+ npcs: templateMap?.npcs?.length ? [...templateMap.npcs] : npcNames,
194
+ shops,
195
+ connections,
196
+ npcDialogs,
197
+ ...encounterConfig,
198
+ });
199
+ }
200
+
201
+ // templates 中有但 game-config 未列出的地图(兜底)
202
+ for (const [name, map] of Object.entries(templateMaps)) {
203
+ if (!mapsByName.has(name)) {
204
+ const encounterConfig = parseEncounters(map.encounters);
205
+ mapsByName.set(name, {
206
+ npcs: [...map.npcs],
207
+ shops: [...map.shops],
208
+ connections: [...map.connections],
209
+ npcDialogs: {},
210
+ ...encounterConfig,
211
+ });
212
+ }
213
+ }
214
+ }
215
+
216
+ function loadCharacters(): void {
217
+ const index = loadJson<{ characters: Array<{ id: number; name: string }> }>(
218
+ 'characters/index.json',
219
+ );
220
+
221
+ for (const info of index.characters) {
222
+ try {
223
+ const data = loadJson<CharacterConfig>(`characters/${info.id}.json`);
224
+ charactersById.set(data.id, data);
225
+ charactersByName.set(data.name, data);
226
+ } catch {
227
+ // 跳过缺失文件,validate-assets 会报告
228
+ }
229
+ }
230
+ }
231
+
232
+ function loadItems(): void {
233
+ const data = loadJson<{ items: ItemConfig[] }>('items.json');
234
+ for (const item of data.items) {
235
+ itemsByName.set(item.name, item);
236
+ itemsById.set(item.id, item);
237
+ }
238
+ }
239
+
240
+ function loadSkills(): void {
241
+ const data = loadJson<{ skills: SkillConfig[] }>('skills.json');
242
+ for (const skill of data.skills) {
243
+ skillsByName.set(skill.name, skill);
244
+ }
245
+ }
246
+
247
+ /** 初始化配置(幂等) */
248
+ export function initConfigs(): void {
249
+ if (initialized) return;
250
+
251
+ templates = loadJson<GameTemplates>('templates.json');
252
+ if (!templates.startLocation) {
253
+ templates.startLocation = '小村';
254
+ }
255
+ if (!templates.enemies) {
256
+ templates.enemies = {
257
+ 山贼: { characterId: 200 },
258
+ 强盗: { characterId: 201 },
259
+ 武林高手: { characterId: 202 },
260
+ 老虎: { hp: 150, attack: 35, defence: 20, solo: true },
261
+ 毒蛇: { hp: 60, attack: 10, defence: 5, solo: true },
262
+ };
263
+ }
264
+
265
+ loadCharacters();
266
+ loadItems();
267
+ loadSkills();
268
+ buildMapsFromConfig();
269
+
270
+ initialized = true;
271
+ }
272
+
273
+ /** 重置缓存(仅测试用) */
274
+ export function resetConfigsForTest(): void {
275
+ initialized = false;
276
+ mapsByName.clear();
277
+ itemsByName.clear();
278
+ itemsById.clear();
279
+ skillsByName.clear();
280
+ charactersById.clear();
281
+ charactersByName.clear();
282
+ dialogsById.clear();
283
+ }
284
+
285
+ // ============================================================================
286
+ // 查询 API
287
+ // ============================================================================
288
+
289
+ export function getTemplates(): GameTemplates {
290
+ initConfigs();
291
+ return templates;
292
+ }
293
+
294
+ export function getMap(name: string): MapInfo | undefined {
295
+ initConfigs();
296
+ return mapsByName.get(name);
297
+ }
298
+
299
+ export function getAllMapNames(): string[] {
300
+ initConfigs();
301
+ return [...mapsByName.keys()];
302
+ }
303
+
304
+ export function getItem(name: string): ItemConfig | undefined {
305
+ initConfigs();
306
+ return itemsByName.get(name);
307
+ }
308
+
309
+ export function getSkill(name: string): SkillConfig | undefined {
310
+ initConfigs();
311
+ return skillsByName.get(name);
312
+ }
313
+
314
+ export function getSkillAttackAtLevel(skillName: string, levelIndex = 0): number {
315
+ const skill = getSkill(skillName);
316
+ if (!skill || !skill.levels.length) return 0;
317
+ const idx = Math.min(levelIndex, skill.levels.length - 1);
318
+ return skill.levels[idx][0] ?? 0;
319
+ }
320
+
321
+ export function getCharacterByName(name: string): CharacterConfig | undefined {
322
+ initConfigs();
323
+ return charactersByName.get(name);
324
+ }
325
+
326
+ export function getDialog(dialogId: string): DialogConfig | undefined {
327
+ initConfigs();
328
+ return dialogsById.get(dialogId);
329
+ }
330
+
331
+ export function getEnemyTemplate(enemyName: string): EnemyTemplate | undefined {
332
+ initConfigs();
333
+ const entry = templates.enemies[enemyName];
334
+ if (!entry) return undefined;
335
+ return resolveEnemyTemplate(entry);
336
+ }
337
+
338
+ export function isWeapon(item: ItemConfig): boolean {
339
+ return item.type === 1 && item.equipmentType >= 0 && item.addAttack > 0;
340
+ }
341
+
342
+ export function isArmor(item: ItemConfig): boolean {
343
+ return item.type === 1 && item.equipmentType >= 0 && item.addDefence > 0;
344
+ }
345
+
346
+ export function isConsumable(item: ItemConfig): boolean {
347
+ return item.useAddHp > 0 || item.useAddMp > 0 || item.useAddStamina > 0 || item.useDePoison > 0;
348
+ }
349
+
350
+ /** 校验 assets 完整性(CLI / CI 用) */
351
+ export function validateAssets(): string[] {
352
+ resetConfigsForTest();
353
+ initConfigs();
354
+
355
+ const errors: string[] = [];
356
+ const index = loadJson<{ characters: Array<{ id: number; name: string }> }>(
357
+ 'characters/index.json',
358
+ );
359
+
360
+ for (const info of index.characters) {
361
+ const path = join(ASSETS_DIR, 'characters', `${info.id}.json`);
362
+ try {
363
+ readFileSync(path, 'utf-8');
364
+ } catch {
365
+ errors.push(`Missing character file: characters/${info.id}.json (${info.name})`);
366
+ }
367
+ }
368
+
369
+ const charFiles = readdirSync(join(ASSETS_DIR, 'characters')).filter((f) =>
370
+ /^\d+\.json$/.test(f),
371
+ );
372
+ const indexIds = new Set(index.characters.map((c) => c.id));
373
+ for (const file of charFiles) {
374
+ const id = Number.parseInt(file.replace('.json', ''), 10);
375
+ if (!indexIds.has(id)) {
376
+ errors.push(`Character file ${file} not listed in characters/index.json`);
377
+ }
378
+ }
379
+
380
+ for (const mapName of getAllMapNames()) {
381
+ const map = getMap(mapName)!;
382
+ for (const shopItem of map.shops) {
383
+ if (!getItem(shopItem)) {
384
+ errors.push(`Map "${mapName}" shop references unknown item: ${shopItem}`);
385
+ }
386
+ }
387
+ for (const conn of map.connections) {
388
+ if (!getMap(conn)) {
389
+ errors.push(`Map "${mapName}" connection target unknown: ${conn}`);
390
+ }
391
+ }
392
+ }
393
+
394
+ for (const enemyName of Object.keys(templates.enemies)) {
395
+ if (!getEnemyTemplate(enemyName)) {
396
+ errors.push(`Enemy template unresolved: ${enemyName}`);
397
+ }
398
+ }
399
+
400
+ for (const skillName of templates.defaultCharacter.skills ?? ['基本拳法']) {
401
+ if (!getSkill(skillName)) {
402
+ errors.push(`Default skill not found in skills.json: ${skillName}`);
403
+ }
404
+ }
405
+
406
+ resetConfigsForTest();
407
+ return errors;
408
+ }