@arken/seer-protocol 0.1.0 → 0.1.1

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 (41) hide show
  1. package/{src/modules/evolution → evolution}/evolution.models.ts +1 -1
  2. package/{src/modules/evolution → evolution}/evolution.router.ts +9 -9
  3. package/evolution/evolution.schema.ts +1 -0
  4. package/{src/modules/evolution → evolution}/evolution.types.ts +1 -1
  5. package/{src/index.ts → index.ts} +2 -2
  6. package/{src/modules/infinite → infinite}/infinite.models.ts +1 -1
  7. package/{src/modules/infinite → infinite}/infinite.router.ts +9 -9
  8. package/infinite/infinite.schema.ts +1 -0
  9. package/{src/modules/infinite → infinite}/infinite.types.ts +1 -1
  10. package/{src/modules/isles → isles}/isles.models.ts +1 -1
  11. package/{src/modules/isles → isles}/isles.router.ts +9 -9
  12. package/isles/isles.schema.ts +1 -0
  13. package/{src/modules/isles → isles}/isles.types.ts +1 -1
  14. package/{src/modules/oasis → oasis}/oasis.models.ts +1 -1
  15. package/{src/modules/oasis → oasis}/oasis.router.ts +2 -2
  16. package/oasis/oasis.schema.ts +1 -0
  17. package/{src/modules/oasis → oasis}/oasis.types.ts +1 -1
  18. package/package.json +3 -12
  19. package/{src/router.ts → router.ts} +23 -23
  20. package/trek/trek.models.ts +1 -0
  21. package/{src/modules/trek → trek}/trek.router.ts +1 -1
  22. package/trek/trek.schema.ts +1 -0
  23. package/trek/trek.types.ts +1 -0
  24. package/src/modules/evolution/evolution.schema.ts +0 -1
  25. package/src/modules/evolution/evolution.service.ts +0 -2000
  26. package/src/modules/infinite/infinite.schema.ts +0 -1
  27. package/src/modules/infinite/infinite.service.ts +0 -40
  28. package/src/modules/isles/isles.schema.ts +0 -1
  29. package/src/modules/isles/isles.service.ts +0 -40
  30. package/src/modules/oasis/oasis.schema.ts +0 -1
  31. package/src/modules/oasis/oasis.service.ts +0 -38
  32. package/src/modules/trek/trek.models.ts +0 -1
  33. package/src/modules/trek/trek.schema.ts +0 -1
  34. package/src/modules/trek/trek.service.ts +0 -1031
  35. package/src/modules/trek/trek.types.ts +0 -1
  36. /package/{src/modules/evolution → evolution}/index.ts +0 -0
  37. /package/{src/modules/infinite → infinite}/index.ts +0 -0
  38. /package/{src/modules/isles → isles}/index.ts +0 -0
  39. /package/{src/modules/oasis → oasis}/index.ts +0 -0
  40. /package/{src/modules/trek → trek}/index.ts +0 -0
  41. /package/{src/types.ts → types.ts} +0 -0
