@linxin666/dsh-pet 0.3.2 → 0.3.4

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 (127) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +25 -7
  3. package/README.zh.md +25 -7
  4. package/assets/ouo-neko/pet.json +31 -0
  5. package/assets/ouo-neko/previews/failed.gif +0 -0
  6. package/assets/ouo-neko/previews/idle.gif +0 -0
  7. package/assets/ouo-neko/previews/jumping.gif +0 -0
  8. package/assets/ouo-neko/previews/review.gif +0 -0
  9. package/assets/ouo-neko/previews/running-left.gif +0 -0
  10. package/assets/ouo-neko/previews/running-right.gif +0 -0
  11. package/assets/ouo-neko/previews/running.gif +0 -0
  12. package/assets/ouo-neko/previews/waiting.gif +0 -0
  13. package/assets/ouo-neko/previews/waving.gif +0 -0
  14. package/assets/ouo-neko/spritesheet.webp +0 -0
  15. package/contracts/pet-manifest-v2.schema.json +102 -1
  16. package/lib/client.js +830 -26
  17. package/lib/client.js.map +1 -1
  18. package/lib/index.js +1226 -24
  19. package/lib/types/client/PetDockEntry.d.ts +3 -0
  20. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  21. package/lib/types/client/PetDockEntry.js +18 -2
  22. package/lib/types/client/PetSprite.d.ts +23 -0
  23. package/lib/types/client/PetSprite.d.ts.map +1 -1
  24. package/lib/types/client/PetSprite.js +34 -29
  25. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  26. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  27. package/lib/types/client/PluginSettingsCard.js +2 -2
  28. package/lib/types/client/drag-stream.d.ts +19 -0
  29. package/lib/types/client/drag-stream.d.ts.map +1 -0
  30. package/lib/types/client/drag-stream.js +26 -0
  31. package/lib/types/client/gameplay-hud.d.ts +50 -0
  32. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  33. package/lib/types/client/gameplay-hud.js +274 -0
  34. package/lib/types/client/index.d.ts +1 -1
  35. package/lib/types/client/index.d.ts.map +1 -1
  36. package/lib/types/client/index.js +16 -0
  37. package/lib/types/client/locales.d.ts +34 -0
  38. package/lib/types/client/locales.d.ts.map +1 -1
  39. package/lib/types/client/locales.js +36 -0
  40. package/lib/types/client/pet-store.d.ts +7 -1
  41. package/lib/types/client/pet-store.d.ts.map +1 -1
  42. package/lib/types/client/pet-store.js +4 -0
  43. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  44. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  45. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  46. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  47. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  48. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  49. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  50. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  51. package/lib/types/client/renderers/frames2d.js +196 -0
  52. package/lib/types/client/settings-form.d.ts +1 -1
  53. package/lib/types/client/settings-form.js +1 -1
  54. package/lib/types/client/telemetry.d.ts +27 -0
  55. package/lib/types/client/telemetry.d.ts.map +1 -0
  56. package/lib/types/client/telemetry.js +97 -0
  57. package/lib/types/defaults.d.ts +6 -0
  58. package/lib/types/defaults.d.ts.map +1 -0
  59. package/lib/types/defaults.js +5 -0
  60. package/lib/types/dsh-home.d.ts.map +1 -1
  61. package/lib/types/dsh-home.js +9 -3
  62. package/lib/types/gameplay.d.ts +182 -0
  63. package/lib/types/gameplay.d.ts.map +1 -0
  64. package/lib/types/gameplay.js +618 -0
  65. package/lib/types/http.d.ts +1 -1
  66. package/lib/types/http.js +1 -1
  67. package/lib/types/index.d.ts +1 -1
  68. package/lib/types/index.js +1 -1
  69. package/lib/types/ledger.d.ts +14 -0
  70. package/lib/types/ledger.d.ts.map +1 -1
  71. package/lib/types/ledger.js +30 -0
  72. package/lib/types/loopback.d.ts +1 -1
  73. package/lib/types/loopback.js +1 -1
  74. package/lib/types/manifest-v2.d.ts +38 -1
  75. package/lib/types/manifest-v2.d.ts.map +1 -1
  76. package/lib/types/manifest-v2.js +182 -2
  77. package/lib/types/mount-once.d.ts +1 -1
  78. package/lib/types/mount-once.js +2 -2
  79. package/lib/types/persist.d.ts +4 -4
  80. package/lib/types/persist.d.ts.map +1 -1
  81. package/lib/types/persist.js +41 -4
  82. package/lib/types/registry.d.ts +29 -0
  83. package/lib/types/registry.d.ts.map +1 -1
  84. package/lib/types/registry.js +170 -1
  85. package/lib/types/routes.d.ts.map +1 -1
  86. package/lib/types/routes.js +22 -0
  87. package/lib/types/service.d.ts +56 -0
  88. package/lib/types/service.d.ts.map +1 -1
  89. package/lib/types/service.js +181 -0
  90. package/package.json +6 -3
  91. package/src/client/PetDockEntry.test.tsx +6 -0
  92. package/src/client/PetDockEntry.tsx +32 -1
  93. package/src/client/PetSprite.test.tsx +36 -6
  94. package/src/client/PetSprite.tsx +42 -21
  95. package/src/client/PluginSettingsCard.tsx +4 -2
  96. package/src/client/drag-stream.ts +35 -0
  97. package/src/client/gameplay-hud.test.tsx +338 -0
  98. package/src/client/gameplay-hud.tsx +403 -0
  99. package/src/client/index.ts +23 -2
  100. package/src/client/locales.ts +36 -0
  101. package/src/client/pet-store.ts +10 -1
  102. package/src/client/pet.module.css +192 -29
  103. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  104. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  105. package/src/client/renderers/frames2d.test.ts +137 -0
  106. package/src/client/renderers/frames2d.ts +216 -0
  107. package/src/client/settings-card.module.css +5 -0
  108. package/src/client/settings-form.ts +1 -1
  109. package/src/client/telemetry.ts +105 -0
  110. package/src/defaults.ts +7 -0
  111. package/src/dsh-home.ts +9 -3
  112. package/src/gameplay.test.ts +239 -0
  113. package/src/gameplay.ts +692 -0
  114. package/src/http.ts +1 -1
  115. package/src/index.ts +1 -1
  116. package/src/ledger.test.ts +16 -0
  117. package/src/ledger.ts +31 -0
  118. package/src/loopback.ts +1 -1
  119. package/src/manifest-v2.test.ts +130 -1
  120. package/src/manifest-v2.ts +181 -2
  121. package/src/mount-once.ts +2 -2
  122. package/src/persist.test.ts +3 -0
  123. package/src/persist.ts +43 -6
  124. package/src/registry.test.ts +168 -1
  125. package/src/registry.ts +199 -1
  126. package/src/routes.ts +19 -0
  127. package/src/service.ts +208 -0
