@nostr-games/inventory 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,882 @@
1
+ // src/common/constants.ts
2
+ var KIND_GAME_ITEM_DEFINITION = 31632;
3
+ var KIND_GAME_INVENTORY = 31633;
4
+
5
+ // src/common/strings.ts
6
+ function isBlank(value) {
7
+ return typeof value !== "string" || value.trim() === "";
8
+ }
9
+
10
+ // src/common/address.ts
11
+ var HEX64 = /^[0-9a-f]{64}$/;
12
+ function buildAddressableEventAddress(kind, pubkey, identifier) {
13
+ if (!Number.isInteger(kind) || kind < 0) {
14
+ throw new Error(`Invalid kind for address: ${String(kind)}`);
15
+ }
16
+ if (pubkey.trim() === "") {
17
+ throw new Error("Cannot build address with empty pubkey");
18
+ }
19
+ if (identifier.trim() === "") {
20
+ throw new Error("Cannot build address with empty identifier");
21
+ }
22
+ return `${kind}:${pubkey}:${identifier}`;
23
+ }
24
+ function parseAddressableEventAddress(address, options = {}) {
25
+ if (typeof address !== "string") {
26
+ return null;
27
+ }
28
+ const firstColon = address.indexOf(":");
29
+ if (firstColon <= 0) {
30
+ return null;
31
+ }
32
+ const secondColon = address.indexOf(":", firstColon + 1);
33
+ if (secondColon < 0) {
34
+ return null;
35
+ }
36
+ const kindStr = address.slice(0, firstColon);
37
+ const pubkey = address.slice(firstColon + 1, secondColon);
38
+ const identifier = address.slice(secondColon + 1);
39
+ if (!/^\d+$/.test(kindStr)) {
40
+ return null;
41
+ }
42
+ const kind = Number.parseInt(kindStr, 10);
43
+ if (!Number.isSafeInteger(kind)) {
44
+ return null;
45
+ }
46
+ if (isBlank(pubkey) || isBlank(identifier)) {
47
+ return null;
48
+ }
49
+ if (options.requireHexPubkey && !HEX64.test(pubkey)) {
50
+ return null;
51
+ }
52
+ return { kind, pubkey, identifier };
53
+ }
54
+
55
+ // src/common/tags.ts
56
+ function getTagValue(tags, name) {
57
+ for (const tag of tags) {
58
+ if (tag[0] === name) {
59
+ return tag[1];
60
+ }
61
+ }
62
+ return void 0;
63
+ }
64
+ function getTagValues(tags, name) {
65
+ const values = [];
66
+ for (const tag of tags) {
67
+ if (tag[0] === name && typeof tag[1] === "string") {
68
+ values.push(tag[1]);
69
+ }
70
+ }
71
+ return values;
72
+ }
73
+ function getDTag(source) {
74
+ const tags = Array.isArray(source) ? source : source.tags;
75
+ return getTagValue(tags, "d");
76
+ }
77
+
78
+ // src/common/json.ts
79
+ function parseContentJson(content) {
80
+ if (content === "") {
81
+ return { kind: "empty" };
82
+ }
83
+ try {
84
+ return { kind: "json", value: JSON.parse(content) };
85
+ } catch {
86
+ return { kind: "invalid", raw: content };
87
+ }
88
+ }
89
+ function serializeContent(content) {
90
+ if (content === void 0) {
91
+ return "";
92
+ }
93
+ if (typeof content === "string") {
94
+ return content;
95
+ }
96
+ let serialized;
97
+ try {
98
+ serialized = JSON.stringify(content);
99
+ } catch (cause) {
100
+ const detail = cause instanceof Error ? `: ${cause.message}` : "";
101
+ throw new Error(
102
+ `serializeContent: value is not JSON-serializable (circular reference?)${detail}`
103
+ );
104
+ }
105
+ if (serialized === void 0) {
106
+ throw new Error(
107
+ "serializeContent: value is not JSON-serializable (produced `undefined`, e.g. a function or symbol)"
108
+ );
109
+ }
110
+ return serialized;
111
+ }
112
+
113
+ // src/kinds/game-item-definition/address.ts
114
+ var BASED_ON_MARKER = "based_on";
115
+ function buildGameItemAddress(pubkey, itemId) {
116
+ return buildAddressableEventAddress(
117
+ KIND_GAME_ITEM_DEFINITION,
118
+ pubkey,
119
+ itemId
120
+ );
121
+ }
122
+ function parseGameItemAddress(address, options = {}) {
123
+ const parsed = parseAddressableEventAddress(address, options);
124
+ if (parsed === null || parsed.kind !== KIND_GAME_ITEM_DEFINITION) {
125
+ return null;
126
+ }
127
+ return {
128
+ kind: KIND_GAME_ITEM_DEFINITION,
129
+ pubkey: parsed.pubkey,
130
+ itemId: parsed.identifier
131
+ };
132
+ }
133
+
134
+ // src/kinds/game-item-definition/validate.ts
135
+ function isBlank2(value) {
136
+ return value.trim() === "";
137
+ }
138
+ function validateGameItemDefinition(event, options = {}) {
139
+ const issues = [];
140
+ if (event.kind !== KIND_GAME_ITEM_DEFINITION) {
141
+ issues.push({
142
+ code: "wrong-kind",
143
+ message: `Expected kind ${KIND_GAME_ITEM_DEFINITION}, got ${event.kind}`
144
+ });
145
+ }
146
+ const d = getTagValue(event.tags, "d");
147
+ if (d === void 0) {
148
+ issues.push({ code: "missing-d", message: "Missing required `d` tag" });
149
+ } else if (isBlank2(d)) {
150
+ issues.push({ code: "empty-d", message: "`d` tag value is empty" });
151
+ }
152
+ const name = getTagValue(event.tags, "name");
153
+ if (name === void 0) {
154
+ issues.push({
155
+ code: "missing-name",
156
+ message: "Missing required `name` tag"
157
+ });
158
+ } else if (isBlank2(name)) {
159
+ issues.push({ code: "empty-name", message: "`name` tag value is empty" });
160
+ }
161
+ const type = getTagValue(event.tags, "type");
162
+ if (type === void 0) {
163
+ issues.push({
164
+ code: "missing-type",
165
+ message: "Missing required `type` tag"
166
+ });
167
+ } else if (isBlank2(type)) {
168
+ issues.push({ code: "empty-type", message: "`type` tag value is empty" });
169
+ }
170
+ if (options.requireJsonContent) {
171
+ const parsed = parseContentJson(event.content);
172
+ if (parsed.kind === "invalid") {
173
+ issues.push({
174
+ code: "invalid-json-content",
175
+ message: "`content` is not valid JSON"
176
+ });
177
+ }
178
+ }
179
+ return { valid: issues.length === 0, issues };
180
+ }
181
+
182
+ // src/common/result.ts
183
+ function ok(value, warnings = []) {
184
+ return { ok: true, value, warnings };
185
+ }
186
+ function fail(error, warnings = []) {
187
+ return { ok: false, error, warnings };
188
+ }
189
+
190
+ // src/kinds/game-item-definition/parse.ts
191
+ function parseGameItemDefinitionResult(event, options = {}) {
192
+ const mode = options.mode ?? "permissive";
193
+ const requireJsonContent = options.requireJsonContent ?? mode === "strict";
194
+ const warnings = [];
195
+ const validation = validateGameItemDefinition(event, {
196
+ requireJsonContent
197
+ });
198
+ if (!validation.valid) {
199
+ return fail(validation.issues.map((i) => i.message).join("; "), warnings);
200
+ }
201
+ const id = getTagValue(event.tags, "d");
202
+ const name = getTagValue(event.tags, "name");
203
+ const type = getTagValue(event.tags, "type");
204
+ const contentParsed = parseContentJson(event.content);
205
+ let contentJson;
206
+ if (contentParsed.kind === "json") {
207
+ contentJson = contentParsed.value;
208
+ } else if (contentParsed.kind === "invalid") {
209
+ warnings.push({
210
+ code: "invalid-json-content",
211
+ message: "`content` is not valid JSON; ignored"
212
+ });
213
+ }
214
+ const basedOn = parseBasedOnReferences(event.tags, warnings);
215
+ const definition = {
216
+ id,
217
+ address: buildGameItemAddress(event.pubkey, id),
218
+ issuer: event.pubkey,
219
+ kind: KIND_GAME_ITEM_DEFINITION,
220
+ name,
221
+ type,
222
+ contexts: getTagValues(event.tags, "context"),
223
+ topics: getTagValues(event.tags, "t"),
224
+ basedOn,
225
+ content: event.content,
226
+ event
227
+ };
228
+ assignOptional(definition, "category", getTagValue(event.tags, "category"));
229
+ assignOptional(definition, "image", getTagValue(event.tags, "image"));
230
+ assignOptional(definition, "model3d", getTagValue(event.tags, "model_3d"));
231
+ assignOptional(definition, "audio", getTagValue(event.tags, "audio"));
232
+ assignOptional(definition, "symbol", getTagValue(event.tags, "symbol"));
233
+ assignOptional(definition, "rarity", getTagValue(event.tags, "rarity"));
234
+ assignOptional(definition, "maxStack", getTagValue(event.tags, "max_stack"));
235
+ assignOptional(definition, "version", getTagValue(event.tags, "version"));
236
+ assignOptional(definition, "alt", getTagValue(event.tags, "alt"));
237
+ if (contentJson !== void 0) {
238
+ definition.contentJson = contentJson;
239
+ }
240
+ return ok(definition, warnings);
241
+ }
242
+ function parseGameItemDefinition(event, options = {}) {
243
+ const result = parseGameItemDefinitionResult(event, options);
244
+ return result.ok ? result.value : null;
245
+ }
246
+ function parseBasedOnReferences(tags, warnings) {
247
+ const refs = [];
248
+ for (const tag of tags) {
249
+ if (tag[0] !== "a") {
250
+ continue;
251
+ }
252
+ if (tag[3] !== BASED_ON_MARKER) {
253
+ continue;
254
+ }
255
+ const address = tag[1];
256
+ if (typeof address !== "string" || address === "") {
257
+ warnings.push({
258
+ code: "malformed-address",
259
+ message: "`based_on` `a` tag is missing an address; ignored",
260
+ tag
261
+ });
262
+ continue;
263
+ }
264
+ const parsed = parseGameItemAddress(address);
265
+ if (parsed === null) {
266
+ const isMalformed = address.startsWith(`${KIND_GAME_ITEM_DEFINITION}:`);
267
+ warnings.push({
268
+ code: isMalformed ? "malformed-address" : "wrong-referenced-kind",
269
+ message: isMalformed ? `Malformed \`based_on\` address; ignored: ${address}` : `\`based_on\` address does not reference kind ${KIND_GAME_ITEM_DEFINITION}; ignored: ${address}`,
270
+ tag
271
+ });
272
+ continue;
273
+ }
274
+ refs.push({ address, relay: tag[2] ?? "" });
275
+ }
276
+ return refs;
277
+ }
278
+ function assignOptional(target, key, value) {
279
+ if (value !== void 0) {
280
+ target[key] = value;
281
+ }
282
+ }
283
+
284
+ // src/kinds/game-item-definition/build.ts
285
+ var MANAGED_TAG_NAMES = /* @__PURE__ */ new Set([
286
+ "d",
287
+ "name",
288
+ "type",
289
+ "category",
290
+ "image",
291
+ "model_3d",
292
+ "audio",
293
+ "symbol",
294
+ "rarity",
295
+ "max_stack",
296
+ "version",
297
+ "context",
298
+ "t",
299
+ "alt"
300
+ ]);
301
+ function buildGameItemDefinitionEvent(input) {
302
+ const id = requireNonBlank(input.id, "id");
303
+ const name = requireNonBlank(input.name, "name");
304
+ const type = requireNonBlank(input.type, "type");
305
+ const tags = [
306
+ ["d", id],
307
+ ["name", name],
308
+ ["type", type]
309
+ ];
310
+ pushOptional(tags, "category", input.category);
311
+ pushOptional(tags, "image", input.image);
312
+ pushOptional(tags, "model_3d", input.model3d);
313
+ pushOptional(tags, "audio", input.audio);
314
+ pushOptional(tags, "symbol", input.symbol);
315
+ pushOptional(tags, "rarity", input.rarity);
316
+ pushOptional(tags, "max_stack", validateMaxStack(input.maxStack));
317
+ pushOptional(tags, "version", validateVersion(input.version));
318
+ for (const context of input.contexts ?? []) {
319
+ if (!isBlank3(context)) {
320
+ tags.push(["context", context]);
321
+ }
322
+ }
323
+ for (const topic of input.topics ?? []) {
324
+ if (!isBlank3(topic)) {
325
+ tags.push(["t", topic]);
326
+ }
327
+ }
328
+ for (const ref of input.basedOn ?? []) {
329
+ if (parseGameItemAddress(ref.address) === null) {
330
+ throw new Error(
331
+ `buildGameItemDefinitionEvent: invalid \`basedOn.address\` (must be a kind:${KIND_GAME_ITEM_DEFINITION} coordinate): ${String(ref.address)}`
332
+ );
333
+ }
334
+ tags.push(["a", ref.address, ref.relay ?? "", BASED_ON_MARKER]);
335
+ }
336
+ pushOptional(tags, "alt", input.alt);
337
+ for (const tag of input.extraTags ?? []) {
338
+ assertExtraTagAllowed(tag);
339
+ tags.push([...tag]);
340
+ }
341
+ return {
342
+ kind: KIND_GAME_ITEM_DEFINITION,
343
+ content: serializeContent(input.content),
344
+ tags
345
+ };
346
+ }
347
+ function isBlank3(value) {
348
+ return typeof value !== "string" || value.trim() === "";
349
+ }
350
+ function requireNonBlank(value, field) {
351
+ if (isBlank3(value)) {
352
+ throw new Error(
353
+ `buildGameItemDefinitionEvent: \`${field}\` is required and must be non-empty`
354
+ );
355
+ }
356
+ return value;
357
+ }
358
+ function pushOptional(tags, name, value) {
359
+ if (value !== void 0 && !isBlank3(value)) {
360
+ tags.push([name, value]);
361
+ }
362
+ }
363
+ function validateMaxStack(value) {
364
+ if (value === void 0) {
365
+ return void 0;
366
+ }
367
+ if (typeof value === "number") {
368
+ if (!Number.isInteger(value) || value <= 0) {
369
+ throw new Error(
370
+ `buildGameItemDefinitionEvent: \`maxStack\` must be a positive integer: ${String(value)}`
371
+ );
372
+ }
373
+ return String(value);
374
+ }
375
+ if (!/^[1-9]\d*$/.test(value)) {
376
+ throw new Error(
377
+ `buildGameItemDefinitionEvent: \`maxStack\` must be a positive integer string: ${value}`
378
+ );
379
+ }
380
+ return value;
381
+ }
382
+ function validateVersion(value) {
383
+ if (value === void 0) {
384
+ return void 0;
385
+ }
386
+ if (typeof value === "number") {
387
+ if (!Number.isFinite(value)) {
388
+ throw new Error(
389
+ `buildGameItemDefinitionEvent: numeric \`version\` must be finite: ${String(value)}`
390
+ );
391
+ }
392
+ return String(value);
393
+ }
394
+ return value;
395
+ }
396
+ function assertExtraTagAllowed(tag) {
397
+ const name = tag[0];
398
+ if (name === void 0) {
399
+ return;
400
+ }
401
+ if (name === "a") {
402
+ if (tag[3] === BASED_ON_MARKER) {
403
+ throw new Error(
404
+ "buildGameItemDefinitionEvent: `extraTags` may not contain a builder-managed `a`+`based_on` tag; pass it via `basedOn` instead"
405
+ );
406
+ }
407
+ return;
408
+ }
409
+ if (MANAGED_TAG_NAMES.has(name)) {
410
+ throw new Error(
411
+ `buildGameItemDefinitionEvent: \`extraTags\` may not contain the builder-managed tag \`${name}\``
412
+ );
413
+ }
414
+ }
415
+
416
+ // src/kinds/game-inventory/address.ts
417
+ var GRANT_MARKER = "grant";
418
+ function buildGameInventoryAddress(ownerPubkey, inventoryId) {
419
+ return buildAddressableEventAddress(
420
+ KIND_GAME_INVENTORY,
421
+ ownerPubkey,
422
+ inventoryId
423
+ );
424
+ }
425
+ function parseGameInventoryAddress(address, options = {}) {
426
+ const parsed = parseAddressableEventAddress(address, options);
427
+ if (parsed === null || parsed.kind !== KIND_GAME_INVENTORY) {
428
+ return null;
429
+ }
430
+ return {
431
+ kind: KIND_GAME_INVENTORY,
432
+ pubkey: parsed.pubkey,
433
+ inventoryId: parsed.identifier
434
+ };
435
+ }
436
+
437
+ // src/kinds/game-inventory/quantity.ts
438
+ function parseInventoryQuantity(raw) {
439
+ if (typeof raw !== "string") {
440
+ return null;
441
+ }
442
+ if (!/^[1-9]\d*$/.test(raw)) {
443
+ return null;
444
+ }
445
+ const value = Number.parseInt(raw, 10);
446
+ if (!Number.isSafeInteger(value) || value <= 0) {
447
+ return null;
448
+ }
449
+ return value;
450
+ }
451
+ function encodeInventoryQuantity(value) {
452
+ if (!Number.isSafeInteger(value) || value <= 0) {
453
+ throw new Error(
454
+ `Cannot encode non-positive-integer quantity: ${String(value)}`
455
+ );
456
+ }
457
+ return String(value);
458
+ }
459
+
460
+ // src/kinds/game-inventory/validate.ts
461
+ function validateGameInventory(event, options = {}) {
462
+ const issues = [];
463
+ if (event.kind !== KIND_GAME_INVENTORY) {
464
+ issues.push({
465
+ code: "wrong-kind",
466
+ message: `Expected kind ${KIND_GAME_INVENTORY}, got ${event.kind}`
467
+ });
468
+ }
469
+ const d = getTagValue(event.tags, "d");
470
+ if (d === void 0) {
471
+ issues.push({ code: "missing-d", message: "Missing required `d` tag" });
472
+ } else if (isBlank(d)) {
473
+ issues.push({ code: "empty-d", message: "`d` tag value is empty" });
474
+ }
475
+ if (options.requireJsonContent) {
476
+ const parsed = parseContentJson(event.content);
477
+ if (parsed.kind === "invalid") {
478
+ issues.push({
479
+ code: "invalid-json-content",
480
+ message: "`content` is not valid JSON"
481
+ });
482
+ }
483
+ }
484
+ return { valid: issues.length === 0, issues };
485
+ }
486
+
487
+ // src/kinds/game-inventory/quantity.internal.ts
488
+ var MAX_QUANTITY = Number.MAX_SAFE_INTEGER;
489
+ function assertNonNegativeInteger(value, label) {
490
+ if (typeof value !== "number" || Number.isNaN(value)) {
491
+ throw new Error(`${label} must be a number, got: ${String(value)}`);
492
+ }
493
+ if (!Number.isFinite(value)) {
494
+ throw new Error(`${label} must be finite, got: ${String(value)}`);
495
+ }
496
+ if (!Number.isInteger(value)) {
497
+ throw new Error(`${label} must be an integer, got: ${String(value)}`);
498
+ }
499
+ if (value < 0) {
500
+ throw new Error(`${label} must be non-negative, got: ${String(value)}`);
501
+ }
502
+ if (!Number.isSafeInteger(value)) {
503
+ throw new Error(
504
+ `${label} exceeds Number.MAX_SAFE_INTEGER, got: ${String(value)}`
505
+ );
506
+ }
507
+ return value;
508
+ }
509
+ function addQuantitiesChecked(a, b, label) {
510
+ const sum = a + b;
511
+ if (sum > MAX_QUANTITY || !Number.isSafeInteger(sum)) {
512
+ throw new Error(
513
+ `${label} overflow: ${String(a)} + ${String(b)} exceeds Number.MAX_SAFE_INTEGER`
514
+ );
515
+ }
516
+ return sum;
517
+ }
518
+
519
+ // src/kinds/game-inventory/parse.ts
520
+ var HEX642 = /^[0-9a-f]{64}$/;
521
+ function parseGameInventoryResult(event, options = {}) {
522
+ const mode = options.mode ?? "permissive";
523
+ const requireJsonContent = options.requireJsonContent ?? mode === "strict";
524
+ const duplicateStrategy = options.duplicateStrategy ?? (mode === "strict" ? "strict" : "last");
525
+ const requireHexPubkey = options.requireHexPubkey ?? false;
526
+ const requireHexEventId = options.requireHexEventId ?? false;
527
+ const warnings = [];
528
+ const validation = validateGameInventory(event, { requireJsonContent });
529
+ if (!validation.valid) {
530
+ return fail(validation.issues.map((i) => i.message).join("; "), warnings);
531
+ }
532
+ const id = getTagValue(event.tags, "d");
533
+ const collected = [];
534
+ for (const tag of event.tags) {
535
+ if (tag[0] !== "a") {
536
+ continue;
537
+ }
538
+ const address = tag[1];
539
+ if (typeof address !== "string") {
540
+ warnings.push({
541
+ code: "invalid-item-tag",
542
+ message: "`a` tag missing address",
543
+ tag
544
+ });
545
+ continue;
546
+ }
547
+ const generic = parseAddressableEventAddress(address, { requireHexPubkey });
548
+ if (generic === null) {
549
+ warnings.push({
550
+ code: "malformed-address",
551
+ message: `Malformed item address: ${address}`,
552
+ tag
553
+ });
554
+ continue;
555
+ }
556
+ if (generic.kind !== KIND_GAME_ITEM_DEFINITION) {
557
+ warnings.push({
558
+ code: "wrong-referenced-kind",
559
+ message: `Item \`a\` tag does not reference kind ${KIND_GAME_ITEM_DEFINITION}: ${address}`,
560
+ tag
561
+ });
562
+ continue;
563
+ }
564
+ if (parseGameItemAddress(address, { requireHexPubkey }) === null) {
565
+ warnings.push({
566
+ code: "malformed-address",
567
+ message: `Malformed item address: ${address}`,
568
+ tag
569
+ });
570
+ continue;
571
+ }
572
+ const quantity = parseInventoryQuantity(tag[3]);
573
+ if (quantity === null) {
574
+ warnings.push({
575
+ code: "invalid-quantity",
576
+ message: `Invalid or missing quantity for ${address}: ${String(tag[3])}`,
577
+ tag
578
+ });
579
+ continue;
580
+ }
581
+ collected.push({ address, relay: tag[2] ?? "", quantity });
582
+ }
583
+ const resolved = resolveDuplicates(collected, duplicateStrategy, warnings);
584
+ if (!resolved.ok) {
585
+ return fail(resolved.error, warnings);
586
+ }
587
+ const grants = parseGrants(event.tags, requireHexEventId, warnings);
588
+ const contentParsed = parseContentJson(event.content);
589
+ let contentJson;
590
+ if (contentParsed.kind === "json") {
591
+ contentJson = contentParsed.value;
592
+ } else if (contentParsed.kind === "invalid") {
593
+ warnings.push({
594
+ code: "invalid-json-content",
595
+ message: "`content` is not valid JSON; ignored"
596
+ });
597
+ }
598
+ const inventory = {
599
+ id,
600
+ address: buildGameInventoryAddress(event.pubkey, id),
601
+ owner: event.pubkey,
602
+ kind: KIND_GAME_INVENTORY,
603
+ contexts: getTagValues(event.tags, "context"),
604
+ items: resolved.value,
605
+ grants,
606
+ grantEventIds: grants.map((g) => g.eventId),
607
+ content: event.content,
608
+ event
609
+ };
610
+ const name = getTagValue(event.tags, "name");
611
+ if (name !== void 0) {
612
+ inventory.name = name;
613
+ }
614
+ const alt = getTagValue(event.tags, "alt");
615
+ if (alt !== void 0) {
616
+ inventory.alt = alt;
617
+ }
618
+ if (contentJson !== void 0) {
619
+ inventory.contentJson = contentJson;
620
+ }
621
+ return ok(inventory, warnings);
622
+ }
623
+ function parseGameInventory(event, options = {}) {
624
+ const result = parseGameInventoryResult(event, options);
625
+ return result.ok ? result.value : null;
626
+ }
627
+ function resolveDuplicates(items, strategy, warnings) {
628
+ const counts = /* @__PURE__ */ new Map();
629
+ for (const item of items) {
630
+ counts.set(item.address, (counts.get(item.address) ?? 0) + 1);
631
+ }
632
+ const hasDuplicate = [...counts.values()].some((n) => n > 1);
633
+ if (!hasDuplicate) {
634
+ return { ok: true, value: items.map((item) => ({ ...item })) };
635
+ }
636
+ if (strategy === "strict") {
637
+ return {
638
+ ok: false,
639
+ error: "Duplicate item references present and duplicate strategy is `strict`"
640
+ };
641
+ }
642
+ for (const [address, count] of counts) {
643
+ if (count > 1) {
644
+ warnings.push({
645
+ code: "duplicate-item",
646
+ message: `Duplicate item reference (${count}x) resolved with strategy \`${strategy}\`: ${address}`
647
+ });
648
+ }
649
+ }
650
+ const order = [];
651
+ const byAddress = /* @__PURE__ */ new Map();
652
+ for (const item of items) {
653
+ const existing = byAddress.get(item.address);
654
+ if (existing === void 0) {
655
+ order.push(item.address);
656
+ byAddress.set(item.address, { ...item });
657
+ } else if (strategy === "sum") {
658
+ const sum = existing.quantity + item.quantity;
659
+ if (sum > MAX_QUANTITY || !Number.isSafeInteger(sum)) {
660
+ return {
661
+ ok: false,
662
+ error: `Summed quantity for ${item.address} exceeds Number.MAX_SAFE_INTEGER`
663
+ };
664
+ }
665
+ existing.quantity = sum;
666
+ existing.relay = item.relay;
667
+ } else {
668
+ existing.quantity = item.quantity;
669
+ existing.relay = item.relay;
670
+ }
671
+ }
672
+ return {
673
+ ok: true,
674
+ value: order.map((addr) => byAddress.get(addr))
675
+ };
676
+ }
677
+ function parseGrants(tags, requireHexEventId, warnings) {
678
+ const grants = [];
679
+ for (const tag of tags) {
680
+ if (tag[0] !== "e") {
681
+ continue;
682
+ }
683
+ if (tag[3] !== GRANT_MARKER) {
684
+ continue;
685
+ }
686
+ const eventId = tag[1];
687
+ if (typeof eventId !== "string" || isBlank(eventId)) {
688
+ warnings.push({
689
+ code: "invalid-grant-tag",
690
+ message: "Grant `e` tag is missing an event id; ignored",
691
+ tag
692
+ });
693
+ continue;
694
+ }
695
+ if (requireHexEventId && !HEX642.test(eventId)) {
696
+ warnings.push({
697
+ code: "invalid-grant-tag",
698
+ message: `Grant \`e\` tag event id is not canonical 64-char hex; ignored: ${eventId}`,
699
+ tag
700
+ });
701
+ continue;
702
+ }
703
+ grants.push({ eventId, relay: tag[2] ?? "" });
704
+ }
705
+ return grants;
706
+ }
707
+
708
+ // src/kinds/game-inventory/build.ts
709
+ var MANAGED_TAG_NAMES2 = /* @__PURE__ */ new Set(["d", "context", "name", "alt"]);
710
+ function buildGameInventoryEvent(input) {
711
+ if (isBlank(input.id)) {
712
+ throw new Error(
713
+ "buildGameInventoryEvent: `id` is required and must be non-empty"
714
+ );
715
+ }
716
+ const strategy = input.duplicateStrategy ?? "last";
717
+ const items = normalizeItems(input.items ?? [], strategy);
718
+ const tags = [["d", input.id]];
719
+ for (const context of input.contexts ?? []) {
720
+ if (!isBlank(context)) {
721
+ tags.push(["context", context]);
722
+ }
723
+ }
724
+ if (input.name !== void 0 && !isBlank(input.name)) {
725
+ tags.push(["name", input.name]);
726
+ }
727
+ for (const item of items) {
728
+ tags.push([
729
+ "a",
730
+ item.address,
731
+ item.relay,
732
+ encodeInventoryQuantity(item.quantity)
733
+ ]);
734
+ }
735
+ for (const grant of input.grants ?? []) {
736
+ if (isBlank(grant.eventId)) {
737
+ throw new Error(
738
+ "buildGameInventoryEvent: grant `eventId` must be non-empty"
739
+ );
740
+ }
741
+ tags.push(["e", grant.eventId, grant.relay ?? "", GRANT_MARKER]);
742
+ }
743
+ if (input.alt !== void 0 && !isBlank(input.alt)) {
744
+ tags.push(["alt", input.alt]);
745
+ }
746
+ for (const tag of input.extraTags ?? []) {
747
+ assertExtraTagAllowed2(tag);
748
+ tags.push([...tag]);
749
+ }
750
+ return {
751
+ kind: KIND_GAME_INVENTORY,
752
+ content: serializeContent(input.content),
753
+ tags
754
+ };
755
+ }
756
+ function normalizeItems(inputs, strategy) {
757
+ const order = [];
758
+ const byAddress = /* @__PURE__ */ new Map();
759
+ for (const raw of inputs) {
760
+ if (parseGameItemAddress(raw.address) === null) {
761
+ throw new Error(
762
+ `buildGameInventoryEvent: invalid item address (must be a kind:${KIND_GAME_ITEM_DEFINITION} coordinate): ${String(raw.address)}`
763
+ );
764
+ }
765
+ const quantity = assertNonNegativeInteger(raw.quantity, "item quantity");
766
+ if (quantity === 0) {
767
+ continue;
768
+ }
769
+ const relay = raw.relay ?? "";
770
+ const existing = byAddress.get(raw.address);
771
+ if (existing === void 0) {
772
+ order.push(raw.address);
773
+ byAddress.set(raw.address, { address: raw.address, relay, quantity });
774
+ continue;
775
+ }
776
+ if (strategy === "strict") {
777
+ throw new Error(
778
+ `buildGameInventoryEvent: duplicate item address under strict strategy: ${raw.address}`
779
+ );
780
+ } else if (strategy === "sum") {
781
+ existing.quantity = addQuantitiesChecked(
782
+ existing.quantity,
783
+ quantity,
784
+ "summed item quantity"
785
+ );
786
+ existing.relay = relay;
787
+ } else {
788
+ existing.quantity = quantity;
789
+ existing.relay = relay;
790
+ }
791
+ }
792
+ return order.map((addr) => byAddress.get(addr));
793
+ }
794
+ function assertExtraTagAllowed2(tag) {
795
+ const name = tag[0];
796
+ if (name === void 0) {
797
+ return;
798
+ }
799
+ if (name === "a") {
800
+ throw new Error(
801
+ "buildGameInventoryEvent: `extraTags` may not contain an `a` tag; pass inventory items via `items` instead"
802
+ );
803
+ }
804
+ if (name === "e") {
805
+ if (tag[3] === GRANT_MARKER) {
806
+ throw new Error(
807
+ "buildGameInventoryEvent: `extraTags` may not contain a grant `e` tag; pass it via `grants` instead"
808
+ );
809
+ }
810
+ return;
811
+ }
812
+ if (MANAGED_TAG_NAMES2.has(name)) {
813
+ throw new Error(
814
+ `buildGameInventoryEvent: \`extraTags\` may not contain the builder-managed tag \`${name}\``
815
+ );
816
+ }
817
+ }
818
+
819
+ // src/kinds/game-inventory/helpers.ts
820
+ function assertItemAddress(itemAddress) {
821
+ if (parseGameItemAddress(itemAddress) === null) {
822
+ throw new Error(
823
+ `itemAddress must be a valid kind:${KIND_GAME_ITEM_DEFINITION} coordinate, got: ${String(itemAddress)}`
824
+ );
825
+ }
826
+ }
827
+ function getInventoryItemQuantity(inventory, itemAddress) {
828
+ for (const item of inventory.items) {
829
+ if (item.address === itemAddress) {
830
+ return item.quantity;
831
+ }
832
+ }
833
+ return 0;
834
+ }
835
+ function setInventoryItemQuantity(inventory, itemAddress, quantity, relay) {
836
+ assertItemAddress(itemAddress);
837
+ assertNonNegativeInteger(quantity, "quantity");
838
+ const existing = inventory.items.find((item) => item.address === itemAddress);
839
+ if (quantity === 0) {
840
+ return {
841
+ ...inventory,
842
+ items: inventory.items.filter((item) => item.address !== itemAddress).map((item) => ({ ...item }))
843
+ };
844
+ }
845
+ const resolvedRelay = relay ?? existing?.relay ?? "";
846
+ if (existing) {
847
+ return {
848
+ ...inventory,
849
+ items: inventory.items.map(
850
+ (item) => item.address === itemAddress ? { address: itemAddress, relay: resolvedRelay, quantity } : { ...item }
851
+ )
852
+ };
853
+ }
854
+ return {
855
+ ...inventory,
856
+ items: [
857
+ ...inventory.items.map((item) => ({ ...item })),
858
+ { address: itemAddress, relay: resolvedRelay, quantity }
859
+ ]
860
+ };
861
+ }
862
+ function addInventoryItemQuantity(inventory, itemAddress, amount, relay) {
863
+ assertItemAddress(itemAddress);
864
+ assertNonNegativeInteger(amount, "amount");
865
+ const current = getInventoryItemQuantity(inventory, itemAddress);
866
+ const next = addQuantitiesChecked(current, amount, "quantity");
867
+ return setInventoryItemQuantity(inventory, itemAddress, next, relay);
868
+ }
869
+ function removeInventoryItemQuantity(inventory, itemAddress, amount) {
870
+ assertItemAddress(itemAddress);
871
+ assertNonNegativeInteger(amount, "amount");
872
+ const current = getInventoryItemQuantity(inventory, itemAddress);
873
+ const next = Math.max(0, current - amount);
874
+ return setInventoryItemQuantity(inventory, itemAddress, next);
875
+ }
876
+ function getInventoryItems(inventory) {
877
+ return inventory.items.map((item) => ({ ...item }));
878
+ }
879
+
880
+ export { BASED_ON_MARKER, GRANT_MARKER, KIND_GAME_INVENTORY, KIND_GAME_ITEM_DEFINITION, addInventoryItemQuantity, buildAddressableEventAddress, buildGameInventoryAddress, buildGameInventoryEvent, buildGameItemAddress, buildGameItemDefinitionEvent, encodeInventoryQuantity, getDTag, getInventoryItemQuantity, getInventoryItems, getTagValue, getTagValues, parseAddressableEventAddress, parseContentJson, parseGameInventory, parseGameInventoryAddress, parseGameInventoryResult, parseGameItemAddress, parseGameItemDefinition, parseGameItemDefinitionResult, parseInventoryQuantity, removeInventoryItemQuantity, serializeContent, setInventoryItemQuantity, validateGameInventory, validateGameItemDefinition };
881
+ //# sourceMappingURL=index.js.map
882
+ //# sourceMappingURL=index.js.map