@mpgd/game-services 0.15.1 → 0.15.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.
@@ -11,7 +11,7 @@ export const adMobSsvConformanceChecks = [
11
11
  'timestamp age and future-skew boundaries fail closed',
12
12
  'callbacks sharing one signed transaction preserve one authority identity',
13
13
  ];
14
- const fixtureTimestampMs = 1784160000000;
14
+ const fixtureTimestampMs = 1_784_160_000_000;
15
15
  const fixtureKeyId = '1234567890';
16
16
  const fixturePublicKey = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvYnh4p45wAsdENipvmqwXzvdNaCgRqx2IvxoYI8kiummEYytzHIhL738YLr3vs5vG/A8xY8QiCLDqVV2c5sUZw==';
17
17
  const firstFixtureSignature = 'MEYCIQDR1_VmQ4xw4WHff8KGDgv4insP7p5VAw8MhP5mZTFTgAIhAJ2Ejy5MU75UxeAxFqdLzvJwzL43qXl3PwuHMGYbjaiR';
@@ -67,7 +67,7 @@ async function createAdMobSsvConformanceContext(input, options) {
67
67
  callbackSource,
68
68
  publicKeySource,
69
69
  subtle,
70
- now: () => fixtureTimestampMs + 1000,
70
+ now: () => fixtureTimestampMs + 1_000,
71
71
  }),
72
72
  };
73
73
  }
@@ -96,7 +96,7 @@ export async function runAdMobSsvConformance(input = {}) {
96
96
  callbackSource: mapCallbackSource(new Map()),
97
97
  publicKeySource,
98
98
  subtle,
99
- now: () => fixtureTimestampMs + 1000,
99
+ now: () => fixtureTimestampMs + 1_000,
100
100
  }).verifyAdReward(verificationInput(firstRequest));
101
101
  assertDecision(pending, 'pending', 'ADMOB_SSV_EVIDENCE_PENDING');
102
102
  const tamperedCallback = fixtureCallback('reward-1', `${firstFixtureSignature.slice(0, -1)}S`);
@@ -104,14 +104,14 @@ export async function runAdMobSsvConformance(input = {}) {
104
104
  callbackSource: fixedCallbackSource(tamperedCallback),
105
105
  publicKeySource,
106
106
  subtle,
107
- now: () => fixtureTimestampMs + 1000,
107
+ now: () => fixtureTimestampMs + 1_000,
108
108
  }).verifyAdReward(verificationInput(firstRequest));
109
109
  assertDecision(tampered, 'rejected', 'ADMOB_SSV_SIGNATURE_INVALID');
110
110
  const equivalentPercentEncoding = await createAdMobSsvEvidenceVerifier({
111
111
  callbackSource: fixedCallbackSource(fixtureCallback('reward-1', firstFixtureSignature).replace('%7B', '%7b')),
112
112
  publicKeySource,
113
113
  subtle,
114
- now: () => fixtureTimestampMs + 1000,
114
+ now: () => fixtureTimestampMs + 1_000,
115
115
  }).verifyAdReward(verificationInput(firstRequest));
116
116
  assert(equivalentPercentEncoding.status === 'verified', 'equivalent percent-encoding must verify against AdMob-decoded query bytes');
117
117
  const validCallback = fixtureCallback('reward-1', firstFixtureSignature);
@@ -119,21 +119,21 @@ export async function runAdMobSsvConformance(input = {}) {
119
119
  callbackSource: fixedCallbackSource(validCallback.replace('&signature=', '&user_id=player-1&signature=')),
120
120
  publicKeySource,
121
121
  subtle,
122
- now: () => fixtureTimestampMs + 1000,
122
+ now: () => fixtureTimestampMs + 1_000,
123
123
  }).verifyAdReward(verificationInput(firstRequest));
124
124
  assertDecision(duplicateSignedField, 'rejected', 'ADMOB_SSV_CALLBACK_INVALID');