@@ -0,0 +1,618 @@
1
+ /**
2
+ * Pet gameplay — the optional manifest 'gameplay' block and its host engine.
3
+ * The block layers an opt-in mini-game over any frames2d pet: decaying stat
4
+ * bars, currencies, a weighted idle director, touch zones, a work loop, a
5
+ * sleep loop, passive income and a shop (issue: miku-pet generalization).
6
+ *
7
+ * Discipline split matches manifest-v2: STRUCTURE is fail-closed (types,
8
+ * ranges, references into stats/tracks); the host engine is pure — every
9
+ * verb takes an explicit clock and rng so tests stay deterministic. Decay,
10
+ * passive income and sleep restore are lazy-settled on read (the treats.ts
11
+ * discipline): the host runs no timers for gameplay.
12
+ * @module @linxin666/dsh-pet/gameplay
13
+ */
14
+ /* ------------------------------------------------------------------ *
15
+ * Manifest parsing (fail-closed structure)
16
+ * ------------------------------------------------------------------ */
17
+ const KEBAB = /^[a-z0-9][a-z0-9-]*$/;
18
+ const MAX_STATS = 16;
19
+ const MAX_ZONES = 8;
20
+ const MAX_BRANCHES = 8;
21
+ const MAX_ACTS = 16;
22
+ const MAX_SHOP_ITEMS = 32;
23
+ const MAX_LOTTERY_TIERS = 16;
24
+ const MAX_PHRASES = 64;
25
+ const PHRASE_MAX_LENGTH = 120;
26
+ const STAT_VALUE_MAX = 1_000_000;
27
+ const CURRENCY_MAX = 9_999_999;
28
+ const KNOWN_GAMEPLAY = new Set(['idleDirector', 'stats', 'hitBox', 'touch', 'work', 'sleep', 'passiveIncome', 'shop', 'dragState', 'dragEndState']);
29
+ const KNOWN_STAT = new Set(['max', 'initial', 'decayPerMinute', 'workingDecayPerMinute', 'idleDecayPerMinute']);
30
+ const KNOWN_ZONE = new Set(['name', 'y0', 'y1', 'branches']);
31
+ const KNOWN_TOUCH = new Set(['zones', 'clickBoost']);
32
+ const KNOWN_BRANCH = new Set(['probability', 'effects', 'state', 'stateMs', 'phrases']);
33
+ const KNOWN_EFFECT = new Set(['stat', 'currency', 'amount']);
34
+ const KNOWN_WORK = new Set(['state', 'successState', 'failState', 'tickMs', 'resultMs', 'successProbability', 'success', 'fail']);
35
+ const KNOWN_SLEEP = new Set(['state', 'wakeState', 'restore']);
36
+ const KNOWN_SHOP_ITEM = new Set(['id', 'label', 'image', 'price', 'currency', 'effects', 'lottery']);
37
+ const KNOWN_LOTTERY = new Set(['effects', 'currency', 'tiers']);
38
+ const KNOWN_IDLE_DIRECTOR = new Set(['intervalMs', 'maxMiss', 'idleWeight', 'acts']);
39
+ const KNOWN_ACT = new Set(['track', 'weight', 'phrases']);
40
+ function isRecord(value) {
41
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
42
+ }
43
+ function unknownKeys(source, known) {
44
+ return Object.keys(source).filter(key => !known.has(key));
45
+ }
46
+ function validName(name, max = 32) {
47
+ return typeof name === 'string' && name.length <= max && KEBAB.test(name);
48
+ }
49
+ function intIn(value, min, max) {
50
+ return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
51
+ }
52
+ function numIn(value, min, max) {
53
+ return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max;
54
+ }
55
+ function parseEffects(raw, field, stats, hooks) {
56
+ if (raw === undefined)
57
+ return undefined;
58
+ if (!Array.isArray(raw) || raw.length === 0) {
59
+ hooks.error(field + ' must be a non-empty array of effects');
60
+ return undefined;
61
+ }
62
+ const effects = [];
63
+ for (const entry of raw) {
64
+ if (!isRecord(entry)) {
65
+ hooks.error(field + ': every effect must be an object');
66
+ continue;
67
+ }
68
+ const extra = unknownKeys(entry, KNOWN_EFFECT);
69
+ if (extra.length > 0)
70
+ hooks.error(field + ': unknown effect field(s) ' + extra.map(k => JSON.stringify(k)).join(', '));
71
+ const hasStat = typeof entry.stat === 'string';
72
+ const hasCurrency = typeof entry.currency === 'string';
73
+ if (hasStat === hasCurrency) {
74
+ hooks.error(field + ': an effect needs exactly one of stat or currency');
75
+ continue;
76
+ }
77
+ if (!intIn(entry.amount, -STAT_VALUE_MAX, STAT_VALUE_MAX) || entry.amount === 0) {
78
+ hooks.error(field + ': effect amount must be a non-zero integer within ±' + STAT_VALUE_MAX);
79
+ continue;
80
+ }
81
+ if (hasStat && stats[entry.stat] === undefined) {
82
+ hooks.error(field + ': effect references undeclared stat ' + JSON.stringify(entry.stat));
83
+ continue;
84
+ }
85
+ if (hasCurrency && !validName(entry.currency, 24)) {
86
+ hooks.error(field + ': effect currency must be a kebab id');
87
+ continue;
88
+ }
89
+ effects.push({
90
+ ...(hasStat ? { stat: entry.stat } : {}),
91
+ ...(hasCurrency ? { currency: entry.currency } : {}),
92
+ amount: entry.amount,
93
+ });
94
+ }
95
+ return effects.length === 0 ? undefined : effects;
96
+ }
97
+ function parsePhrases(raw, field, hooks) {
98
+ if (raw === undefined)
99
+ return undefined;
100
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_PHRASES
101
+ || raw.some(line => typeof line !== 'string' || line.trim() === '' || line.length > PHRASE_MAX_LENGTH)) {
102
+ hooks.error(field + ' must be 1..' + MAX_PHRASES + ' non-empty lines of at most ' + PHRASE_MAX_LENGTH + ' chars');
103
+ return undefined;
104
+ }
105
+ return raw;
106
+ }
107
+ function parseStateRef(raw, field, hooks) {
108
+ if (raw === undefined)
109
+ return undefined;
110
+ if (typeof raw !== 'string' || !hooks.stateNames.has(raw)) {
111
+ hooks.error(field + ' must name a declared frames2d track');
112
+ return undefined;
113
+ }
114
+ return raw;
115
+ }
116
+ /**
117
+ * Validate the manifest 'gameplay' block (fail-closed). Only frames2d pets
118
+ * may declare gameplay today: every state reference checks against the
119
+ * declared track names.
120
+ */
121
+ export function parseGameplayManifest(raw, hooks) {
122
+ const error = (message) => hooks.error(message);
123
+ if (!isRecord(raw)) {
124
+ error('gameplay must be an object');
125
+ return undefined;
126
+ }
127
+ const extra = unknownKeys(raw, KNOWN_GAMEPLAY);
128
+ if (extra.length > 0)
129
+ error('gameplay: unknown field(s) ' + extra.map(k => JSON.stringify(k)).join(', '));
130
+ let failed = false;
131
+ const fail = (message) => { failed = true; error(message); };
132
+ // --- stats (parsed first: effects reference them) ---
133
+ const stats = {};
134
+ if (raw.stats !== undefined) {
135
+ if (!isRecord(raw.stats))
136
+ fail('gameplay.stats must be an object keyed by stat id');
137
+ else {
138
+ const entries = Object.entries(raw.stats);
139
+ if (entries.length > MAX_STATS)
140
+ fail('gameplay.stats declares too many stats (max ' + MAX_STATS + ')');
141
+ for (const [name, value] of entries) {
142
+ if (!validName(name, 24)) {
143
+ fail('gameplay.stats: invalid stat id ' + JSON.stringify(name));
144
+ continue;
145
+ }
146
+ if (!isRecord(value)) {
147
+ fail('gameplay.stats.' + name + ' must be an object');
148
+ continue;
149
+ }
150
+ const statExtra = unknownKeys(value, KNOWN_STAT);
151
+ if (statExtra.length > 0)
152
+ fail('gameplay.stats.' + name + ': unknown field(s) ' + statExtra.map(k => JSON.stringify(k)).join(', '));
153
+ if (!intIn(value.max, 1, STAT_VALUE_MAX)) {
154
+ fail('gameplay.stats.' + name + '.max must be an integer in [1, ' + STAT_VALUE_MAX + ']');
155
+ continue;
156
+ }
157
+ const def = { max: value.max };
158
+ if (value.initial !== undefined) {
159
+ if (!numIn(value.initial, 0, value.max))
160
+ fail('gameplay.stats.' + name + '.initial must be within [0, max]');
161
+ else
162
+ def.initial = value.initial;
163
+ }
164
+ for (const key of ['decayPerMinute', 'workingDecayPerMinute', 'idleDecayPerMinute']) {
165
+ if (value[key] !== undefined) {
166
+ if (!numIn(value[key], 0, 1000))
167
+ fail('gameplay.stats.' + name + '.' + key + ' must be a number in [0, 1000]');
168
+ else
169
+ def[key] = value[key];
170
+ }
171
+ }
172
+ stats[name] = def;
173
+ }
174
+ }
175
+ }
176
+ const block = {};
177
+ if (Object.keys(stats).length > 0)
178
+ block.stats = stats;
179
+ // --- idle director ---
180
+ if (raw.idleDirector !== undefined) {
181
+ if (!isRecord(raw.idleDirector) || !Array.isArray(raw.idleDirector.acts)) {
182
+ fail('gameplay.idleDirector must be an object with an acts array');
183
+ }
184
+ else {
185
+ const d = raw.idleDirector;
186
+ const dExtra = unknownKeys(d, KNOWN_IDLE_DIRECTOR);
187
+ if (dExtra.length > 0)
188
+ fail('gameplay.idleDirector: unknown field(s) ' + dExtra.map(k => JSON.stringify(k)).join(', '));
189
+ if (d.intervalMs !== undefined && !intIn(d.intervalMs, 1000, 60_000))
190
+ fail('gameplay.idleDirector.intervalMs must be an integer in [1000, 60000]');
191
+ if (d.maxMiss !== undefined && !intIn(d.maxMiss, 0, 10))
192
+ fail('gameplay.idleDirector.maxMiss must be an integer in [0, 10]');
193
+ if (d.idleWeight !== undefined && !intIn(d.idleWeight, 0, 10_000))
194
+ fail('gameplay.idleDirector.idleWeight must be an integer in [0, 10000]');
195
+ if (d.acts.length === 0 || d.acts.length > MAX_ACTS)
196
+ fail('gameplay.idleDirector.acts must declare 1..' + MAX_ACTS + ' acts');
197
+ const acts = [];
198
+ for (const act of d.acts) {
199
+ if (!isRecord(act) || !intIn(act.weight, 1, 10_000)) {
200
+ fail('gameplay.idleDirector.acts entries need a weight integer in [1, 10000]');
201
+ continue;
202
+ }
203
+ const aExtra = unknownKeys(act, KNOWN_ACT);
204
+ if (aExtra.length > 0)
205
+ fail('gameplay.idleDirector.acts: unknown field(s) ' + aExtra.map(k => JSON.stringify(k)).join(', '));
206
+ const track = parseStateRef(act.track, 'gameplay.idleDirector.acts.track', hooks);
207
+ if (track === undefined)
208
+ continue;
209
+ const entry = { track, weight: act.weight };
210
+ const phrases = parsePhrases(act.phrases, 'gameplay.idleDirector.acts.phrases', hooks);
211
+ if (phrases !== undefined)
212
+ entry.phrases = phrases;
213
+ acts.push(entry);
214
+ }
215
+ if (acts.length > 0) {
216
+ block.idleDirector = {
217
+ intervalMs: intIn(d.intervalMs, 1000, 60_000) ? d.intervalMs : 5000,
218
+ maxMiss: intIn(d.maxMiss, 0, 10) ? d.maxMiss : 2,
219
+ idleWeight: intIn(d.idleWeight, 0, 10_000) ? d.idleWeight : 0,
220
+ acts,
221
+ };
222
+ }
223
+ }
224
+ }
225
+ // --- hit box ---
226
+ if (raw.hitBox !== undefined) {
227
+ const b = raw.hitBox;
228
+ if (!isRecord(b) || !numIn(b.x0, 0, 1) || !numIn(b.x1, 0, 1) || !numIn(b.y0, 0, 1) || !numIn(b.y1, 0, 1)
229
+ || !(b.x0 < b.x1) || !(b.y0 < b.y1)) {
230
+ fail('gameplay.hitBox must be { x0, y0, x1, y1 } fractions with x0 < x1 and y0 < y1');
231
+ }
232
+ else {
233
+ block.hitBox = { x0: b.x0, y0: b.y0, x1: b.x1, y1: b.y1 };
234
+ }
235
+ }
236
+ // --- touch zones ---
237
+ if (raw.touch !== undefined) {
238
+ if (!isRecord(raw.touch) || !Array.isArray(raw.touch.zones)) {
239
+ fail('gameplay.touch must be an object with a zones array');
240
+ }
241
+ else {
242
+ const tExtra = unknownKeys(raw.touch, KNOWN_TOUCH);
243
+ if (tExtra.length > 0)
244
+ fail('gameplay.touch: unknown field(s) ' + tExtra.map(k => JSON.stringify(k)).join(', '));
245
+ let clickBoost;
246
+ if (raw.touch.clickBoost !== undefined) {
247
+ const cb = raw.touch.clickBoost;
248
+ if (!isRecord(cb) || typeof cb.stat !== 'string' || stats[cb.stat] === undefined
249
+ || !intIn(cb.min, 0, 1000) || !intIn(cb.max, 0, 1000) || cb.min > cb.max) {
250
+ fail('gameplay.touch.clickBoost must be { stat (declared), min, max } integers with 0 <= min <= max <= 1000');
251
+ }
252
+ else
253
+ clickBoost = { stat: cb.stat, min: cb.min, max: cb.max };
254
+ }
255
+ const zones = [];
256
+ if (raw.touch.zones.length === 0 || raw.touch.zones.length > MAX_ZONES)
257
+ fail('gameplay.touch.zones must declare 1..' + MAX_ZONES + ' zones');
258
+ for (const zoneRaw of raw.touch.zones) {
259
+ if (!isRecord(zoneRaw) || !validName(zoneRaw.name) || !numIn(zoneRaw.y0, 0, 1) || !numIn(zoneRaw.y1, 0, 1) || !(zoneRaw.y0 < zoneRaw.y1)) {
260
+ fail('gameplay.touch.zones entries need a kebab name and 0 <= y0 < y1 <= 1');
261
+ continue;
262
+ }
263
+ const zExtra = unknownKeys(zoneRaw, KNOWN_ZONE);
264
+ if (zExtra.length > 0)
265
+ fail('gameplay.touch.' + zoneRaw.name + ': unknown field(s) ' + zExtra.map(k => JSON.stringify(k)).join(', '));
266
+ if (!Array.isArray(zoneRaw.branches) || zoneRaw.branches.length === 0 || zoneRaw.branches.length > MAX_BRANCHES) {
267
+ fail('gameplay.touch.' + zoneRaw.name + '.branches must declare 1..' + MAX_BRANCHES + ' branches');
268
+ continue;
269
+ }
270
+ let probabilitySum = 0;
271
+ const branches = [];
272
+ for (const branchRaw of zoneRaw.branches) {
273
+ if (!isRecord(branchRaw) || !numIn(branchRaw.probability, 0, 1) || branchRaw.probability === 0) {
274
+ fail('gameplay.touch.' + zoneRaw.name + '.branches entries need a probability in (0, 1]');
275
+ continue;
276
+ }
277
+ const bExtra = unknownKeys(branchRaw, KNOWN_BRANCH);
278
+ if (bExtra.length > 0)
279
+ fail('gameplay.touch.' + zoneRaw.name + ': unknown branch field(s) ' + bExtra.map(k => JSON.stringify(k)).join(', '));
280
+ probabilitySum += branchRaw.probability;
281
+ const branch = { probability: branchRaw.probability };
282
+ const effects = parseEffects(branchRaw.effects, 'gameplay.touch.' + zoneRaw.name + '.effects', stats, hooks);
283
+ if (effects !== undefined)
284
+ branch.effects = effects;
285
+ const state = parseStateRef(branchRaw.state, 'gameplay.touch.' + zoneRaw.name + '.state', hooks);
286
+ if (state !== undefined)
287
+ branch.state = state;
288
+ if (branchRaw.stateMs !== undefined) {
289
+ if (!intIn(branchRaw.stateMs, 200, 10_000))
290
+ fail('gameplay.touch.' + zoneRaw.name + '.stateMs must be an integer in [200, 10000]');
291
+ else
292
+ branch.stateMs = branchRaw.stateMs;
293
+ }
294
+ const phrases = parsePhrases(branchRaw.phrases, 'gameplay.touch.' + zoneRaw.name + '.phrases', hooks);
295
+ if (phrases !== undefined)
296
+ branch.phrases = phrases;
297
+ branches.push(branch);
298
+ }
299
+ if (probabilitySum > 1 + 1e-9)
300
+ fail('gameplay.touch.' + zoneRaw.name + ': branch probabilities must sum to at most 1');
301
+ if (branches.length > 0)
302
+ zones.push({ name: zoneRaw.name, y0: zoneRaw.y0, y1: zoneRaw.y1, branches });
303
+ }
304
+ if (zones.length > 0 || clickBoost !== undefined)
305
+ block.touch = { zones, ...(clickBoost === undefined ? {} : { clickBoost }) };
306
+ }
307
+ }
308
+ // --- work ---
309
+ if (raw.work !== undefined) {
310
+ const w = raw.work;
311
+ if (!isRecord(w))
312
+ fail('gameplay.work must be an object');
313
+ else {
314
+ const wExtra = unknownKeys(w, KNOWN_WORK);
315
+ if (wExtra.length > 0)
316
+ fail('gameplay.work: unknown field(s) ' + wExtra.map(k => JSON.stringify(k)).join(', '));
317
+ const state = parseStateRef(w.state, 'gameplay.work.state', hooks);
318
+ const successState = parseStateRef(w.successState, 'gameplay.work.successState', hooks);
319
+ const failState = parseStateRef(w.failState, 'gameplay.work.failState', hooks);
320
+ if (!intIn(w.tickMs, 1000, 60_000))
321
+ fail('gameplay.work.tickMs must be an integer in [1000, 60000]');
322
+ if (!numIn(w.successProbability, 0, 1))
323
+ fail('gameplay.work.successProbability must be a number in [0, 1]');
324
+ if (state !== undefined && successState !== undefined && failState !== undefined
325
+ && intIn(w.tickMs, 1000, 60_000) && numIn(w.successProbability, 0, 1)) {
326
+ const work = {
327
+ state, successState, failState,
328
+ tickMs: w.tickMs,
329
+ successProbability: w.successProbability,
330
+ };
331
+ if (w.resultMs !== undefined) {
332
+ if (!isRecord(w.resultMs) || !intIn(w.resultMs.success, 200, 10_000) || !intIn(w.resultMs.fail, 200, 10_000)) {
333
+ fail('gameplay.work.resultMs must be { success, fail } integers in [200, 10000]');
334
+ }
335
+ else
336
+ work.resultMs = { success: w.resultMs.success, fail: w.resultMs.fail };
337
+ }
338
+ for (const key of ['success', 'fail']) {
339
+ if (w[key] !== undefined) {
340
+ if (!isRecord(w[key]))
341
+ fail('gameplay.work.' + key + ' must be an object { effects }');
342
+ else {
343
+ const effects = parseEffects(w[key].effects, 'gameplay.work.' + key + '.effects', stats, hooks);
344
+ if (effects !== undefined)
345
+ work[key] = { effects };
346
+ }
347
+ }
348
+ }
349
+ block.work = work;
350
+ }
351
+ }
352
+ }
353
+ // --- sleep ---
354
+ if (raw.sleep !== undefined) {
355
+ const s = raw.sleep;
356
+ if (!isRecord(s) || !isRecord(s.restore))
357
+ fail('gameplay.sleep must be an object with a restore block');
358
+ else {
359
+ const sExtra = unknownKeys(s, KNOWN_SLEEP);
360
+ if (sExtra.length > 0)
361
+ fail('gameplay.sleep: unknown field(s) ' + sExtra.map(k => JSON.stringify(k)).join(', '));
362
+ const state = parseStateRef(s.state, 'gameplay.sleep.state', hooks);
363
+ const wakeState = parseStateRef(s.wakeState, 'gameplay.sleep.wakeState', hooks);
364
+ const restoreStat = typeof s.restore.stat === 'string' && stats[s.restore.stat] !== undefined ? s.restore.stat : undefined;
365
+ if (restoreStat === undefined)
366
+ fail('gameplay.sleep.restore.stat must reference a declared stat');
367
+ if (!intIn(s.restore.amount, 1, 1000))
368
+ fail('gameplay.sleep.restore.amount must be an integer in [1, 1000]');
369
+ if (!intIn(s.restore.intervalMs, 1000, 600_000))
370
+ fail('gameplay.sleep.restore.intervalMs must be an integer in [1000, 600000]');
371
+ if (state !== undefined && restoreStat !== undefined && intIn(s.restore.amount, 1, 1000) && intIn(s.restore.intervalMs, 1000, 600_000)) {
372
+ block.sleep = {
373
+ state,
374
+ ...(wakeState === undefined ? {} : { wakeState }),
375
+ restore: { stat: restoreStat, amount: s.restore.amount, intervalMs: s.restore.intervalMs },
376
+ };
377
+ }
378
+ }
379
+ }
380
+ // --- passive income ---
381
+ if (raw.passiveIncome !== undefined) {
382
+ const p = raw.passiveIncome;
383
+ if (!isRecord(p) || !validName(p.currency, 24) || !intIn(p.amount, 1, 10_000) || !intIn(p.intervalMs, 1000, 86_400_000)) {
384
+ fail('gameplay.passiveIncome must be { currency (kebab), amount 1..10000, intervalMs 1000..86400000 }');
385
+ }
386
+ else {
387
+ block.passiveIncome = { currency: p.currency, amount: p.amount, intervalMs: p.intervalMs };
388
+ }
389
+ }
390
+ // --- shop ---
391
+ if (raw.shop !== undefined) {
392
+ const s = raw.shop;
393
+ if (!isRecord(s) || !Array.isArray(s.items) || s.items.length === 0 || s.items.length > MAX_SHOP_ITEMS) {
394
+ fail('gameplay.shop must be an object with 1..' + MAX_SHOP_ITEMS + ' items');
395
+ }
396
+ else {
397
+ const shopState = parseStateRef(s.state, 'gameplay.shop.state', hooks);
398
+ const items = [];
399
+ const seen = new Set();
400
+ for (const itemRaw of s.items) {
401
+ if (!isRecord(itemRaw) || !validName(itemRaw.id, 24)) {
402
+ fail('gameplay.shop.items entries need a kebab id');
403
+ continue;
404
+ }
405
+ if (seen.has(itemRaw.id)) {
406
+ fail('gameplay.shop: duplicate item id ' + JSON.stringify(itemRaw.id));
407
+ continue;
408
+ }
409
+ seen.add(itemRaw.id);
410
+ const iExtra = unknownKeys(itemRaw, KNOWN_SHOP_ITEM);
411
+ if (iExtra.length > 0)
412
+ fail('gameplay.shop.' + itemRaw.id + ': unknown field(s) ' + iExtra.map(k => JSON.stringify(k)).join(', '));
413
+ if (typeof itemRaw.label !== 'string' || itemRaw.label.trim() === '' || itemRaw.label.length > 80) {
414
+ fail('gameplay.shop.' + itemRaw.id + '.label must be a non-empty string of at most 80 chars');
415
+ continue;
416
+ }
417
+ if (!intIn(itemRaw.price, 1, 1_000_000)) {
418
+ fail('gameplay.shop.' + itemRaw.id + '.price must be an integer in [1, 1000000]');
419
+ continue;
420
+ }
421
+ if (!validName(itemRaw.currency, 24)) {
422
+ fail('gameplay.shop.' + itemRaw.id + '.currency must be a kebab id');
423
+ continue;
424
+ }
425
+ const item = {
426
+ id: itemRaw.id,
427
+ label: itemRaw.label.trim(),
428
+ price: itemRaw.price,
429
+ currency: itemRaw.currency,
430
+ };
431
+ if (itemRaw.image !== undefined) {
432
+ if (typeof itemRaw.image !== 'string' || itemRaw.image.includes('..') || itemRaw.image.includes('\\') || itemRaw.image.startsWith('/')) {
433
+ fail('gameplay.shop.' + itemRaw.id + '.image must be a safe manifest-relative frame path');
434
+ }
435
+ else
436
+ item.image = itemRaw.image;
437
+ }
438
+ const effects = parseEffects(itemRaw.effects, 'gameplay.shop.' + itemRaw.id + '.effects', stats, hooks);
439
+ if (effects !== undefined)
440
+ item.effects = effects;
441
+ if (itemRaw.lottery !== undefined) {
442
+ const l = itemRaw.lottery;
443
+ if (!isRecord(l) || !Array.isArray(l.tiers) || l.tiers.length === 0 || l.tiers.length > MAX_LOTTERY_TIERS) {
444
+ fail('gameplay.shop.' + itemRaw.id + '.lottery needs 1..' + MAX_LOTTERY_TIERS + ' tiers');
445
+ }
446
+ else {
447
+ const lExtra = unknownKeys(l, KNOWN_LOTTERY);
448
+ if (lExtra.length > 0)
449
+ fail('gameplay.shop.' + itemRaw.id + '.lottery: unknown field(s) ' + lExtra.map(k => JSON.stringify(k)).join(', '));
450
+ if (l.currency !== undefined && !validName(l.currency, 24))
451
+ fail('gameplay.shop.' + itemRaw.id + '.lottery.currency must be a kebab id');
452
+ let tierSum = 0;
453
+ const tiers = [];
454
+ for (const tierRaw of l.tiers) {
455
+ if (!isRecord(tierRaw) || !numIn(tierRaw.probability, 0, 1) || tierRaw.probability === 0
456
+ || !intIn(tierRaw.prize, 0, 1_000_000_000)) {
457
+ fail('gameplay.shop.' + itemRaw.id + '.lottery.tiers entries need probability (0,1] and prize 0..1e9');
458
+ continue;
459
+ }
460
+ tierSum += tierRaw.probability;
461
+ const tier = {
462
+ probability: tierRaw.probability,
463
+ prize: tierRaw.prize,
464
+ };
465
+ if (tierRaw.currency !== undefined) {
466
+ if (!validName(tierRaw.currency, 24))
467
+ fail('gameplay.shop.' + itemRaw.id + '.lottery tier currency must be a kebab id');
468
+ else
469
+ tier.currency = tierRaw.currency;
470
+ }
471
+ tiers.push(tier);
472
+ }
473
+ if (tierSum > 1 + 1e-9)
474
+ fail('gameplay.shop.' + itemRaw.id + '.lottery tier probabilities must sum to at most 1');
475
+ if (tiers.length > 0) {
476
+ const lotteryEffects = parseEffects(l.effects, 'gameplay.shop.' + itemRaw.id + '.lottery.effects', stats, hooks);
477
+ item.lottery = {
478
+ tiers,
479
+ ...(lotteryEffects === undefined ? {} : { effects: lotteryEffects }),
480
+ ...(validName(l.currency, 24) ? { currency: l.currency } : {}),
481
+ };
482
+ }
483
+ }
484
+ }
485
+ if (item.effects === undefined && item.lottery === undefined) {
486
+ fail('gameplay.shop.' + itemRaw.id + ' needs effects or a lottery');
487
+ continue;
488
+ }
489
+ items.push(item);
490
+ }
491
+ if (items.length > 0)
492
+ block.shop = { ...(shopState === undefined ? {} : { state: shopState }), items };
493
+ }
494
+ }
495
+ // --- drag state ---
496
+ if (raw.dragState !== undefined) {
497
+ const state = parseStateRef(raw.dragState, 'gameplay.dragState', hooks);
498
+ if (state !== undefined)
499
+ block.dragState = state;
500
+ }
501
+ if (raw.dragEndState !== undefined) {
502
+ const state = parseStateRef(raw.dragEndState, 'gameplay.dragEndState', hooks);
503
+ if (state !== undefined)
504
+ block.dragEndState = state;
505
+ }
506
+ return failed ? undefined : block;
507
+ }
508
+ /** Fresh state for one pet: stats at their initial (default max), no currency. */
509
+ export function initialGameplayState(manifest, now) {
510
+ const stats = {};
511
+ for (const [name, def] of Object.entries(manifest.stats ?? {})) {
512
+ stats[name] = def.initial ?? def.max;
513
+ }
514
+ return { stats, currencies: {}, mode: null, settledAt: now };
515
+ }
516
+ /** Clamp one stat value into [0, max]; currencies into [0, CURRENCY_MAX]. */
517
+ export function clampGameplay(state, manifest) {
518
+ for (const [name, def] of Object.entries(manifest.stats ?? {})) {
519
+ const value = state.stats[name];
520
+ if (value === undefined)
521
+ state.stats[name] = def.initial ?? def.max;
522
+ else
523
+ state.stats[name] = Math.min(def.max, Math.max(0, value));
524
+ }
525
+ for (const [name, value] of Object.entries(state.currencies)) {
526
+ state.currencies[name] = Math.min(CURRENCY_MAX, Math.max(0, Math.floor(value)));
527
+ }
528
+ }
529
+ /**
530
+ * Lazy settle: apply stat decay, passive income and sleep restore for the
531
+ * elapsed wall time since the last settle. Mirrors the treats.ts discipline
532
+ * (no host timers; read paths settle). Returns whether anything changed.
533
+ */
534
+ export function settleGameplay(state, manifest, now, options) {
535
+ const elapsedMs = now - state.settledAt;
536
+ if (elapsedMs <= 0)
537
+ return false;
538
+ const minutes = elapsedMs / 60_000;
539
+ let changed = false;
540
+ for (const [name, def] of Object.entries(manifest.stats ?? {})) {
541
+ const current = state.stats[name];
542
+ if (current === undefined || current <= 0)
543
+ continue;
544
+ let rate = def.decayPerMinute ?? 0;
545
+ if (state.mode === 'work' && def.workingDecayPerMinute !== undefined)
546
+ rate = def.workingDecayPerMinute;
547
+ if (!options.sessionActive)
548
+ rate += def.idleDecayPerMinute ?? 0;
549
+ if (rate <= 0)
550
+ continue;
551
+ const next = Math.max(0, current - rate * minutes);
552
+ if (next !== current) {
553
+ state.stats[name] = next;
554
+ changed = true;
555
+ }
556
+ }
557
+ if (manifest.passiveIncome !== undefined) {
558
+ const ticks = Math.floor(elapsedMs / manifest.passiveIncome.intervalMs);
559
+ if (ticks > 0) {
560
+ const currency = manifest.passiveIncome.currency;
561
+ state.currencies[currency] = (state.currencies[currency] ?? 0) + ticks * manifest.passiveIncome.amount;
562
+ changed = true;
563
+ }
564
+ }
565
+ if (state.mode === 'sleep' && manifest.sleep !== undefined) {
566
+ const ticks = Math.floor(elapsedMs / manifest.sleep.restore.intervalMs);
567
+ if (ticks > 0) {
568
+ const stat = manifest.sleep.restore.stat;
569
+ state.stats[stat] = (state.stats[stat] ?? 0) + ticks * manifest.sleep.restore.amount;
570
+ changed = true;
571
+ }
572
+ }
573
+ state.settledAt = now;
574
+ clampGameplay(state, manifest);
575
+ return changed;
576
+ }
577
+ /** Apply one effect vector (touch/work/shop), clamped. */
578
+ export function applyGameplayEffects(state, manifest, effects) {
579
+ for (const effect of effects) {
580
+ if (effect.stat !== undefined) {
581
+ state.stats[effect.stat] = (state.stats[effect.stat] ?? 0) + effect.amount;
582
+ }
583
+ else if (effect.currency !== undefined) {
584
+ state.currencies[effect.currency] = (state.currencies[effect.currency] ?? 0) + effect.amount;
585
+ }
586
+ }
587
+ clampGameplay(state, manifest);
588
+ }
589
+ /** Roll one touch zone branch; undefined when the roll lands in no-op mass. */
590
+ export function rollTouchBranch(zone, rng) {
591
+ const roll = rng();
592
+ let acc = 0;
593
+ for (const branch of zone.branches) {
594
+ acc += branch.probability;
595
+ if (roll < acc)
596
+ return branch;
597
+ }
598
+ return undefined;
599
+ }
600
+ /** Roll one work tick outcome. */
601
+ export function rollWorkOutcome(work, rng) {
602
+ return rng() < work.successProbability ? 'success' : 'fail';
603
+ }
604
+ /** Draw one lottery prize tier; uncovered mass falls through to the last tier. */
605
+ export function drawLotteryTier(lottery, rng) {
606
+ const roll = rng();
607
+ let acc = 0;
608
+ for (const tier of lottery.tiers) {
609
+ acc += tier.probability;
610
+ if (roll < acc)
611
+ return tier;
612
+ }
613
+ return lottery.tiers[lottery.tiers.length - 1];
614
+ }
615
+ /** The zone one normalized hit-box point lands in, if any. */
616
+ export function touchZoneAt(touch, yFraction) {
617
+ return touch.zones.find(zone => yFraction >= zone.y0 && yFraction < zone.y1);
618
+ }
@@ -10,7 +10,7 @@
10
10
  * edit this shared source and re-run the sync instead of editing a copy.
