@mpgd/game-services 0.6.0 → 0.7.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.
@@ -71,6 +71,7 @@ async function runFirstSelectionAndSnapshotScenario(context) {
71
71
  participantLabel: 'First Player',
72
72
  attemptId: 'attempt:later',
73
73
  score: 20,
74
+ metrics: { elapsedMs: 20000, hints: 1, mistakes: 2 },
74
75
  completedAt: '2030-01-02T02:02:00.000Z',
75
76
  });
76
77
  const laterRecord = await context.service.recordVerifiedAttempt(laterRequest);
@@ -80,6 +81,7 @@ async function runFirstSelectionAndSnapshotScenario(context) {
80
81
  participantLabel: 'First Player',
81
82
  attemptId: 'attempt:earlier',
82
83
  score: 30,
84
+ metrics: { elapsedMs: 30000, hints: 0, mistakes: 1 },
83
85
  completedAt: '2030-01-02T02:01:00.000Z',
84
86
  }));
85
87
  await context.service.recordVerifiedAttempt(createAttempt({
@@ -97,10 +99,12 @@ async function runFirstSelectionAndSnapshotScenario(context) {
97
99
  participantLabel: 'First Player',
98
100
  attemptId: 'attempt:latest',
99
101
  score: 5,
102
+ metrics: { elapsedMs: 5000, hints: 2, mistakes: 0 },
100
103
  completedAt: '2030-01-02T02:04:00.000Z',
101
104
  }));
102
105
  assertEqual(latestRecord.retained, false, 'later completions must not replace the earliest retained attempt');
103
106
  assertEqual(latestRecord.entry.attemptId, 'attempt:earlier', 'rejected later attempts must report the retained earliest attempt');
107
+ assertEqual(latestRecord.entry.metrics?.elapsedMs, 30000, 'rejected attempts must report the retained entry metrics');
104
108
  const snapshot = await context.service.getSnapshot({
105
109
  leaderboardId: 'first:board',
106
110
  participantId: 'participant:first',
@@ -111,6 +115,7 @@ async function runFirstSelectionAndSnapshotScenario(context) {
111
115
  assertEqual(snapshot.entries[0]?.rank, 1, 'page entries must carry their global rank');
112
116
  assertEqual(snapshot.entries[0]?.participantId, 'participant:leader', 'ascending snapshots must rank lower scores first');
113
117
  assertEqual(snapshot.participantEntry?.attemptId, 'attempt:earlier', 'participant entries must reflect retained attempts outside the requested page');
118
+ assertEqual(snapshot.participantEntry?.metrics?.mistakes, 1, 'participant entries must preserve generic attempt metrics');
114
119
  assertEqual(snapshot.participantEntry?.rank, 2, 'participant entries must carry global rank');
115
120
  assertEqual(snapshot.totalParticipants, 2, 'snapshots must count retained participants');
116
121
  assertEqual(snapshot.generatedAt, context.now, 'snapshots must use the provider clock');
@@ -309,6 +314,7 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
309
314
  participantLabel: 'Original Label',
310
315
  attemptId: 'attempt:identity',
311
316
  score: 42,
317
+ metrics: { elapsedMs: 42000, hints: 1, mistakes: 2 },
312
318
  completedAt: '2030-01-02T02:30:00.000Z',
313
319
  });
314
320
  await context.service.recordVerifiedAttempt(request);
@@ -317,6 +323,7 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
317
323
  attempt: {
318
324
  ...request.attempt,
319
325
  participantLabel: 'Changed Label',
326
+ metrics: { mistakes: 2, elapsedMs: 42000, hints: 1 },
320
327
  completedAt: '2030-01-02T11:30:00.000+09:00',
321
328
  verification: {
322
329
  ...request.attempt.verification,
@@ -327,10 +334,18 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
327
334
  assertEqual(equivalentRetry.alreadyProcessed, true, 'equivalent retries must be idempotent');
328
335
  assertEqual(equivalentRetry.entry.participantLabel, 'Original Label', 'retry labels must not rewrite stored presentation metadata');
329
336
  assertEqual(equivalentRetry.entry.completedAt, request.attempt.completedAt, 'equivalent retries must preserve the original timestamp representation');
337
+ assertEqual(equivalentRetry.entry.metrics?.elapsedMs, 42000, 'equivalent retries must preserve the original metrics');
330
338
  await assertRejects(() => context.service.recordVerifiedAttempt({
331
339
  ...request,
332
340
  attempt: { ...request.attempt, score: 43 },
333
341
  }), 'attempt IDs reused with a different score must fail closed');
342
+ await assertRejects(() => context.service.recordVerifiedAttempt({
343
+ ...request,
344
+ attempt: {
345
+ ...request.attempt,
346
+ metrics: { elapsedMs: 42000, hints: 0, mistakes: 2 },
347
+ },
348
+ }), 'attempt IDs reused with different metrics must fail closed');
334
349
  await assertRejects(() => context.service.recordVerifiedAttempt({
335
350
  ...request,
336
351
  attempt: {
@@ -417,11 +432,15 @@ async function runMutationIsolationScenario(context) {
417
432
  participantLabel: 'Stable Label',
418
433
  attemptId: 'attempt:mutation',
419
434
  score: 50,
435
+ metrics: { elapsedMs: 50000, hints: 1, mistakes: 2 },
420
436
  completedAt: '2030-01-02T02:50:00.000Z',
421
437
  });
422
438
  const response = await context.service.recordVerifiedAttempt(request);
423
439
  request.definition.scoreOrder = 'descending';
424
440
  request.attempt.score = -1;
441
+ if (request.attempt.metrics !== undefined) {
442
+ request.attempt.metrics.elapsedMs = 1;
443
+ }
425
444
  request.attempt.verification.evidenceId = 'evidence:mutated';
426
445
  try {
427
446
  Object.assign(response.entry, { participantLabel: 'Mutated Label', score: -2 });
@@ -429,6 +448,12 @@ async function runMutationIsolationScenario(context) {
429
448
  catch {
430
449
  // Frozen provider responses are already isolated from caller mutation.
431
450
  }
451
+ try {
452
+ Object.assign(response.entry.metrics ?? {}, { elapsedMs: 2 });
453
+ }
454
+ catch {
455
+ // Frozen provider metric copies are already isolated from caller mutation.
456
+ }
432
457
  const snapshot = await context.service.getSnapshot({
433
458
  leaderboardId: 'mutation:board',
434
459
  participantId: 'participant:mutation',
@@ -440,6 +465,7 @@ async function runMutationIsolationScenario(context) {
440
465
  assert(participantEntry !== undefined, 'participant reads must return the retained entry');
441
466
  assertEqual(snapshot.definition.scoreOrder, 'ascending', 'stored definitions must be cloned');
442
467
  assertEqual(snapshotEntry.score, 50, 'stored attempts must resist caller mutation');
468
+ assertEqual(snapshotEntry.metrics?.elapsedMs, 50000, 'stored metrics must be cloned');
443
469
  assertEqual(snapshotEntry.participantLabel, 'Stable Label', 'response mutation must not rewrite retained entries');
444
470
  try {
445
471
  Object.assign(snapshot.definition, { scoreOrder: 'descending' });
@@ -453,12 +479,24 @@ async function runMutationIsolationScenario(context) {
453
479
  catch {
454
480
  // Frozen provider snapshot entries are already isolated from mutation.
455
481
  }
482
+ try {
483
+ Object.assign(snapshotEntry.metrics ?? {}, { elapsedMs: 3 });
484
+ }
485
+ catch {
486
+ // Frozen provider snapshot metrics are already isolated from mutation.
487
+ }
456
488
  try {
457
489
  Object.assign(participantEntry, { participantLabel: 'Participant Mutation', score: -4 });
458
490
  }
459
491
  catch {
460
492
  // Frozen provider participant entries are already isolated from mutation.
461
493
  }
494
+ try {
495
+ Object.assign(participantEntry.metrics ?? {}, { mistakes: 4 });
496
+ }
497
+ catch {
498
+ // Frozen provider participant metrics are already isolated from mutation.
499
+ }
462
500
  try {
463
501
  snapshot.entries.pop();
464
502
  }
@@ -472,9 +510,11 @@ async function runMutationIsolationScenario(context) {
472
510
  assertEqual(rereadSnapshot?.definition.scoreOrder, 'ascending', 'snapshot definition mutation must not rewrite stored policy');
473
511
  assertEqual(rereadSnapshot?.entries[0]?.score, 50, 'snapshot entry mutation must not rewrite retained attempts');
474
512
  assertEqual(rereadSnapshot?.entries[0]?.participantLabel, 'Stable Label', 'snapshot label mutation must not rewrite presentation metadata');
513
+ assertEqual(rereadSnapshot?.entries[0]?.metrics?.elapsedMs, 50000, 'snapshot metric mutation must not rewrite retained metrics');
475
514
  assertEqual(rereadSnapshot?.entries.length, 1, 'snapshot array mutation must not rewrite retained entries');
476
515
  assertEqual(rereadSnapshot?.participantEntry?.score, 50, 'participant entry mutation must not rewrite retained attempts');
477
516
  assertEqual(rereadSnapshot?.participantEntry?.participantLabel, 'Stable Label', 'participant entry mutation must not rewrite presentation metadata');
517
+ assertEqual(rereadSnapshot?.participantEntry?.metrics?.mistakes, 2, 'participant metric mutation must not rewrite retained metrics');
478
518
  }
479
519
  class ProviderOutputValidationError extends Error {
480
520
  }
@@ -552,6 +592,38 @@ async function runRuntimeValidationScenario(context) {
552
592
  score: Number.POSITIVE_INFINITY,
553
593
  completedAt: validVerificationTimestamp,
554
594
  })), 'infinite scores must fail closed');
595
+ await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
596
+ leaderboardId: 'validation:metric-key',
597
+ participantId: 'participant:metric-key',
598
+ attemptId: 'attempt:metric-key',
599
+ score: 1,
600
+ metrics: { '1elapsedMs': 1 },
601
+ completedAt: validVerificationTimestamp,
602
+ })), 'invalid metric keys must fail closed');
603
+ await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
604
+ leaderboardId: 'validation:metric-value',
605
+ participantId: 'participant:metric-value',
606
+ attemptId: 'attempt:metric-value',
607
+ score: 1,
608
+ metrics: { mistakes: -1 },
609
+ completedAt: validVerificationTimestamp,
610
+ })), 'negative metric values must fail closed');
611
+ await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
612
+ leaderboardId: 'validation:metric-safe-integer',
613
+ participantId: 'participant:metric-safe-integer',
614
+ attemptId: 'attempt:metric-safe-integer',
615
+ score: 1,
616
+ metrics: { elapsedMs: Number.MAX_SAFE_INTEGER + 1 },
617
+ completedAt: validVerificationTimestamp,
618
+ })), 'unsafe integer metric values must fail closed');
619
+ await assertRejects(() => context.service.recordVerifiedAttempt(createAttempt({
620
+ leaderboardId: 'validation:metric-count',
621
+ participantId: 'participant:metric-count',
622
+ attemptId: 'attempt:metric-count',
623
+ score: 1,
624
+ metrics: Object.fromEntries(Array.from({ length: 17 }, (_, index) => [`metric${String(index)}`, index])),
625
+ completedAt: validVerificationTimestamp,
626
+ })), 'oversized metric maps must fail closed');
555
627
  const failedWriteChecks = await Promise.all([
556
628
  'validation:calendar',
557
629
  'validation:timezone',
@@ -559,6 +631,10 @@ async function runRuntimeValidationScenario(context) {
559
631
  'validation:evidence-timezone',
560
632
  'validation:score-nan',
561
633
  'validation:score-infinity',
634
+ 'validation:metric-key',
635
+ 'validation:metric-value',
636
+ 'validation:metric-safe-integer',
637
+ 'validation:metric-count',
562
638
  ].map(async (leaderboardId) => ({
563
639
  leaderboardId,
564
640
  snapshot: await context.service.getSnapshot({ leaderboardId }),
@@ -597,6 +673,7 @@ function createMutableAttempt(input) {
597
673
  : { participantLabel: input.participantLabel }),
598
674
  attemptId: input.attemptId,
599
675
  score: input.score,
676
+ ...(input.metrics === undefined ? {} : { metrics: { ...input.metrics } }),
600
677
  completedAt: input.completedAt,
601
678
  verification: {
602
679
  authorityId: 'verified-leaderboard-conformance',
@@ -1,4 +1,4 @@
1
- import { assertGetVerifiedLeaderboardSnapshotRequest, assertVerifiedLeaderboardSnapshot, VerifiedLeaderboardCursorError, } from './verified-leaderboard.js';
1
+ import { assertGetVerifiedLeaderboardSnapshotRequest, assertVerifiedLeaderboardSnapshot, normalizeVerifiedLeaderboardMetrics, VerifiedLeaderboardCursorError, } from './verified-leaderboard.js';
2
2
  export const verifiedLeaderboardSnapshotPath = '/game-services/verified-leaderboard/snapshot';
3
3
  export function createVerifiedLeaderboardSnapshotFetchHandler(input) {
4
4
  const path = input.path ?? verifiedLeaderboardSnapshotPath;
@@ -99,10 +99,35 @@ export function createVerifiedLeaderboardSnapshotFetchClient(input) {
99
99
  throw new Error(`Verified leaderboard snapshot request failed: ${error}.`);
100
100
  }
101
101
  assertVerifiedLeaderboardSnapshot(body);
102
- return body;
102
+ return normalizeSnapshotMetrics(body);
103
103
  },
104
104
  };
105
105
  }
106
+ function normalizeSnapshotMetrics(input) {
107
+ const normalizeEntry = (entry) => ({
108
+ rank: entry.rank,
109
+ participantId: entry.participantId,
110
+ ...(entry.participantLabel === undefined
111
+ ? {}
112
+ : { participantLabel: entry.participantLabel }),
113
+ attemptId: entry.attemptId,
114
+ score: entry.score,
115
+ ...(entry.metrics === undefined
116
+ ? {}
117
+ : { metrics: normalizeVerifiedLeaderboardMetrics(entry.metrics) }),
118
+ completedAt: entry.completedAt,
119
+ });
120
+ return {
121
+ definition: input.definition,
122
+ entries: input.entries.map(normalizeEntry),
123
+ ...(input.participantEntry === undefined
124
+ ? {}
125
+ : { participantEntry: normalizeEntry(input.participantEntry) }),
126
+ totalParticipants: input.totalParticipants,
127
+ generatedAt: input.generatedAt,
128
+ ...(input.nextCursor === undefined ? {} : { nextCursor: input.nextCursor }),
129
+ };
130
+ }
106
131
  function readSnapshotRequest(url) {
107
132
  const allowedParameters = new Set(['leaderboardId', 'limit', 'cursor']);
108
133
  for (const parameter of url.searchParams.keys()) {
@@ -1,6 +1,9 @@
1
1
  export type VerifiedLeaderboardScoreOrder = 'ascending' | 'descending';
2
2
  export type VerifiedLeaderboardAttemptSelection = 'first' | 'best';
3
+ export type VerifiedLeaderboardMetrics = Readonly<Record<string, number>>;
3
4
  export declare const verifiedLeaderboardIdentifierMaximumLength = 512;
5
+ export declare const verifiedLeaderboardMetricsMaximumCount = 16;
6
+ export declare const verifiedLeaderboardMetricKeyMaximumLength = 64;
4
7
  export interface VerifiedLeaderboardDefinition {
5
8
  readonly leaderboardId: string;
6
9
  readonly scoreOrder: VerifiedLeaderboardScoreOrder;
@@ -16,6 +19,7 @@ export interface VerifiedLeaderboardAttempt {
16
19
  readonly participantLabel?: string;
17
20
  readonly attemptId: string;
18
21
  readonly score: number;
22
+ readonly metrics?: VerifiedLeaderboardMetrics;
19
23
  readonly completedAt: string;
20
24
  readonly verification: LeaderboardVerificationEvidence;
21
25
  }
@@ -29,6 +33,7 @@ export interface LeaderboardRankedEntry {
29
33
  readonly participantLabel?: string;
30
34
  readonly attemptId: string;
31
35
  readonly score: number;
36
+ readonly metrics?: VerifiedLeaderboardMetrics;
32
37
  readonly completedAt: string;
33
38
  }
34
39
  export interface RecordVerifiedLeaderboardAttemptResponse {
@@ -107,6 +112,10 @@ export declare function assertRecordVerifiedLeaderboardAttemptRequest(input: unk
107
112
  export declare function assertRecordVerifiedLeaderboardAttemptResponse(input: unknown): asserts input is RecordVerifiedLeaderboardAttemptResponse;
108
113
  export declare function assertGetVerifiedLeaderboardSnapshotRequest(input: unknown): asserts input is GetVerifiedLeaderboardSnapshotRequest;
109
114
  export declare function assertLeaderboardRankedEntry(input: unknown): asserts input is LeaderboardRankedEntry;
115
+ export declare function assertVerifiedLeaderboardMetrics(input: unknown): asserts input is VerifiedLeaderboardMetrics | undefined;
116
+ export declare function normalizeVerifiedLeaderboardMetrics(input: undefined): undefined;
117
+ export declare function normalizeVerifiedLeaderboardMetrics(input: VerifiedLeaderboardMetrics): VerifiedLeaderboardMetrics;
118
+ export declare function areVerifiedLeaderboardMetricsEqual(left: VerifiedLeaderboardMetrics | undefined, right: VerifiedLeaderboardMetrics | undefined): boolean;
110
119
  export declare function assertVerifiedLeaderboardSnapshot(input: unknown): asserts input is VerifiedLeaderboardSnapshot;
111
120
  export declare function createVerifiedLeaderboardCursor(definition: VerifiedLeaderboardDefinition, entry: LeaderboardRankedEntry): string;
112
121
  export declare function parseVerifiedLeaderboardCursor(cursor: string, definition: VerifiedLeaderboardDefinition): VerifiedLeaderboardCursorPosition;
@@ -1,4 +1,7 @@
1
1
  export const verifiedLeaderboardIdentifierMaximumLength = 512;
2
+ export const verifiedLeaderboardMetricsMaximumCount = 16;
3
+ export const verifiedLeaderboardMetricKeyMaximumLength = 64;
4
+ const verifiedLeaderboardMetricKeyPattern = /^[A-Za-z][A-Za-z0-9._-]{0,63}$/;
2
5
  const timestampPattern = new RegExp('^(\\d{4})-(\\d{2})-(\\d{2})'
3
6
  + 'T(\\d{2}):(\\d{2}):(\\d{2})'
4
7
  + '(?:\\.(\\d{1,3}))?(Z|([+-])(\\d{2}):(\\d{2}))$');
@@ -143,6 +146,7 @@ export function assertVerifiedLeaderboardAttempt(input) {
143
146
  assertOptionalNonEmptyString(input.participantLabel, 'participantLabel');
144
147
  assertVerifiedLeaderboardIdentifier(input.attemptId, 'attemptId');
145
148
  assertFiniteNumber(input.score, 'score');
149
+ assertVerifiedLeaderboardMetrics(input.metrics);
146
150
  assertTimestamp(input.completedAt, 'completedAt');
147
151
  assertLeaderboardVerificationEvidence(input.verification);
148
152
  }
@@ -188,8 +192,46 @@ export function assertLeaderboardRankedEntry(input) {
188
192
  assertOptionalNonEmptyString(input.participantLabel, 'participantLabel');
189
193
  assertVerifiedLeaderboardIdentifier(input.attemptId, 'attemptId');
190
194
  assertFiniteNumber(input.score, 'score');
195
+ assertVerifiedLeaderboardMetrics(input.metrics);
191
196
  assertTimestamp(input.completedAt, 'completedAt');
192
197
  }
198
+ export function assertVerifiedLeaderboardMetrics(input) {
199
+ if (input === undefined) {
200
+ return;
201
+ }
202
+ assertRecord(input, 'metrics');
203
+ const keys = Object.keys(input);
204
+ if (keys.length > verifiedLeaderboardMetricsMaximumCount) {
205
+ throw new Error(`metrics must contain at most ${String(verifiedLeaderboardMetricsMaximumCount)} keys.`);
206
+ }
207
+ for (const key of keys) {
208
+ if (!verifiedLeaderboardMetricKeyPattern.test(key)) {
209
+ throw new Error('metric keys must start with an ASCII letter and contain at most '
210
+ + `${String(verifiedLeaderboardMetricKeyMaximumLength)} ASCII letters, digits, dots, `
211
+ + 'underscores, or hyphens.');
212
+ }
213
+ const value = input[key];
214
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
215
+ throw new Error('metric values must be non-negative safe integers.');
216
+ }
217
+ }
218
+ }
219
+ export function normalizeVerifiedLeaderboardMetrics(input) {
220
+ assertVerifiedLeaderboardMetrics(input);
221
+ if (input === undefined) {
222
+ return undefined;
223
+ }
224
+ return Object.freeze(Object.fromEntries(Object.entries(input).sort(([left], [right]) => compareOrdinal(left, right))));
225
+ }
226
+ export function areVerifiedLeaderboardMetricsEqual(left, right) {
227
+ if (left === undefined || right === undefined) {
228
+ return left === right;
229
+ }
230
+ const leftKeys = Object.keys(left);
231
+ const rightKeys = Object.keys(right);
232
+ return leftKeys.length === rightKeys.length
233
+ && leftKeys.every((key) => left[key] === right[key]);
234
+ }
193
235
  export function assertVerifiedLeaderboardSnapshot(input) {
194
236
  assertRecord(input, 'VerifiedLeaderboardSnapshot');
195
237
  assertVerifiedLeaderboardDefinition(input.definition);
@@ -292,6 +334,7 @@ function toRankedEntry(attempt, rank) {
292
334
  : { participantLabel: attempt.participantLabel }),
293
335
  attemptId: attempt.attemptId,
294
336
  score: attempt.score,
337
+ ...verifiedLeaderboardMetricsProperty(attempt.metrics),
295
338
  completedAt: attempt.completedAt,
296
339
  };
297
340
  assertLeaderboardRankedEntry(entry);
@@ -301,6 +344,7 @@ function assertSameAttempt(existing, candidate) {
301
344
  if (existing.participantId !== candidate.participantId
302
345
  || existing.attemptId !== candidate.attemptId
303
346
  || existing.score !== candidate.score
347
+ || !areVerifiedLeaderboardMetricsEqual(existing.metrics, candidate.metrics)
304
348
  || parseTimestamp(existing.completedAt) !== parseTimestamp(candidate.completedAt)
305
349
  || existing.verification.authorityId !== candidate.verification.authorityId
306
350
  || existing.verification.evidenceId !== candidate.verification.evidenceId
@@ -324,6 +368,7 @@ function cloneVerifiedLeaderboardAttempt(input) {
324
368
  : { participantLabel: input.participantLabel }),
325
369
  attemptId: input.attemptId,
326
370
  score: input.score,
371
+ ...verifiedLeaderboardMetricsProperty(input.metrics),
327
372
  completedAt: input.completedAt,
328
373
  verification: {
329
374
  authorityId: input.verification.authorityId,
@@ -345,6 +390,7 @@ function cloneRecordResponse(input, alreadyProcessed) {
345
390
  : { participantLabel: input.entry.participantLabel }),
346
391
  attemptId: input.entry.attemptId,
347
392
  score: input.entry.score,
393
+ ...verifiedLeaderboardMetricsProperty(input.entry.metrics),
348
394
  completedAt: input.entry.completedAt,
349
395
  },
350
396
  ...(input.reason === undefined ? {} : { reason: input.reason }),
@@ -454,6 +500,9 @@ function compareOrdinal(left, right) {
454
500
  }
455
501
  return left > right ? 1 : 0;
456
502
  }
503
+ function verifiedLeaderboardMetricsProperty(metrics) {
504
+ return metrics === undefined ? {} : { metrics: normalizeVerifiedLeaderboardMetrics(metrics) };
505
+ }
457
506
  function compareRankedEntryToCursor(definition, entry, cursor) {
458
507
  if (entry.score !== cursor.score) {
459
508
  const comparison = entry.score < cursor.score ? -1 : 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -77,9 +77,9 @@
77
77
  "@orpc/client": "2.0.0-beta.14",
78
78
  "@orpc/contract": "2.0.0-beta.14",
79
79
  "@orpc/server": "2.0.0-beta.14",
80
+ "@mpgd/analytics": "0.3.5",
80
81
  "@mpgd/catalog": "0.3.5",
81
- "@mpgd/platform": "0.5.1",
82
- "@mpgd/analytics": "0.3.5"
82
+ "@mpgd/platform": "0.5.1"
83
83
  },
84
84
  "devDependencies": {
85
85
  "ttsc": "0.18.4",