125
125
  const missingSignedField = await createAdMobSsvEvidenceVerifier({
126
126
  callbackSource: fixedCallbackSource(validCallback.replace('&user_id=player-1', '')),
127
127
  publicKeySource,
128
128
  subtle,
129
- now: () => fixtureTimestampMs + 1000,
129
+ now: () => fixtureTimestampMs + 1_000,
130
130
  }).verifyAdReward(verificationInput(firstRequest));
131
131
  assertDecision(missingSignedField, 'rejected', 'ADMOB_SSV_CALLBACK_INVALID');
132
132
  const unknownKey = await createAdMobSsvEvidenceVerifier({
133
133
  callbackSource: fixedCallbackSource(fixtureCallback('reward-1', firstFixtureSignature).replace(`key_id=${fixtureKeyId}`, 'key_id=9999999999')),
134
134
  publicKeySource,
135
135
  subtle,
136
- now: () => fixtureTimestampMs + 1000,
136
+ now: () => fixtureTimestampMs + 1_000,
137
137
  }).verifyAdReward(verificationInput(firstRequest));
138
138
  assertDecision(unknownKey, 'rejected', 'ADMOB_SSV_KEY_UNAVAILABLE');
139
139
  const secp256k1Decision = await verifier.verifyAdReward(verificationInput(rewardRequest('reward-secp')));
@@ -149,28 +149,28 @@ export async function runAdMobSsvConformance(input = {}) {
149
149
  callbackSource,
150
150
  publicKeySource,
151
151
  subtle,
152
- now: () => fixtureTimestampMs + 86400001,
152
+ now: () => fixtureTimestampMs + 86_400_001,
153
153
  }).verifyAdReward(verificationInput(firstRequest));
154
154
  assertDecision(expired, 'rejected', 'ADMOB_SSV_CALLBACK_EXPIRED');
155
155
  const future = await createAdMobSsvEvidenceVerifier({
156
156
  callbackSource,
157
157
  publicKeySource,
158
158
  subtle,
159
- now: () => fixtureTimestampMs - 300001,
159
+ now: () => fixtureTimestampMs - 300_001,
160
160
  }).verifyAdReward(verificationInput(firstRequest));
161
161
  assertDecision(future, 'rejected', 'ADMOB_SSV_TIMESTAMP_IN_FUTURE');
162
162
  const ageBoundary = await createAdMobSsvEvidenceVerifier({
163
163
  callbackSource,
164
164
  publicKeySource,
165
165
  subtle,
166
- now: () => fixtureTimestampMs + 86400000,
166
+ now: () => fixtureTimestampMs + 86_400_000,
167
167
  }).verifyAdReward(verificationInput(firstRequest));
168
168
  assert(ageBoundary.status === 'verified', 'the maximum callback age boundary must remain valid');
169
169
  const futureSkewBoundary = await createAdMobSsvEvidenceVerifier({
170
170
  callbackSource,
171
171
  publicKeySource,
172
172
  subtle,
173
- now: () => fixtureTimestampMs - 300000,
173
+ now: () => fixtureTimestampMs - 300_000,
174
174
  }).verifyAdReward(verificationInput(firstRequest));
175
175
  assert(futureSkewBoundary.status === 'verified', 'the maximum future skew boundary must remain valid');
176
176
  const sharedTransactionDecision = await verifier.verifyAdReward(verificationInput(rewardRequest('reward-2')));
@@ -188,7 +188,7 @@ function verificationInput(request) {
188
188
  placement,
189
189
  platformPlacementId: placement.platformPlacementIds.android,
190
190
  signal: new AbortController().signal,
191
- timeoutMs: 10000,
191
+ timeoutMs: 10_000,
192
192
  };
193
193
  }