@@ -1,1031 +0,0 @@
1
- // arken/packages/seer/packages/protocol/src/modules/trek/trek.service.ts
2
- //
3
-
4
- import crypto from 'crypto';
5
- import get from 'lodash/get';
6
- import set from 'lodash/set';
7
-
8
- import type { RouterContext } from '../../types';
9
- import type { PatchOp, EntityPatch } from '@arken/node/types';
10
- import { applyPatchesWithInventoryViaMail, claimMailMessage } from '@arken/node/modules/core/mail/applyPatchesOrMail';
11
- import type { MailReward, MailEffect } from '@arken/node/modules/core/mail/applyPatchesOrMail';
12
-
13
- /**
14
- * Trek Service (server-authoritative)
15
- *
16
- * UI contract:
17
- * - getState returns a "UI shaped" object:
18
- * { stopIndex, status, canAdvance, feed, choices, stats }
19
- *
20
- * - nextStop:
21
- * - only generates an OPEN node if there is no open node
22
- *
23
- * - choose:
24
- * - applies effects for a choice on the OPEN node
25
- * - closes the node (openNodeId -> null)
26
- *
27
- * IMPORTANT UI invariant:
28
- * - Nodes MUST NOT contain a "Next Stop" choice.
29
- * "Next Stop" is a global UI button that exists ONLY when there is no open node.
30
- *
31
- * Inventory sync:
32
- * - Trek does NOT own inventory syncing anymore.
33
- * - When choices apply inventory effects, Trek emits a standard "syncCharacterInventory" event.
34
- */
35
-
36
- // -------------------------------
37
- // Inventory Sync Standard (shared contract)
38
- // -------------------------------
39
- export type InventorySyncOp =
40
- | { op: 'add'; itemKey: string; quantity?: number }
41
- | { op: 'remove'; itemKey: string; quantity?: number };
42
-
43
- export type SyncCharacterInventoryPayload =
44
- | {
45
- characterId: string;
46
- mode: 'patch';
47
- ops: InventorySyncOp[];
48
- reason?: string;
49
- source?: string;
50
- }
51
- | {
52
- characterId: string;
53
- mode: 'refresh';
54
- reason?: string;
55
- source?: string;
56
- };
57
-
58
- // ✅ Sprites reward
59
- const SPRITES_ITEM_KEY = 'sprites';
60
- const SPRITES_ITEM_NAME = 'Sprites';
61
-
62
- async function ensureItemByKey(ctx: RouterContext, itemKey: string, name?: string) {
63
- const Item = (ctx.app as any)?.model?.Item;
64
- if (!Item) return null;
65
-
66
- const found = await Item.findOne?.({ key: itemKey })?.exec?.();
67
- if (found) return found;
68
-
69
- // best-effort create / upsert (schema may require more fields)
70
- try {
71
- // Prefer upsert so repeated calls don't race
72
- const res = await Item.findOneAndUpdate?.(
73
- { key: itemKey },
74
- {
75
- $setOnInsert: {
76
- key: itemKey,
77
- name: name || itemKey,
78
- status: 'Active',
79
- meta: { name: name || itemKey },
80
- },
81
- },
82
- { new: true, upsert: true }
83
- )?.exec?.();
84
-
85
- return res || (await Item.findOne?.({ key: itemKey })?.exec?.());
86
- } catch (e) {
87
- console.warn('[ensureItemByKey] failed to create item', itemKey, e);
88
- return null;
89
- }
90
- }
91
-
92
- // -------------------------------
93
- // Hardcoded “DB records” (for now)
94
- // -------------------------------
95
- const TREK_DEFS = {
96
- 'trek.default': {
97
- key: 'trek.default',
98
- name: 'Trek',
99
- nodeTypeWeights: [
100
- { w: 55, v: 'dialog' },
101
- { w: 20, v: 'reward' },
102
- { w: 10, v: 'npc' },
103
- { w: 10, v: 'battle' },
104
- { w: 5, v: 'minigame' },
105
- ],
106
- grantableItems: ['runic-bag'],
107
- },
108
- } as const;
109
-
110
- type TrekDef = (typeof TREK_DEFS)[keyof typeof TREK_DEFS];
111
-
112
- // -------------------------------
113
- // Types (Run storage)
114
- // -------------------------------
115
- export type TrekNode = {
116
- id: string;
117
- createdDate: string;
118
- nodeType: string;
119
- presentation: {
120
- title?: string;
121
- text: string;
122
- npc?: { id?: string; name?: string; tags?: string[] };
123
- };
124
- choices: Array<{
125
- id: string;
126
- label: string;
127
- effects: EntityPatch[];
128
- opens?: { kind: string; refId: string; data?: any };
129
- tags?: string[];
130
- }>;
131
- tags?: string[];
132
- };
133
-
134
- // ✅ UI-friendly effect/reward payload (for rendering tiles)
135
- export type TrekUiReward =
136
- | { type: 'item'; id: string; quantity?: number }
137
- | { type: 'reward'; id?: string; label?: string; meta?: any; quantity?: number };
138
-
139
- export type TrekUiEffect =
140
- | { type: 'stat'; key: string; delta: number }
141
- | { type: 'flag'; key: string; value: any }
142
- | { type: 'buff'; key: string }
143
- | { type: 'debuff'; key: string }
144
- | { type: 'effect'; key?: string; label?: string; meta?: any };
145
-
146
- export type TrekHistoryEntry = {
147
- nodeId: string;
148
- at: string;
149
- chosenChoiceId?: string;
150
- rewards?: TrekUiReward[];
151
- effects?: TrekUiEffect[];
152
- };
153
-
154
- export type TrekRun = {
155
- id: string;
156
- defKey: string;
157
- seed: string;
158
-
159
- // ✅ seeded cursor (persists across thousands of stops)
160
- rngState: number;
161
-
162
- stepIndex: number;
163
- openNodeId?: string | null;
164
-
165
- // ✅ anti-spam: server-authoritative busy window
166
- busyUntil?: string | null;
167
-
168
- nodes: Record<string, TrekNode>;
169
- history: TrekHistoryEntry[];
170
- };
171
-
172
- export type TrekUiChoice = {
173
- id: string;
174
- label: string;
175
- enabled: boolean;
176
-
177
- // ✅ needed so UI can call choose(runId, nodeId, choiceId)
178
- nodeId: string;
179
-
180
- tags?: string[];
181
- };
182
-
183
- export type TrekUiState = {
184
- stopIndex: number;
185
- status: 'READY' | 'AWAIT_CHOICE' | 'BUSY';
186
- canAdvance: boolean;
187
- feed: TrekUiFeedItem[];
188
- choices: TrekUiChoice[];
189
- busyMs?: number;
190
- stats?: { warmth: number; supplies: number; morale: number };
191
- };
192
-
193
- // -------------------------------
194
- // Types (UI state shape)
195
- // -------------------------------
196
- export type TrekUiFeedItem = {
197
- id: string;
198
- kind: 'dialog' | 'reward' | 'npc' | 'battle' | 'minigame' | 'system';
199
- tone?: string;
200
- title?: string;
201
- text: string;
202
- nodeId?: string;
203
- choiceId?: string;
204
- createdDate?: string;
205
-
206
- // ✅ drives your EffectGrid
207
- rewards?: TrekUiReward[];
208
- effects?: TrekUiEffect[];
209
- };
210
-
211
- export type TrekGetStateInput = {
212
- metaverseId?: string;
213
- trekId?: string; // maps to defKey
214
- defKey?: string; // allow direct usage too
215
- characterId?: string;
216
- };
217
-
218
- export type TrekNextStopInput = {
219
- defKey?: string;
220
- characterId?: string;
221
- };
222
-
223
- export type TrekChooseInput = {
224
- runId: string;
225
- nodeId: string;
226
- choiceId: string;
227
- characterId?: string;
228
- };
229
-
230
- export type TrekGetStateResult = {
231
- runId: string;
232
- state: TrekUiState;
233
- };
234
-
235
- export type TrekNextStopResult = {
236
- runId: string;
237
- openNodeId: string | null;
238
- run: TrekRun;
239
- state: TrekUiState;
240
- };
241
-
242
- export type TrekChooseResult = {
243
- runId: string;
244
- state: TrekUiState;
245
- };
246
-
247
- function resolveActiveCharacter(profile: any, inputCharacterId?: string) {
248
- const chars = Array.isArray(profile?.characters) ? profile.characters : [];
249
-
250
- // 1) explicit input wins
251
- if (inputCharacterId) {
252
- const hit = chars.find((c: any) => String(c?.id ?? c?._id) === String(inputCharacterId));
253
- if (hit) return hit;
254
- }
255
-
256
- // 2) profile.data.activeCharacterId
257
- const activeId = profile?.data?.activeCharacterId ? String(profile.data.activeCharacterId) : null;
258
- if (activeId) {
259
- const hit = chars.find((c: any) => String(c?.id ?? c?._id) === activeId);
260
- if (hit) return hit;
261
- }
262
-
263
- // 3) fallback
264
- return chars[0] ?? null;
265
- }
266
- // -------------------------------
267
- // Helpers: time, ids, rng, weighted
268
- // -------------------------------
269
- function nowIso() {
270
- return new Date().toISOString();
271
- }
272
- function newId(prefix: string) {
273
- return `${prefix}_${crypto.randomBytes(8).toString('hex')}`;
274
- }
275
- function hashToUint32(input: string) {
276
- const h = crypto.createHash('sha256').update(input).digest();
277
- return h.readUInt32LE(0);
278
- }
279
- function makeRngFromState(initialState: number) {
280
- let state = initialState >>> 0 || 1;
281
- const next = () => {
282
- state ^= state << 13;
283
- state ^= state >>> 17;
284
- state ^= state << 5;
285
- return ((state >>> 0) % 1_000_000) / 1_000_000;
286
- };
287
- const getState = () => state >>> 0;
288
- return { next, getState };
289
- }
290
- function getBusyMs(run: TrekRun) {
291
- const until = run.busyUntil ? Date.parse(run.busyUntil) : 0;
292
- return Math.max(0, until - Date.now());
293
- }
294
-
295
- function assertNotBusy(run: TrekRun) {
296
- const ms = getBusyMs(run);
297
- if (ms > 0) {
298
- const err: any = new Error(`BUSY (${ms}ms remaining)`);
299
- err.code = 'TREK_BUSY';
300
- err.busyMs = ms;
301
- throw err;
302
- }
303
- }
304
-
305
- function setBusy(run: TrekRun, ms = 3000) {
306
- run.busyUntil = new Date(Date.now() + ms).toISOString();
307
- }
308
- function pruneRun(run: TrekRun, max = 100) {
309
- run.history = (run.history || []).slice(-max);
310
-
311
- const keep = new Set<string>();
312
- for (const h of run.history) keep.add(h.nodeId);
313
- if (run.openNodeId) keep.add(run.openNodeId);
314
-
315
- const entries = Object.entries(run.nodes || {}).filter(([id]) => keep.has(id));
316
- run.nodes = Object.fromEntries(entries);
317
- }
318
- function pickWeighted<T>(rng: () => number, items: ReadonlyArray<{ w: number; v: T }>): T {
319
- const total = items.reduce((s, x) => s + x.w, 0);
320
- let r = rng() * total;
321
- for (const it of items) {
322
- r -= it.w;
323
- if (r <= 0) return it.v;
324
- }
325
- return items[items.length - 1].v;
326
- }
327
-
328
- // -------------------------------
329
- // Patch application
330
- // -------------------------------
331
- function applyPatchToObject(obj: any, patch: PatchOp[]) {
332
- for (const p of patch) {
333
- if (p.op === 'set') {
334
- set(obj, p.key, p.value);
335
- } else if (p.op === 'unset') {
336
- const parts = p.key.split('.');
337
- const last = parts.pop();
338
- const parent = parts.reduce((acc: any, k) => (acc ? acc[k] : undefined), obj);
339
- if (parent && last) delete parent[last];
340
- } else if (p.op === 'inc') {
341
- const cur = Number(get(obj, p.key)) || 0;
342
- set(obj, p.key, cur + Number(p.value || 0));
343
- } else if (p.op === 'push') {
344
- const cur = get(obj, p.key);
345
- const arr = Array.isArray(cur) ? cur : [];
346
- arr.push(p.value);
347
- set(obj, p.key, arr);
348
- } else if (p.op === 'merge') {
349
- const cur = get(obj, p.key);
350
- const base = cur && typeof cur === 'object' ? cur : {};
351
- set(obj, p.key, { ...base, ...(p.value || {}) });
352
- }
353
- }
354
- }
355
-
356
- /**
357
- * Normalize inventory patch: push {itemKey} -> push {itemId}
358
- * Assumes inventory uses key path "inventory.0.items".
359
- *
360
- * ✅ Also supports "quantity" on push value by expanding into repeated items,
361
- * since your inventory schema is per-item entry (not stacked).
362
- */
363
- async function normalizeInventoryPatch(ctx: RouterContext, patch: PatchOp[]) {
364
- const out: PatchOp[] = [];
365
-
366
- for (const p of patch) {
367
- if (p.op === 'push' && (p.key === 'inventory.0.items' || p.key.startsWith('inventory.0.items'))) {
368
- const v = (p as any).value || {};
369
-
370
- // already normalized
371
- if (v.itemId) {
372
- const q = Math.max(1, Number(v.quantity ?? 1));
373
- for (let i = 0; i < q; i++) {
374
- out.push({
375
- op: 'push',
376
- key: p.key,
377
- value: { itemId: v.itemId, x: v.x ?? 1, y: v.y ?? 1, meta: v.meta ?? undefined },
378
- });
379
- }
380
- continue;
381
- }
382
-
383
- if (v.itemKey) {
384
- const itemKey = String(v.itemKey);
385
-
386
- // ✅ if missing, create it (Sprites etc.)
387
- const item = await ensureItemByKey(ctx, itemKey, itemKey === SPRITES_ITEM_KEY ? SPRITES_ITEM_NAME : itemKey);
388
-
389
- const q = Math.max(1, Number(v.quantity ?? 1));
390
-
391
- if (item?._id) {
392
- for (let i = 0; i < q; i++) {
393
- out.push({
394
- op: 'push',
395
- key: p.key,
396
- value: { itemId: item._id, x: v.x ?? 1, y: v.y ?? 1, meta: v.meta ?? undefined },
397
- });
398
- }
399
- } else {
400
- // ✅ fallback: keep itemKey in inventory if DB create failed
401
- for (let i = 0; i < q; i++) {
402
- out.push({
403
- op: 'push',
404
- key: p.key,
405
- value: { itemKey, x: v.x ?? 1, y: v.y ?? 1, meta: v.meta ?? undefined },
406
- });
407
- }
408
- }
409
-
410
- continue;
411
- }
412
- }
413
-
414
- out.push(p);
415
- }
416
-
417
- return out;
418
- }
419
-
420
- function inventoryOpsFromEntityPatches(patches: EntityPatch[] | undefined): InventorySyncOp[] {
421
- const ops: InventorySyncOp[] = [];
422
- for (const ep of patches || []) {
423
- if (ep?.entityType !== 'character.inventory' || !Array.isArray(ep.ops)) continue;
424
-
425
- for (const op of ep.ops as any[]) {
426
- // Add item (push inventory.0.items { itemKey, x?, y?, quantity? })
427
- if (
428
- op?.op === 'push' &&
429
- (op.key === 'inventory.0.items' || String(op.key || '').startsWith('inventory.0.items')) &&
430
- op?.value?.itemKey
431
- ) {
432
- ops.push({ op: 'add', itemKey: op.value.itemKey, quantity: Number(op.value.quantity ?? 1) });
433
- }
434
-
435
- // Remove item (future)
436
- if (
437
- op?.op === 'pull' &&
438
- (op.key === 'inventory.0.items' || String(op.key || '').startsWith('inventory.0.items'))
439
- ) {
440
- const itemKey = op?.value?.itemKey;
441
- if (itemKey) ops.push({ op: 'remove', itemKey, quantity: 1 });
442
- }
443
- }
444
- }
445
- return ops;
446
- }
447
-
448
- // -------------------------------
449
- // Extract UI effects/rewards from a choice (for EffectGrid tiles)
450
- // -------------------------------
451
- function extractRewardsAndEffectsFromChoice(choice: TrekNode['choices'][number]) {
452
- const rewards: TrekUiReward[] = [];
453
- const effects: TrekUiEffect[] = [];
454
-
455
- for (const ep of choice.effects || []) {
456
- const ops = Array.isArray(ep?.ops) ? (ep.ops as any[]) : [];
457
-
458
- // Inventory → reward tiles
459
- if (ep.entityType === 'character.inventory') {
460
- for (const op of ops) {
461
- if (
462
- op?.op === 'push' &&
463
- (op.key === 'inventory.0.items' || String(op.key || '').startsWith('inventory.0.items'))
464
- ) {
465
- const itemKey = op?.value?.itemKey;
466
- const quantity = Number(op?.value?.quantity ?? 1);
467
- if (itemKey)
468
- rewards.push({ type: 'item', id: String(itemKey), quantity: Number.isFinite(quantity) ? quantity : 1 });
469
- }
470
- }
471
- }
472
-
473
- // Generic inc/set → effect tiles (best-effort)
474
- for (const op of ops) {
475
- if (op?.op === 'inc' && op?.key) {
476
- const delta = Number(op?.value ?? 0);
477
- effects.push({ type: 'stat', key: String(op.key), delta: Number.isFinite(delta) ? delta : 0 });
478
- } else if (op?.op === 'set' && op?.key) {
479
- effects.push({ type: 'flag', key: String(op.key), value: op?.value });
480
- }
481
- }
482
- }
483
-
484
- return { rewards, effects };
485
- }
486
-
487
- // -------------------------------
488
- // Trek state layout in character.data
489
- // -------------------------------
490
- function trekRootKey() {
491
- return `modes.trek`;
492
- }
493
- function activeRunIdKey() {
494
- return `${trekRootKey()}.activeRunId`;
495
- }
496
- function runsKey() {
497
- return `${trekRootKey()}.runs`;
498
- }
499
- function runKey(runId: string) {
500
- return `${runsKey()}.${runId}`;
501
- }
502
-
503
- // -------------------------------
504
- // Effects builders (generic)
505
- // -------------------------------
506
- function mkPatch(entityType: string, entityId: string, ops: PatchOp[]): EntityPatch {
507
- return { entityType, entityId, ops };
508
- }
509
-
510
- // -------------------------------
511
- // Node generation (replaceable by AI later)
512
- // -------------------------------
513
- function generateNodeText(nodeType: string, stepIndex: number) {
514
- if (nodeType === 'dialog') return `You press onward. The wind carries distant whispers.`;
515
- if (nodeType === 'reward') return `Something glints in the snow. A small cache awaits.`;
516
- if (nodeType === 'npc') return `A traveler emerges from the fog and waves you closer.`;
517
- if (nodeType === 'battle') return `Tracks circle you. Something is hunting.`;
518
- if (nodeType === 'minigame') return `A strange device hums. It looks like a challenge.`;
519
- return `An event unfolds.`;
520
- }
521
-
522
- /**
523
- * compileNode produces an OPEN node with contextual choices.
524
- *
525
- * IMPORTANT:
526
- * - DO NOT include a "Next Stop" choice here.
527
- * The UI has a separate global Next Stop button that exists only when no node is open.
528
- */
529
- function compileNode(params: {
530
- nodeId: string;
531
- nodeType: string;
532
- stepIndex: number;
533
- def: TrekDef;
534
- characterId: string;
535
- profileId: string;
536
- }): TrekNode {
537
- const { nodeId, nodeType, stepIndex, def, characterId, profileId } = params;
538
-
539
- const choices: TrekNode['choices'] = [];
540
-
541
- if (nodeType === 'dialog') {
542
- choices.push({ id: 'push', label: 'Push forward', effects: [], tags: ['flow'] });
543
-
544
- // ✅ Rest briefly gives +5 Sprites (creates Sprites item if missing)
545
- choices.push({
546
- id: 'rest',
547
- label: 'Rest briefly',
548
- effects: [
549
- mkPatch('character.inventory', characterId, [
550
- {
551
- op: 'push',
552
- key: 'inventory.0.items',
553
- value: { itemKey: SPRITES_ITEM_KEY, quantity: 5, x: 1, y: 1 }, // ✅ +5 Sprites
554
- },
555
- ]),
556
- ],
557
- tags: ['effect', 'reward'],
558
- });
559
-
560
- choices.push({ id: 'scout', label: 'Scout the ridge', effects: [], tags: ['flow'] });
561
- }
562
-
563
- if (nodeType === 'reward') {
564
- const itemKey = def.grantableItems[0];
565
-
566
- choices.push({
567
- id: 'claim',
568
- label: 'Take Supplies',
569
- effects: [
570
- mkPatch('character.inventory', characterId, [
571
- { op: 'push', key: 'inventory.0.items', value: { itemKey, x: 1, y: 1 } },
572
- ]),
573
- mkPatch('character.data', characterId, [{ op: 'set', key: `${trekRootKey()}.lastGrantDate`, value: nowIso() }]),
574
- ],
575
- tags: ['effect', 'reward'],
576
- });
577
-
578
- choices.push({ id: 'leave', label: 'Leave it', effects: [], tags: ['flow'] });
579
- }
580
-
581
- if (nodeType === 'npc') {
582
- choices.push({
583
- id: 'talk',
584
- label: 'Talk',
585
- effects: [mkPatch('profile.meta', profileId, [{ op: 'inc', key: `reputation.npc.traveler`, value: 1 }])],
586
- tags: ['effect', 'npc'],
587
- });
588
-
589
- choices.push({
590
- id: 'insult',
591
- label: 'Insult',
592
- effects: [mkPatch('profile.meta', profileId, [{ op: 'inc', key: `reputation.npc.traveler`, value: -2 }])],
593
- tags: ['effect', 'npc', 'negative'],
594
- });
595
-
596
- choices.push({ id: 'moveOn', label: 'Move on', effects: [], tags: ['flow'] });
597
- }
598
-
599
- if (nodeType === 'battle') {
600
- const encId = newId('enc');
601
-
602
- choices.push({
603
- id: 'fight',
604
- label: 'Fight',
605
- effects: [
606
- mkPatch('character.data', characterId, [
607
- {
608
- op: 'set',
609
- key: `${trekRootKey()}.activeEncounter`,
610
- value: {
611
- kind: 'battle',
612
- refId: encId,
613
- difficulty: Math.min(10, 1 + Math.floor(stepIndex / 3)),
614
- seed: `${characterId}:${stepIndex}:${encId}`,
615
- createdDate: nowIso(),
616
- },
617
- },
618
- ]),
619
- ],
620
- opens: { kind: 'battle', refId: encId },
621
- tags: ['effect', 'battle'],
622
- });
623
-
624
- choices.push({ id: 'flee', label: 'Flee', effects: [], tags: ['flow'] });
625
- }
626
-
627
- if (nodeType === 'minigame') {
628
- const miniId = newId('mini');
629
-
630
- choices.push({
631
- id: 'attempt',
632
- label: 'Attempt Challenge',
633
- effects: [
634
- mkPatch('character.data', characterId, [
635
- {
636
- op: 'set',
637
- key: `${trekRootKey()}.activeEncounter`,
638
- value: {
639
- kind: 'minigame',
640
- refId: miniId,
641
- minigameKey: 'trek.lockpick',
642
- seed: `${characterId}:${stepIndex}:${miniId}`,
643
- createdDate: nowIso(),
644
- },
645
- },
646
- ]),
647
- ],
648
- opens: { kind: 'minigame', refId: miniId },
649
- tags: ['effect', 'minigame'],
650
- });
651
-
652
- choices.push({ id: 'skip', label: 'Skip', effects: [], tags: ['flow'] });
653
- }
654
-
655
- // safety: always ensure at least one choice exists
656
- if (!choices.length) choices.push({ id: 'ok', label: 'Continue', effects: [], tags: ['flow'] });
657
-
658
- return {
659
- id: nodeId,
660
- createdDate: nowIso(),
661
- nodeType,
662
- presentation: {
663
- title: def.name,
664
- text: generateNodeText(nodeType, stepIndex),
665
- npc: nodeType === 'npc' ? { name: 'Traveler', tags: ['wanderer'] } : undefined,
666
- },
667
- choices,
668
- tags: ['trek'],
669
- };
670
- }
671
-
672
- // -------------------------------
673
- // State -> UI adapter
674
- // -------------------------------
675
- function runToUiState(run: TrekRun): TrekUiState {
676
- const busyMs = getBusyMs(run);
677
- const openNode = run.openNodeId ? run.nodes?.[run.openNodeId] : undefined;
678
-
679
- const feed: TrekUiFeedItem[] = [];
680
-
681
- // ✅ history feed items include rewards/effects computed at choose-time
682
- for (const h of run.history || []) {
683
- const n = run.nodes?.[h.nodeId];
684
- if (!n) continue;
685
- feed.push({
686
- id: `hist_${h.nodeId}_${h.at}`,
687
- kind: (n.nodeType as any) || 'dialog',
688
- tone: n.nodeType,
689
- title: n.presentation?.title,
690
- text: n.presentation?.text || '',
691
- nodeId: n.id,
692
- choiceId: h.chosenChoiceId,
693
- createdDate: n.createdDate,
694
- rewards: h.rewards || [],
695
- effects: h.effects || [],
696
- });
697
- }
698
-
699
- // ✅ open node shows empty rewards/effects (until you choose)
700
- if (openNode) {
701
- feed.push({
702
- id: `open_${openNode.id}`,
703
- kind: (openNode.nodeType as any) || 'dialog',
704
- tone: openNode.nodeType,
705
- title: openNode.presentation?.title,
706
- text: openNode.presentation?.text || '',
707
- nodeId: openNode.id,
708
- createdDate: openNode.createdDate,
709
- rewards: [],
710
- effects: [],
711
- });
712
- }
713
-
714
- const choices: TrekUiChoice[] = (openNode?.choices || []).map((c) => ({
715
- id: c.id,
716
- label: c.label,
717
- enabled: busyMs > 0 ? false : true,
718
- nodeId: openNode?.id || '',
719
- tags: c.tags,
720
- }));
721
-
722
- const canAdvance = !run.openNodeId && busyMs <= 0;
723
- const baseStatus: TrekUiState['status'] = !run.openNodeId ? 'READY' : 'AWAIT_CHOICE';
724
-
725
- return {
726
- stopIndex: run.stepIndex ?? 0,
727
- status: busyMs > 0 ? 'BUSY' : baseStatus,
728
- canAdvance,
729
- feed,
730
- choices,
731
- busyMs,
732
- stats: { warmth: 0, supplies: 0, morale: 0 },
733
- };
734
- }
735
-
736
- // -------------------------------
737
- // Service
738
- // -------------------------------
739
- export class Service {
740
- /**
741
- * UI reads this.
742
- * - Returns (runId, state)
743
- * - If no run exists yet, creates one (but does NOT open a node).
744
- */
745
- async getState(input: TrekGetStateInput, ctx: RouterContext): Promise<TrekGetStateResult> {
746
- if (!ctx.client?.profile) throw new Error('Unauthorized');
747
-
748
- const defKey = input?.defKey || input?.trekId || 'trek.default';
749
- const def = TREK_DEFS[defKey as keyof typeof TREK_DEFS];
750
- if (!def) throw new Error(`Unknown trek defKey: ${defKey}`);
751
-
752
- const profile = await ctx.app.model.Profile.findById(ctx.client.profile.id).populate('characters').exec();
753
- if (!profile) throw new Error('Profile not found');
754
-
755
- const character = resolveActiveCharacter(profile, input?.characterId);
756
- if (!character) throw new Error('No character');
757
-
758
- if (!character.data) character.data = {};
759
- if (!character.inventory) character.inventory = [{ items: [] }];
760
-
761
- const activeRunId = get(character.data, activeRunIdKey()) as string | undefined;
762
- let run: TrekRun | null = null;
763
-
764
- if (activeRunId) run = get(character.data, runKey(activeRunId)) as TrekRun;
765
-
766
- if (!run?.id) {
767
- const runId = newId('run');
768
- const seed = `${profile.id}:${character.id}:${Date.now()}`;
769
- run = {
770
- id: runId,
771
- defKey: def.key,
772
- seed,
773
- rngState: hashToUint32(seed), // ✅ required
774
- stepIndex: 0,
775
- openNodeId: null,
776
- busyUntil: null, // ✅ optional but you use it
777
- nodes: {},
778
- history: [],
779
- };
780
-
781
- set(character.data, activeRunIdKey(), runId);
782
- set(character.data, runKey(runId), run);
783
- character.markModified('data');
784
- await character.save();
785
-
786
- await emitTrekStateSync(ctx, {
787
- characterId: character.id,
788
- runId: run.id,
789
- state: runToUiState(run),
790
- reason: 'trek.nextStop',
791
- });
792
- }
793
-
794
- return { runId: run.id, state: runToUiState(run) };
795
- }
796
-
797
- /**
798
- * Generates the next node IF there is no open node.
799
- * Stores everything in character.data.
800
- */
801
- async nextStop(input: TrekNextStopInput, ctx: RouterContext): Promise<TrekNextStopResult> {
802
- if (!ctx.client?.profile) throw new Error('Unauthorized');
803
-
804
- const defKey = input?.defKey || 'trek.default';
805
- const def = TREK_DEFS[defKey as keyof typeof TREK_DEFS];
806
- if (!def) throw new Error(`Unknown trek defKey: ${defKey}`);
807
-
808
- const profile = await ctx.app.model.Profile.findById(ctx.client.profile.id).populate('characters').exec();
809
- if (!profile) throw new Error('Profile not found');
810
-
811
- const character = resolveActiveCharacter(profile, input?.characterId);
812
- if (!character) throw new Error('No character');
813
-
814
- if (!character.data) character.data = {};
815
- if (!character.inventory) character.inventory = [{ items: [] }];
816
-
817
- const activeRunId = get(character.data, activeRunIdKey()) as string | undefined;
818
- let run: TrekRun | null = null;
819
- if (activeRunId) run = get(character.data, runKey(activeRunId)) as TrekRun;
820
-
821
- // create run if missing
822
- if (!run?.id) {
823
- const runId = newId('run');
824
- const seed = `${profile.id}:${character.id}:${Date.now()}`;
825
-
826
- run = {
827
- id: runId,
828
- defKey: def.key,
829
- seed,
830
- rngState: hashToUint32(seed),
831
- stepIndex: 0,
832
- openNodeId: null,
833
- busyUntil: null,
834
- nodes: {},
835
- history: [],
836
- };
837
-
838
- set(character.data, activeRunIdKey(), runId);
839
- set(character.data, runKey(runId), run);
840
- }
841
-
842
- assertNotBusy(run);
843
-
844
- // if open node exists, do not generate a new one — just return current state
845
- if (run.openNodeId) {
846
- set(character.data, runKey(run.id), run);
847
- character.markModified('data');
848
- await character.save();
849
- return { runId: run.id, state: runToUiState(run), openNodeId: run.openNodeId, run };
850
- }
851
-
852
- // deterministic nodeType via persisted rngState
853
- const rng = makeRngFromState(run.rngState);
854
- const nodeType = pickWeighted(rng.next, def.nodeTypeWeights) as string;
855
- run.rngState = rng.getState();
856
-
857
- const nodeId = newId('node');
858
- const node = compileNode({
859
- nodeId,
860
- nodeType,
861
- stepIndex: run.stepIndex,
862
- def,
863
- characterId: character.id,
864
- profileId: profile.id,
865
- });
866
-
867
- run.nodes ||= {};
868
- run.nodes[nodeId] = node;
869
-
870
- // keep it OPEN
871
- run.openNodeId = nodeId;
872
- run.stepIndex += 1;
873
-
874
- // ✅ REQUIRED: create a history entry NOW so the OPEN node can later be annotated with chosen rewards/effects
875
- // (and so the node appears in history immediately if you filter open nodes out on the client)
876
- run.history ||= [];
877
- run.history.push({ nodeId, at: nowIso() });
878
-
879
- pruneRun(run, 100);
880
- setBusy(run, 3000);
881
-
882
- set(character.data, runKey(run.id), run);
883
- character.markModified('data');
884
- await character.save();
885
-
886
- return { runId: run.id, state: runToUiState(run), openNodeId: nodeId, run };
887
- }
888
-
889
- /**
890
- * Applies effects for a chosen choice on the currently open node.
891
- * - Applies entity patches
892
- * - Closes the open node (openNodeId -> null)
893
- * - Emits inventory sync hint to the client when inventory changes occur
894
- */
895
- async choose(input: TrekChooseInput, ctx: RouterContext): Promise<TrekChooseResult> {
896
- if (!ctx.client?.profile) throw new Error('Unauthorized');
897
- if (!input?.runId || !input?.nodeId || !input?.choiceId) throw new Error('Invalid input');
898
-
899
- const profile = await ctx.app.model.Profile.findById(ctx.client.profile.id).populate('characters').exec();
900
- if (!profile) throw new Error('Profile not found');
901
-
902
- const character = resolveActiveCharacter(profile, input?.characterId);
903
- if (!character) throw new Error('No character');
904
-
905
- if (!profile.meta) profile.meta = {};
906
- if (!character.data) character.data = {};
907
- if (!character.inventory) character.inventory = [{ items: [] }];
908
-
909
- const run = get(character.data, runKey(input.runId)) as TrekRun;
910
- if (!run?.id) throw new Error('Run not found');
911
-
912
- assertNotBusy(run);
913
-
914
- if (run.openNodeId !== input.nodeId) throw new Error('Node is not open');
915
-
916
- const node = run.nodes?.[input.nodeId];
917
- if (!node) throw new Error('Node not found');
918
-
919
- const choice = node.choices?.find((c) => c.id === input.choiceId);
920
- if (!choice) throw new Error('Choice not found');
921
-
922
- // ✅ compute UI tiles BEFORE any mailing/claim
923
- const { rewards, effects } = extractRewardsAndEffectsFromChoice(choice);
924
-
925
- // ✅ everything becomes claimable mail patches (bundled into ONE message)
926
- const patches: EntityPatch[] = (choice.effects || []).map((p) => ({
927
- ...p,
928
- claimable: true, // your new convention: "mail any claimable patch"
929
- }));
930
-
931
- // One dedupe key per choice so retries/double-clicks don't spam mail
932
- const dedupeKey = `trek:${profile.id}:${character.id}:${run.id}:${node.id}:${choice.id}`;
933
-
934
- // 1) Write the mail message (posterity) — but do NOT rely on it staying unclaimed
935
- const result = await applyPatchesWithInventoryViaMail({
936
- ctx,
937
- profile,
938
- character,
939
- patches,
940
- mail: {
941
- profileId: profile.id.toString(),
942
- conversationKey: 'reports',
943
- kind: 'mail',
944
- source: 'trek.choice',
945
- title: `Trek: ${node.presentation?.title ?? 'Update'}`,
946
- body: `You chose: ${choice.label}`,
947
- ui: {
948
- rewards: rewards as MailReward[],
949
- effects: effects as MailEffect[],
950
- },
951
- dedupeKey,
952
- },
953
- });
954
-
955
- // 2) Auto-claim it immediately so the patches apply NOW
956
- // (message remains as history, but it's claimed)
957
- if (result?.mailed?.messageId) {
958
- await claimMailMessage({
959
- ctx,
960
- profile: profile,
961
- character, // needed if any patch touches character.inventory or character.data
962
- messageId: result.mailed.messageId,
963
- // If claimMailMessage applies profile.meta too, it should load/save profile internally,
964
- // OR you can pass profile as well if you extend the signature.
965
- });
966
- }
967
-
968
- // ─────────────────────────────────────────────
969
- // Trek run state still updates immediately
970
- // ─────────────────────────────────────────────
971
-
972
- run.openNodeId = null;
973
-
974
- const last = run.history?.[run.history.length - 1];
975
- if (last?.nodeId === input.nodeId) {
976
- last.chosenChoiceId = input.choiceId;
977
- last.rewards = rewards;
978
- last.effects = effects;
979
- } else {
980
- run.history ||= [];
981
- run.history.push({
982
- nodeId: input.nodeId,
983
- at: nowIso(),
984
- chosenChoiceId: input.choiceId,
985
- rewards,
986
- effects,
987
- });
988
- }
989
-
990
- pruneRun(run, 100);
991
- setBusy(run, 3000);
992
-
993
- set(character.data, runKey(run.id), run);
994
- character.markModified('data');
995
-
996
- // Important: claimMailMessage may have mutated character.inventory / character.data as well,
997
- // but saving again is fine.
998
- await character.save();
999
-
1000
- await emitTrekStateSync(ctx, {
1001
- characterId: character.id,
1002
- runId: run.id,
1003
- state: runToUiState(run),
1004
- reason: 'trek.nextStop',
1005
- });
1006
-
1007
- return { runId: run.id, state: runToUiState(run) };
1008
- }
1009
- }
1010
-
1011
- async function emitTrekStateSync(
1012
- ctx: RouterContext,
1013
- params: { characterId: string; runId: string; state: any; reason: string }
1014
- ) {
1015
- try {
1016
- await (ctx.client as any)?.emit?.sync?.mutate?.({
1017
- kind: 'patch',
1018
- target: 'trek.state',
1019
- patch: {
1020
- characterId: params.characterId,
1021
- runId: params.runId,
1022
- state: params.state,
1023
- mode: 'replace', // optional semantic hint
1024
- source: 'trek',
1025
- },
1026
- reason: params.reason,
1027
- });
1028
- } catch (e) {
1029
- console.warn('[trek] emit sync failed', e);
1030
- }
1031
- }