@arken/seer-protocol 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.rush/temp/shrinkwrap-deps.json +537 -56
- package/area/area.models.ts +15 -0
- package/area/area.router.ts +74 -0
- package/area/area.schema.ts +22 -0
- package/area/area.types.ts +26 -0
- package/area/index.ts +5 -0
- package/asset/asset.models.ts +59 -0
- package/asset/asset.router.ts +55 -0
- package/asset/asset.schema.ts +27 -0
- package/asset/asset.types.ts +22 -0
- package/asset/index.ts +5 -0
- package/chain/chain.models.ts +50 -0
- package/chain/chain.router.ts +104 -0
- package/chain/chain.schema.ts +52 -0
- package/chain/chain.types.ts +24 -0
- package/chain/index.ts +5 -0
- package/character/character.models.ts +174 -0
- package/character/character.router.ts +314 -0
- package/character/character.schema.ts +147 -0
- package/character/character.types.ts +64 -0
- package/character/index.ts +5 -0
- package/chat/chat.models.ts +43 -0
- package/chat/chat.router.ts +67 -0
- package/chat/chat.schema.ts +36 -0
- package/chat/chat.types.ts +20 -0
- package/chat/index.ts +5 -0
- package/collection/collection.models.ts +76 -0
- package/collection/collection.router.ts +91 -0
- package/collection/collection.schema.ts +90 -0
- package/collection/collection.types.ts +36 -0
- package/collection/index.ts +5 -0
- package/core/core.models.ts +1380 -0
- package/core/core.router.ts +1781 -0
- package/core/core.schema.ts +847 -0
- package/core/core.types.ts +340 -0
- package/core/index.ts +5 -0
- package/{src/modules/evolution → evolution}/evolution.models.ts +1 -1
- package/{src/modules/evolution → evolution}/evolution.router.ts +5 -5
- package/{src/modules/evolution → evolution}/evolution.types.ts +1 -1
- package/game/game.models.ts +53 -0
- package/game/game.router.ts +110 -0
- package/game/game.schema.ts +23 -0
- package/game/game.types.ts +28 -0
- package/game/index.ts +5 -0
- package/index.ts +59 -0
- package/{src/modules/infinite → infinite}/infinite.models.ts +1 -1
- package/{src/modules/infinite → infinite}/infinite.router.ts +5 -5
- package/{src/modules/infinite → infinite}/infinite.types.ts +1 -1
- package/interface/index.ts +5 -0
- package/interface/interface.canonicalize.ts +279 -0
- package/interface/interface.models.ts +40 -0
- package/interface/interface.router.ts +175 -0
- package/interface/interface.schema.ts +59 -0
- package/interface/interface.types.ts +25 -0
- package/{src/modules/isles → isles}/isles.models.ts +1 -1
- package/{src/modules/isles → isles}/isles.router.ts +5 -5
- package/{src/modules/isles → isles}/isles.types.ts +1 -1
- package/item/index.ts +5 -0
- package/item/item.models.ts +124 -0
- package/item/item.router.ts +103 -0
- package/item/item.schema.ts +120 -0
- package/item/item.types.ts +74 -0
- package/job/index.ts +5 -0
- package/job/job.models.ts +14 -0
- package/job/job.router.ts +44 -0
- package/job/job.schema.ts +9 -0
- package/job/job.types.ts +23 -0
- package/market/index.ts +5 -0
- package/market/market.models.ts +113 -0
- package/market/market.router.ts +73 -0
- package/market/market.schema.ts +140 -0
- package/market/market.types.ts +56 -0
- package/{src/modules/oasis → oasis}/oasis.models.ts +1 -1
- package/{src/modules/oasis → oasis}/oasis.router.ts +1 -1
- package/{src/modules/oasis → oasis}/oasis.types.ts +1 -1
- package/package.json +12 -14
- package/product/index.ts +5 -0
- package/product/product.models.ts +166 -0
- package/product/product.router.ts +93 -0
- package/product/product.schema.ts +149 -0
- package/product/product.types.ts +33 -0
- package/profile/index.ts +5 -0
- package/profile/profile.models.ts +214 -0
- package/profile/profile.router.ts +72 -0
- package/profile/profile.schema.ts +156 -0
- package/profile/profile.types.ts +22 -0
- package/raffle/index.ts +5 -0
- package/raffle/raffle.models.ts +44 -0
- package/raffle/raffle.router.ts +90 -0
- package/raffle/raffle.schema.ts +32 -0
- package/raffle/raffle.types.ts +30 -0
- package/{src/router.ts → router.ts} +22 -28
- package/schema.ts +321 -0
- package/skill/index.ts +5 -0
- package/skill/skill.models.ts +16 -0
- package/skill/skill.router.ts +201 -0
- package/skill/skill.schema.ts +40 -0
- package/skill/skill.types.ts +33 -0
- package/{src/modules/trek → trek}/trek.router.ts +1 -1
- package/types.ts +273 -0
- package/video/index.ts +5 -0
- package/video/video.models.ts +25 -0
- package/video/video.router.ts +143 -0
- package/video/video.schema.ts +46 -0
- package/video/video.types.ts +33 -0
- package/src/index.ts +0 -22
- package/src/modules/evolution/evolution.service.ts +0 -2000
- package/src/modules/infinite/infinite.service.ts +0 -40
- package/src/modules/isles/isles.service.ts +0 -40
- package/src/modules/oasis/oasis.service.ts +0 -38
- package/src/modules/trek/trek.service.ts +0 -1031
- package/src/types.ts +0 -106
- /package/{src/modules/evolution → evolution}/evolution.schema.ts +0 -0
- /package/{src/modules/evolution → evolution}/index.ts +0 -0
- /package/{src/modules/infinite → infinite}/index.ts +0 -0
- /package/{src/modules/infinite → infinite}/infinite.schema.ts +0 -0
- /package/{src/modules/isles → isles}/index.ts +0 -0
- /package/{src/modules/isles → isles}/isles.schema.ts +0 -0
- /package/{src/modules/oasis → oasis}/index.ts +0 -0
- /package/{src/modules/oasis → oasis}/oasis.schema.ts +0 -0
- /package/{src/modules/trek → trek}/index.ts +0 -0
- /package/{src/modules/trek → trek}/trek.models.ts +0 -0
- /package/{src/modules/trek → trek}/trek.schema.ts +0 -0
- /package/{src/modules/trek → trek}/trek.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
|
-
}
|