194
194
  function rewardRequest(idempotencyKey) {
package/dist/admob-ssv.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { Point, verify as verifySecp256k1 } from '@noble/secp256k1';
2
2
  export const admobSsvCustomDataSchema = 'mpgd.admob.ssv.binding.v1';
3
- export const defaultAdMobSsvMaximumCallbackAgeMs = 86400000;
4
- export const defaultAdMobSsvMaximumFutureSkewMs = 300000;
3
+ export const defaultAdMobSsvMaximumCallbackAgeMs = 86_400_000;
4
+ export const defaultAdMobSsvMaximumFutureSkewMs = 300_000;
5
5
  const signatureMarker = '&signature=';
6
- const maximumCallbackUrlLength = 65536;
7
- const maximumDateTimestampMs = 8640000000000000;
6
+ const maximumCallbackUrlLength = 65_536;
7
+ const maximumDateTimestampMs = 8_640_000_000_000_000;
8
8
  const ecPublicKeyObjectIdentifier = Uint8Array.of(0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01);
9
9
  const p256ObjectIdentifier = Uint8Array.of(0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07);
10
10
  const p384ObjectIdentifier = Uint8Array.of(0x2b, 0x81, 0x04, 0x00, 0x22);
@@ -391,7 +391,7 @@ async function runRewardTimestampValidationScenario(createVerifier, now) {
391
391
  async function runAuthorityErrorsAndRewardMatchingScenario(createVerifier, now) {
392
392
  const fixture = createAppsInTossProductionEvidenceAuthorityFixture();
393
393
  const diagnosticReason = `Authority temporarily unavailable: ${'x'.repeat(512)} `;
394
- const oversizedDiagnosticReason = 'x'.repeat(4097);
394
+ const oversizedDiagnosticReason = 'x'.repeat(4_097);
395
395
  fixture.enqueuePurchaseResult({ decision: 'rejected', reason: diagnosticReason });
396
396
  fixture.enqueuePurchaseResult({ decision: 'pending', reason: diagnosticReason });
397
397
  fixture.enqueuePurchaseResult({ decision: 'pending', reason: oversizedDiagnosticReason });
@@ -1,10 +1,10 @@
1
1
  export const appsInTossPurchaseCallbackEvidenceSchema = 'apps-in-toss.iap.callback.v1';
2
2
  export const appsInTossRewardCallbackEvidenceSchema = 'apps-in-toss.rewarded-ad.callback.v1';
3
3
  /** Leaves five seconds of headroom inside the SDK's documented 30-second window. */
4
- export const appsInTossProductGrantCallbackTimeoutMs = 25000;
4
+ export const appsInTossProductGrantCallbackTimeoutMs = 25_000;
5
5
  // Offset-free order-status timestamps are documented as KST (UTC+09:00).
6
6
  const appsInTossKstOffsetMinutes = 9 * 60;
7
- const appsInTossAuthorityReasonMaxLength = 4096;
7
+ const appsInTossAuthorityReasonMaxLength = 4_096;
8
8
  const appsInTossTimestampPattern = new RegExp(String.raw `^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})`
9
9
  + String.raw `(?:\.(\d{1,3}))?(Z|([+-])(\d{2}):(\d{2}))?$`, 'u');
10
10
  const appsInTossProductGrantVerificationPortBrand = Symbol('AppsInTossProductGrantVerificationPort');
@@ -393,7 +393,7 @@ function normalizeTimestamp(value, offsetFreeOffsetMinutes) {
393
393
  if (offsetMinutes === undefined) {
394
394
  return undefined;
395
395
  }
396
- return new Date(wallClock - offsetMinutes * 60000).toISOString();
396
+ return new Date(wallClock - offsetMinutes * 60_000).toISOString();
397
397
  }
398
398
  function pending(reason) {
399
399
  return { status: 'pending', reason };
@@ -1,11 +1,11 @@
1
1
  export const appsInTossPartnerApiBaseUrl = 'https://apps-in-toss-api.toss.im';
2
- export const defaultAppsInTossPartnerApiTimeoutMs = 10000;
2
+ export const defaultAppsInTossPartnerApiTimeoutMs = 10_000;
3
3
  const verifyAnonymousKeyPath = 'api-partner/v1/apps-in-toss/users/anon-key/verify';
4
4
  const sendFunctionalMessagePath = 'api-partner/v1/apps-in-toss/messenger/send-message';
5
5
  const exchangeLoginAuthorizationCodePath = 'api-partner/v1/apps-in-toss/user/oauth2/generate-token';
6
6
  const getLoginUserPath = 'api-partner/v1/apps-in-toss/user/oauth2/login-me';
7
7
  const getIapOrderStatusPath = 'api-partner/v1/apps-in-toss/order/get-order-status';
8
- const maximumResponseBodyBytes = 256 * 1024;
8
+ const maximumResponseBodyBytes = 256 * 1_024;
9
9
  export class AppsInTossPartnerApiError extends Error {
10
10
  status;
11
11
  code;
@@ -82,7 +82,7 @@ export function createAppsInTossPartnerApiClient(input) {
82
82
  return parseLoginToken(envelope.success, response.status);
83
83
  },
84
84
  async getLoginUser(request) {
85
- const accessToken = normalizeOpaqueValue(request.accessToken, 'accessToken', 16384);
85
+ const accessToken = normalizeOpaqueValue(request.accessToken, 'accessToken', 16_384);
86
86
  const response = await requestJson({
87
87
  mtls: input.mtls,
88
88
  method: 'GET',
@@ -233,7 +233,7 @@ function requireSuccessEnvelope(response) {
233
233
  const error = isRecord(response.body.error) ? response.body.error : undefined;
234
234
  const code = typeof error?.errorCode === 'string' ? error.errorCode : undefined;
235
235
  const reason = typeof error?.reason === 'string'
236
- ? error.reason.slice(0, 1024)
236
+ ? error.reason.slice(0, 1_024)
237
237
  : 'Apps in Toss partner API request failed.';
238
238
  throw new AppsInTossPartnerApiError(reason, response.status, code);
239
239
  }
@@ -261,10 +261,10 @@ function parseLoginToken(input, status) {
261
261
  }
262
262
  return {
263
263
  tokenType,
264
- accessToken: requireOpaqueValue(input.accessToken, 'accessToken', status, 16384),
265
- refreshToken: requireOpaqueValue(input.refreshToken, 'refreshToken', status, 16384),
264
+ accessToken: requireOpaqueValue(input.accessToken, 'accessToken', status, 16_384),
265
+ refreshToken: requireOpaqueValue(input.refreshToken, 'refreshToken', status, 16_384),
266
266
  expiresInSeconds: requirePositiveInteger(input.expiresIn, 'expiresIn', status),
267
- scope: requireOpaqueValue(input.scope, 'scope', status, 4096),
267
+ scope: requireOpaqueValue(input.scope, 'scope', status, 4_096),
268
268
  };
269
269
  }
270
270
  function parseLoginUser(input, status) {
@@ -273,7 +273,7 @@ function parseLoginUser(input, status) {
273
273
  }
274
274
  return {
275
275
  userKey: requireTossUserKey(input.userKey, 'userKey', status),
276
- scope: requireOpaqueValue(input.scope, 'scope', status, 4096),
276
+ scope: requireOpaqueValue(input.scope, 'scope', status, 4_096),
277
277
  };
278
278
  }
279
279
  function parseIapOrderStatus(input, status) {
@@ -286,13 +286,13 @@ function parseIapOrderStatus(input, status) {
286
286
  }
287
287
  const reason = input.reason;
288
288
  return {
289
- orderId: requireOpaqueValue(input.orderId, 'orderId', status, 2048),
290
- sku: requireOpaqueValue(input.sku, 'sku', status, 2048),
291
- statusDeterminedAt: requireOpaqueValue(input.statusDeterminedAt, 'statusDeterminedAt', status, 2048),
289
+ orderId: requireOpaqueValue(input.orderId, 'orderId', status, 2_048),
290
+ sku: requireOpaqueValue(input.sku, 'sku', status, 2_048),
291
+ statusDeterminedAt: requireOpaqueValue(input.statusDeterminedAt, 'statusDeterminedAt', status, 2_048),
292
292
  status: orderStatus,
293
293
  ...(reason === undefined
294
294
  ? {}
295
- : { reason: requireOpaqueValue(reason, 'reason', status, 4096) }),
295
+ : { reason: requireOpaqueValue(reason, 'reason', status, 4_096) }),
296
296
  };
297
297
  }
298
298
  const appsInTossIapOrderStatuses = new Set([
@@ -358,7 +358,7 @@ function normalizeContext(input) {
358
358
  function normalizeIdentifier(value, field) {
359
359
  const normalized = value.trim();
360
360
  if (normalized.length === 0
361
- || normalized.length > 2048
361
+ || normalized.length > 2_048
362
362
  || /[\p{Cc}\p{Cf}]/u.test(normalized)) {
363
363
  throw new TypeError(`${field} must contain 1 to 2048 characters without control or format characters.`);
364
364
  }
@@ -444,7 +444,7 @@ function normalizeBaseUrl(input) {
444
444
  }
445
445
  function normalizeTimeout(value) {
446
446
  const timeoutMs = value ?? defaultAppsInTossPartnerApiTimeoutMs;
447
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000) {
447
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
448
448
  throw new TypeError('AIT partner API timeoutMs must be between 1 and 60000.');
449
449
  }
450
450
  return timeoutMs;
@@ -1,7 +1,7 @@
1
1
  export const microsoftStoreIdentityCredentialsSchema = 'mpgd.microsoft-store.identity-credentials.v1';
2
2
  export const microsoftStoreIdentityCredentialsRequestUrl = 'https://microsoft-store-authority.internal/v1/credentials';
3
- const defaultTimeoutMs = 8000;
4
- const maximumResponseBytes = 16 * 1024;
3
+ const defaultTimeoutMs = 8_000;
4
+ const maximumResponseBytes = 16 * 1_024;
5
5
  const gameIdPattern = /^[a-z0-9][a-z0-9-]{0,63}$/u;
6
6
  export class MicrosoftStoreIdentityAuthorityError extends Error {
7
7
  code;
@@ -45,8 +45,8 @@ export function parseMicrosoftStoreIdentityCredentialsResponse(value, expectedRe
45
45
  schema: microsoftStoreIdentityCredentialsSchema,
46
46
  gameId: requireGameId(record.gameId),
47
47
  playerId: requireText(record.playerId, 'response playerId', 256),
48
- accessToken: requireToken(record.accessToken, 'Microsoft Store access token', 4096),
49
- userStoreId: requireToken(record.userStoreId, 'Microsoft Store User Store ID', 4096),
48
+ accessToken: requireToken(record.accessToken, 'Microsoft Store access token', 4_096),
49
+ userStoreId: requireToken(record.userStoreId, 'Microsoft Store User Store ID', 4_096),
50
50
  accountBindingId: requireText(record.accountBindingId, 'Microsoft Store account binding ID', 512),
51
51
  ...(record.sandbox === undefined
52
52
  ? {}
@@ -71,7 +71,7 @@ export async function resolveMicrosoftStoreIdentityCredentials(input) {
71
71
  playerId: input.playerId,
72
72
  });
73
73
  const timeoutMs = input.timeoutMs ?? defaultTimeoutMs;
74
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 25000) {
74
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 25_000) {
75
75
  throw new TypeError('Microsoft Store identity timeout is invalid.');
76
76
  }
77
77
  const abort = createAbortScope(input.signal, timeoutMs);
@@ -17,7 +17,7 @@ const notificationTopics = new Set([
17
17
  'streak-at-risk',
18
18
  'friend-challenge',
19
19
  ]);
20
- const defaultClaimLeaseMs = 5 * 60 * 1000;
20
+ const defaultClaimLeaseMs = 5 * 60 * 1_000;
21
21
  const maxCompletionAttempts = 3;
22
22
  const maxNotificationTemplateDataEntries = 128;
23
23
  /** Throw only when the provider can prove that no external send was attempted. */
@@ -322,7 +322,7 @@ function normalizeTemplateData(input) {
322
322
  if (typeof value === 'number' && !Number.isFinite(value)) {
323
323
  throw new Error(`templateData.${key} must be a finite number.`);
324
324
  }
325
- if (typeof value === 'string' && value.length > 16384) {
325
+ if (typeof value === 'string' && value.length > 16_384) {
326
326
  throw new Error(`templateData.${key} must contain at most 16384 characters.`);
327
327
  }
328
328
  return [normalizedKey, value];
@@ -390,7 +390,7 @@ function normalizeLeaseDuration(input) {
390
390
  if (typeof input !== 'number'
391
391
  || !Number.isSafeInteger(input)
392
392
  || input <= 0
393
- || input > 24 * 60 * 60 * 1000) {
393
+ || input > 24 * 60 * 60 * 1_000) {
394
394
  throw new Error('claimLeaseMs must be a positive integer no greater than 24 hours.');
395
395
  }
396
396
  return input;
@@ -427,7 +427,7 @@ function assertRecord(input, label) {
427
427
  function normalizeIdentifier(input, label) {
428
428
  if (typeof input !== 'string'
429
429
  || input.length === 0
430
- || input.length > 2048
430
+ || input.length > 2_048
431
431
  || input.trim() !== input) {
432
432
  throw new Error(`${label} must be a non-empty, trimmed string.`);
433
433
  }
@@ -4,8 +4,8 @@ export const gameProgressLimits = {
4
4
  maxCompletedIds: 512,
5
5
  maxMetricEntries: 512,
6
6
  maxPayloadDepth: 16,
7
- maxPayloadNodes: 2048,
8
- maxPayloadStringUnits: 32768,
7
+ maxPayloadNodes: 2_048,
8
+ maxPayloadStringUnits: 32_768,
9
9
  };
10
10
  const snapshotFields = new Set(['completedIds', 'bestTimesMs', 'bestScores', 'activeProgress']);
11
11
  const activeProgressFields = new Set(['id', 'updatedAt', 'payload']);
package/dist/server.js CHANGED
@@ -994,14 +994,14 @@ function matchesEntitlementTransactionIdentity(transaction, identity) {
994
994
  && transaction.payload.deploymentTarget === identity.deploymentTarget;
995
995
  }
996
996
  function resolveEvidenceVerificationTimeout(timeoutMs) {
997
- const resolved = timeoutMs ?? 10000;
997
+ const resolved = timeoutMs ?? 10_000;
998
998
  if (!Number.isFinite(resolved) || resolved <= 0) {
999
999
  throw new Error('evidenceVerificationTimeoutMs must be a positive finite number.');
1000
1000
  }
1001
1001
  return resolved;
1002
1002
  }
1003
1003
  function resolvePurchaseGrantFinalizationTimeout(timeoutMs) {
1004
- const resolved = timeoutMs ?? 15000;
1004
+ const resolved = timeoutMs ?? 15_000;
1005
1005
  if (!Number.isFinite(resolved) || resolved <= 0) {
1006
1006
  throw new Error('purchaseGrantFinalizationTimeoutMs must be a positive finite number.');
1007
1007
  }
@@ -71,7 +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
+ metrics: { elapsedMs: 20_000, hints: 1, mistakes: 2 },
75
75
  completedAt: '2030-01-02T02:02:00.000Z',
76
76
  });
77
77
  const laterRecord = await context.service.recordVerifiedAttempt(laterRequest);
@@ -81,7 +81,7 @@ async function runFirstSelectionAndSnapshotScenario(context) {
81
81
  participantLabel: 'First Player',
82
82
  attemptId: 'attempt:earlier',
83
83
  score: 30,
84
- metrics: { elapsedMs: 30000, hints: 0, mistakes: 1 },
84
+ metrics: { elapsedMs: 30_000, hints: 0, mistakes: 1 },
85
85
  completedAt: '2030-01-02T02:01:00.000Z',
86
86
  }));
87
87
  await context.service.recordVerifiedAttempt(createAttempt({
@@ -99,12 +99,12 @@ async function runFirstSelectionAndSnapshotScenario(context) {
99
99
  participantLabel: 'First Player',
100
100
  attemptId: 'attempt:latest',
101
101
  score: 5,
102
- metrics: { elapsedMs: 5000, hints: 2, mistakes: 0 },
102
+ metrics: { elapsedMs: 5_000, hints: 2, mistakes: 0 },
103
103
  completedAt: '2030-01-02T02:04:00.000Z',
104
104
  }));
105
105
  assertEqual(latestRecord.retained, false, 'later completions must not replace the earliest retained attempt');
106
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');
107
+ assertEqual(latestRecord.entry.metrics?.elapsedMs, 30_000, 'rejected attempts must report the retained entry metrics');
108
108
  const snapshot = await context.service.getSnapshot({
109
109
  leaderboardId: 'first:board',
110
110
  participantId: 'participant:first',
@@ -314,7 +314,7 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
314
314
  participantLabel: 'Original Label',
315
315
  attemptId: 'attempt:identity',
316
316
  score: 42,
317
- metrics: { elapsedMs: 42000, hints: 1, mistakes: 2 },
317
+ metrics: { elapsedMs: 42_000, hints: 1, mistakes: 2 },
318
318
  completedAt: '2030-01-02T02:30:00.000Z',
319
319
  });
320
320
  await context.service.recordVerifiedAttempt(request);
@@ -323,7 +323,7 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
323
323
  attempt: {
324
324
  ...request.attempt,
325
325
  participantLabel: 'Changed Label',
326
- metrics: { mistakes: 2, elapsedMs: 42000, hints: 1 },
326
+ metrics: { mistakes: 2, elapsedMs: 42_000, hints: 1 },
327
327
  completedAt: '2030-01-02T11:30:00.000+09:00',
328
328
  verification: {
329
329
  ...request.attempt.verification,
@@ -334,7 +334,7 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
334
334
  assertEqual(equivalentRetry.alreadyProcessed, true, 'equivalent retries must be idempotent');
335
335
  assertEqual(equivalentRetry.entry.participantLabel, 'Original Label', 'retry labels must not rewrite stored presentation metadata');
336
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');
337
+ assertEqual(equivalentRetry.entry.metrics?.elapsedMs, 42_000, 'equivalent retries must preserve the original metrics');
338
338
  await assertRejects(() => context.service.recordVerifiedAttempt({
339
339
  ...request,
340
340
  attempt: { ...request.attempt, score: 43 },
@@ -343,7 +343,7 @@ async function runIdentityAndDefinitionConflictsScenario(context) {
343
343
  ...request,
344
344
  attempt: {
345
345
  ...request.attempt,
346
- metrics: { elapsedMs: 42000, hints: 0, mistakes: 2 },
346
+ metrics: { elapsedMs: 42_000, hints: 0, mistakes: 2 },
347
347
  },
348
348
  }), 'attempt IDs reused with different metrics must fail closed');
349
349
  await assertRejects(() => context.service.recordVerifiedAttempt({
@@ -432,7 +432,7 @@ async function runMutationIsolationScenario(context) {
432
432
  participantLabel: 'Stable Label',
433
433
  attemptId: 'attempt:mutation',
434
434
  score: 50,
435
- metrics: { elapsedMs: 50000, hints: 1, mistakes: 2 },
435
+ metrics: { elapsedMs: 50_000, hints: 1, mistakes: 2 },
436
436
  completedAt: '2030-01-02T02:50:00.000Z',
437
437
  });
438
438
  const response = await context.service.recordVerifiedAttempt(request);
@@ -465,7 +465,7 @@ async function runMutationIsolationScenario(context) {
465
465
  assert(participantEntry !== undefined, 'participant reads must return the retained entry');
466
466
  assertEqual(snapshot.definition.scoreOrder, 'ascending', 'stored definitions must be cloned');
467
467
  assertEqual(snapshotEntry.score, 50, 'stored attempts must resist caller mutation');
468
- assertEqual(snapshotEntry.metrics?.elapsedMs, 50000, 'stored metrics must be cloned');
468
+ assertEqual(snapshotEntry.metrics?.elapsedMs, 50_000, 'stored metrics must be cloned');
469
469
  assertEqual(snapshotEntry.participantLabel, 'Stable Label', 'response mutation must not rewrite retained entries');
470
470
  try {
471
471
  Object.assign(snapshot.definition, { scoreOrder: 'descending' });
@@ -510,7 +510,7 @@ async function runMutationIsolationScenario(context) {
510
510
  assertEqual(rereadSnapshot?.definition.scoreOrder, 'ascending', 'snapshot definition mutation must not rewrite stored policy');
511
511
  assertEqual(rereadSnapshot?.entries[0]?.score, 50, 'snapshot entry mutation must not rewrite retained attempts');
512
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');
513
+ assertEqual(rereadSnapshot?.entries[0]?.metrics?.elapsedMs, 50_000, 'snapshot metric mutation must not rewrite retained metrics');
514
514
  assertEqual(rereadSnapshot?.entries.length, 1, 'snapshot array mutation must not rewrite retained entries');
515
515
  assertEqual(rereadSnapshot?.participantEntry?.score, 50, 'participant entry mutation must not rewrite retained attempts');
516
516
  assertEqual(rereadSnapshot?.participantEntry?.participantLabel, 'Stable Label', 'participant entry mutation must not rewrite presentation metadata');
@@ -572,7 +572,7 @@ function parseTimestamp(input) {
572
572
  const offsetDirection = match[9] === '-' ? -1 : 1;
573
573
  const offsetMilliseconds = offsetDirection
574
574
  * (offsetHour * 60 + offsetMinute)
575
- * 60000;
575
+ * 60_000;
576
576
  return localTime.getTime() - offsetMilliseconds;
577
577
  }
578
578
  function compareOrdinal(left, right) {
@@ -601,7 +601,7 @@ function assertOptionalCursor(input) {
601
601
  }
602
602
  if (typeof input !== 'string'
603
603
  || input.length === 0
604
- || input.length > 65536
604
+ || input.length > 65_536
605
605
  || !/^[A-Za-z0-9_-]+$/u.test(input)) {
606
606
  throw new VerifiedLeaderboardCursorError('cursor must be a base64url string no longer than 65536 characters.');
607
607
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -138,12 +138,12 @@
138
138
  "@orpc/client": "2.0.0-beta.14",
139
139
  "@orpc/contract": "2.0.0-beta.14",
140
140
  "@orpc/server": "2.0.0-beta.14",
141
- "@mpgd/analytics": "0.3.12",
142
- "@mpgd/catalog": "0.7.2",
143
- "@mpgd/platform": "0.12.0"
141
+ "@mpgd/catalog": "0.7.3",
142
+ "@mpgd/platform": "0.12.1",
143
+ "@mpgd/analytics": "0.3.13"
144
144
  },
145
145
  "devDependencies": {
146
- "ttsc": "0.18.4",
146
+ "ttsc": "0.30.4",
147
147
  "typescript": "7.0.2"
148
148
  },
149
149
  "main": "./dist/index.js",