11
11
  * Consumer code is migrated onto it in follow-up waves; no call site changes
12
12
  * belong in the same change as its introduction.
13
- * @module dsh-web-ui-shared/host/http
13
+ * @module dsh-web-shared/host/http
14
14
  */
15
15
  import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http';
16
16
  /**
package/lib/types/http.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * edit this shared source and re-run the sync instead of editing a copy.
12
12
  * Consumer code is migrated onto it in follow-up waves; no call site changes
13
13
  * belong in the same change as its introduction.
14
- * @module dsh-web-ui-shared/host/http
14
+ * @module dsh-web-shared/host/http
15
15
  */
16
16
  /** Default body cap for readJsonBody: 64 KiB. */
17
17
  const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024;
@@ -6,7 +6,7 @@
6
6
  * the package assets, the hatch-pet custom pets directory, and composed
7
7
  * config entries; adding a pet means dropping a manifest + atlas into one of
8
8
  * those sources, never touching host or client code. Install via
9
- * 'dsh plugin --profile web add link:<dsh-web-ui>/packages/dsh-pet'; the
9
+ * 'dsh plugin --profile web add link:<dsh-web>/packages/dsh-pet'; the
10
10
  * cordis.patch.yml inserts this plugin row.
11
11
  * @module @linxin666/dsh-pet
12
12
  */
@@ -6,7 +6,7 @@
6
6
  * the package assets, the hatch-pet custom pets directory, and composed
7
7
  * config entries; adding a pet means dropping a manifest + atlas into one of
8
8
  * those sources, never touching host or client code. Install via
9
- * 'dsh plugin --profile web add link:<dsh-web-ui>/packages/dsh-pet'; the
9
+ * 'dsh plugin --profile web add link:<dsh-web>/packages/dsh-pet'; the
10
10
  * cordis.patch.yml inserts this plugin row.
11
11
  * @module @linxin666/dsh-pet
12
12
  */