@my-swu/simulator-client 0.1.6 → 0.1.7
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/README.md +29 -0
- package/dist/client.js +6 -1
- package/dist/connection/url.d.ts +2 -0
- package/dist/connection/url.js +4 -0
- package/dist/generated/index.d.ts +4 -0
- package/dist/generated/schemas.d.ts +1 -1
- package/dist/generated/schemas.js +1405 -23
- package/dist/generated/types/admin-match-debug-bundle.d.ts +1 -1
- package/dist/generated/types/admin-match-list-response.d.ts +1 -1
- package/dist/generated/types/admin-match-replay-response.d.ts +1 -1
- package/dist/generated/types/common.d.ts +445 -0
- package/dist/generated/types/create-limited-event-request.d.ts +1 -1
- package/dist/generated/types/format-event-response.d.ts +1 -1
- package/dist/generated/types/game-event.d.ts +1 -1
- package/dist/generated/types/game-state.d.ts +1 -1
- package/dist/generated/types/get-match-response.d.ts +1 -1
- package/dist/generated/types/leave-limited-event-request.d.ts +1 -0
- package/dist/generated/types/leave-limited-event-request.js +2 -0
- package/dist/generated/types/limited-event-server-message.d.ts +1 -0
- package/dist/generated/types/limited-event-server-message.js +2 -0
- package/dist/generated/types/list-active-ai-matches-response.d.ts +1 -1
- package/dist/generated/types/lobby-access-response.d.ts +1 -1
- package/dist/generated/types/lobby-chat-response.d.ts +1 -1
- package/dist/generated/types/lobby-response.d.ts +1 -1
- package/dist/generated/types/lobby-server-message.d.ts +1 -1
- package/dist/generated/types/match-access-recovery-response.d.ts +1 -1
- package/dist/generated/types/match-access-response.d.ts +1 -1
- package/dist/generated/types/match-history-replay-response.d.ts +1 -1
- package/dist/generated/types/match-recovery-response.d.ts +1 -1
- package/dist/generated/types/match-spectator-access-response.d.ts +1 -1
- package/dist/generated/types/rematch-match-response.d.ts +1 -1
- package/dist/generated/types/restore-history-response.d.ts +1 -1
- package/dist/generated/types/restore-match-save-response.d.ts +1 -1
- package/dist/generated/types/server-message.d.ts +1 -1
- package/dist/generated/types/shared-match-history-replay-response.d.ts +1 -1
- package/dist/generated/types/submit-ai-game-feedback-request.d.ts +1 -0
- package/dist/generated/types/submit-ai-game-feedback-request.js +2 -0
- package/dist/generated/types/submit-ai-game-feedback-response.d.ts +1 -0
- package/dist/generated/types/submit-ai-game-feedback-response.js +2 -0
- package/dist/generated/types/submit-match-deck-response.d.ts +1 -1
- package/dist/generated/types/tournament-room-access-response.d.ts +1 -1
- package/dist/generated/types/tournament-room-server-message.d.ts +1 -1
- package/dist/generated/types/undo-match-response.d.ts +1 -1
- package/dist/generated/types/update-lobby-settings-request.d.ts +1 -1
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/http/request.d.ts +2 -0
- package/dist/http/request.js +9 -1
- package/dist/i18n/card-print-catalog.d.ts +9 -0
- package/dist/i18n/card-print-catalog.js +260 -0
- package/dist/i18n/catalog.d.ts +8 -0
- package/dist/i18n/catalog.js +195 -0
- package/dist/i18n/diagnostics.d.ts +6 -0
- package/dist/i18n/diagnostics.js +19 -0
- package/dist/i18n/fallback.d.ts +3 -0
- package/dist/i18n/fallback.js +40 -0
- package/dist/i18n/format.d.ts +8 -0
- package/dist/i18n/format.js +29 -0
- package/dist/i18n/generated/message-types.d.ts +91 -0
- package/dist/i18n/generated/message-types.js +3 -0
- package/dist/i18n/icu.d.ts +12 -0
- package/dist/i18n/icu.js +75 -0
- package/dist/i18n/index.d.ts +9 -0
- package/dist/i18n/index.js +5 -0
- package/dist/i18n/locale.d.ts +9 -0
- package/dist/i18n/locale.js +19 -0
- package/dist/i18n/types.d.ts +151 -0
- package/dist/i18n/types.js +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/internal/authenticated-socket-session.d.ts +2 -0
- package/dist/internal/authenticated-socket-session.js +3 -1
- package/dist/local/client.js +7 -2
- package/dist/local/server/http-router.js +18 -0
- package/dist/resources/admin.d.ts +8 -0
- package/dist/resources/admin.js +2 -0
- package/dist/resources/limited-event-session.d.ts +10 -0
- package/dist/resources/limited-event-session.js +35 -0
- package/dist/resources/limited-event-types.d.ts +34 -1
- package/dist/resources/limited-events.d.ts +8 -1
- package/dist/resources/limited-events.js +18 -1
- package/dist/resources/lobbies.d.ts +1 -1
- package/dist/resources/lobbies.js +1 -1
- package/dist/resources/lobby-types.d.ts +15 -2
- package/dist/resources/match-history.d.ts +8 -1
- package/dist/resources/match-history.js +10 -0
- package/dist/resources/tournament-rooms.d.ts +1 -1
- package/dist/resources/tournament-rooms.js +1 -1
- package/dist/validators.d.ts +5 -1
- package/i18n/cards/de.json +121 -0
- package/i18n/cards/en.json +121 -0
- package/i18n/cards/es.json +121 -0
- package/i18n/cards/fr.json +121 -0
- package/i18n/cards/it.json +121 -0
- package/i18n/cards/manifest.json +2715 -0
- package/i18n/de.json +11 -0
- package/i18n/en.json +11 -0
- package/i18n/es.json +11 -0
- package/i18n/fr.json +11 -0
- package/i18n/it.json +11 -0
- package/i18n/messages.schema.json +28 -0
- package/package.json +12 -2
- package/schema/common.schema.json +1092 -1
- package/schema/create-limited-event-request.schema.json +11 -0
- package/schema/game-event.schema.json +76 -0
- package/schema/leave-limited-event-request.schema.json +15 -0
- package/schema/limited-event-server-message.schema.json +63 -0
- package/schema/manifest.json +5 -1
- package/schema/route-manifest.json +54 -0
- package/schema/submit-ai-game-feedback-request.schema.json +36 -0
- package/schema/submit-ai-game-feedback-response.schema.json +19 -0
- package/schema/update-lobby-settings-request.schema.json +67 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { resolveLocale } from './locale.js';
|
|
2
|
+
const fieldNames = ['title', 'subtitle', 'text', 'deployBox', 'epicAction', 'rules'];
|
|
3
|
+
/** Requires complete full-print data from a format-2 compiler shard. */
|
|
4
|
+
export function requiredPrints(input) {
|
|
5
|
+
if (input.prints == null)
|
|
6
|
+
throw new Error('format 2 card catalog lacks prints');
|
|
7
|
+
return input.prints;
|
|
8
|
+
}
|
|
9
|
+
/** Copies and validates full physical-print fields without accepting stale source strings. */
|
|
10
|
+
export function copyPrints(source, manifest, locale) {
|
|
11
|
+
const sourceIds = Object.keys(source).sort();
|
|
12
|
+
const aliasIds = Object.keys(manifest.canonicalAliases).sort();
|
|
13
|
+
if (sourceIds.length !== aliasIds.length || sourceIds.some((id, index) => id !== aliasIds[index])) {
|
|
14
|
+
throw new Error('card catalog print set does not match manifest aliases');
|
|
15
|
+
}
|
|
16
|
+
const prints = Object.create(null);
|
|
17
|
+
for (const [printId, entry] of Object.entries(source)) {
|
|
18
|
+
if (!isPositiveNumericId(printId) || !isRecord(entry) || !isRecord(entry.fields)) {
|
|
19
|
+
throw new Error(`invalid card catalog print ${printId}`);
|
|
20
|
+
}
|
|
21
|
+
const requestedPrintId = entry.requestedPrintId;
|
|
22
|
+
const canonicalCardId = entry.canonicalCardId;
|
|
23
|
+
if (!Number.isSafeInteger(requestedPrintId) || requestedPrintId !== Number(printId)
|
|
24
|
+
|| !Number.isSafeInteger(canonicalCardId) || canonicalCardId <= 0
|
|
25
|
+
|| manifest.canonicalAliases[printId] !== canonicalCardId) {
|
|
26
|
+
throw new Error(`invalid card catalog print identity ${printId}`);
|
|
27
|
+
}
|
|
28
|
+
const fields = Object.create(null);
|
|
29
|
+
if (Object.keys(entry.fields).length !== fieldNames.length) {
|
|
30
|
+
throw new Error(`incomplete card catalog print fields ${printId}`);
|
|
31
|
+
}
|
|
32
|
+
for (const field of fieldNames) {
|
|
33
|
+
const value = entry.fields[field];
|
|
34
|
+
if (value == null)
|
|
35
|
+
throw new Error(`incomplete card catalog print fields ${printId}`);
|
|
36
|
+
fields[field] = copyPrintField(value, field, printId, canonicalCardId, locale, manifest);
|
|
37
|
+
}
|
|
38
|
+
prints[printId] = Object.freeze({
|
|
39
|
+
canonicalCardId,
|
|
40
|
+
requestedPrintId,
|
|
41
|
+
fields: Object.freeze(fields),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return Object.freeze(prints);
|
|
45
|
+
}
|
|
46
|
+
/** Validates one requested/resolved full-field provenance pair. */
|
|
47
|
+
function copyPrintField(value, fieldName, printId, canonicalCardId, locale, manifest) {
|
|
48
|
+
if (!isRecord(value) || !hasExactKeys(value, ['requested', 'resolved', 'status'])) {
|
|
49
|
+
throw new Error(`invalid card catalog print field ${printId}`);
|
|
50
|
+
}
|
|
51
|
+
const requested = copyRequestedField(value.requested, printId, locale);
|
|
52
|
+
const resolved = copyResolvedField(value.resolved, printId, canonicalCardId, manifest);
|
|
53
|
+
const status = copyPrintStatus(value.status, printId);
|
|
54
|
+
if (requested.kind === 'source' && requested.sourcePrintId !== Number(printId)) {
|
|
55
|
+
throw new Error(`invalid card catalog requested source print ${printId}`);
|
|
56
|
+
}
|
|
57
|
+
if (fieldName === 'title' && resolved.field.state !== 'value') {
|
|
58
|
+
throw new Error(`card catalog title lacks a usable resolved value ${printId}`);
|
|
59
|
+
}
|
|
60
|
+
if (status.kind === 'source') {
|
|
61
|
+
if (requested.kind !== 'source' || requested.sourceLocale !== locale
|
|
62
|
+
|| resolved.sourceLocale !== locale || resolved.effectiveBaseline != null
|
|
63
|
+
|| resolved.sourcePrintId !== Number(printId)
|
|
64
|
+
|| requested.sourceRevision !== resolved.sourceRevision
|
|
65
|
+
|| requested.presence !== sourceFieldPresence(resolved.field)) {
|
|
66
|
+
throw new Error(`invalid card catalog print source provenance ${printId}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
if (resolved.sourceLocale !== 'en') {
|
|
71
|
+
throw new Error(`invalid card catalog print fallback locale ${printId}`);
|
|
72
|
+
}
|
|
73
|
+
if (status.reason === 'missingLocale' && requested.kind !== 'missingLocale') {
|
|
74
|
+
throw new Error(`invalid card catalog missing locale fallback ${printId}`);
|
|
75
|
+
}
|
|
76
|
+
if (status.reason === 'missingRequiredTitle'
|
|
77
|
+
&& (fieldName !== 'title' || requested.kind !== 'source'
|
|
78
|
+
|| (requested.presence !== 'missing' && requested.presence !== 'null'))) {
|
|
79
|
+
throw new Error(`invalid card catalog missing title fallback ${printId}`);
|
|
80
|
+
}
|
|
81
|
+
if (status.reason === 'missingSourceField'
|
|
82
|
+
&& (fieldName === 'title' || requested.kind !== 'source'
|
|
83
|
+
|| (requested.presence !== 'missing' && requested.presence !== 'null')
|
|
84
|
+
|| resolved.field.state !== 'value' || resolved.field.value.length === 0)) {
|
|
85
|
+
throw new Error(`invalid card catalog missing source field fallback ${printId}`);
|
|
86
|
+
}
|
|
87
|
+
if (status.reason === 'staleEffectiveText' && resolved.effectiveBaseline == null) {
|
|
88
|
+
throw new Error(`stale card catalog print fallback lacks effective baseline ${printId}`);
|
|
89
|
+
}
|
|
90
|
+
if (status.reason === 'staleEffectiveText' && requested.kind !== 'source') {
|
|
91
|
+
throw new Error(`invalid card catalog stale source fallback ${printId}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Object.freeze({ requested, resolved, status });
|
|
95
|
+
}
|
|
96
|
+
/** Copies requested source presence without retaining stale source text twice. */
|
|
97
|
+
function copyRequestedField(value, printId, locale) {
|
|
98
|
+
if (!isRecord(value) || typeof value.kind !== 'string') {
|
|
99
|
+
throw new Error(`invalid card catalog requested field ${printId}`);
|
|
100
|
+
}
|
|
101
|
+
if (value.kind === 'missingLocale' && hasExactKeys(value, ['kind', 'requestedPrintId'])
|
|
102
|
+
&& value.requestedPrintId === Number(printId)) {
|
|
103
|
+
return Object.freeze({ kind: 'missingLocale', requestedPrintId: value.requestedPrintId });
|
|
104
|
+
}
|
|
105
|
+
if (value.kind === 'source' && hasExactKeys(value, [
|
|
106
|
+
'kind', 'sourcePrintId', 'sourceLocale', 'sourceRevision', 'presence',
|
|
107
|
+
])) {
|
|
108
|
+
const sourceLocale = value.sourceLocale;
|
|
109
|
+
const sourceRevision = value.sourceRevision;
|
|
110
|
+
const presence = value.presence;
|
|
111
|
+
if (!isPositiveNumber(value.sourcePrintId) || typeof sourceLocale !== 'string'
|
|
112
|
+
|| typeof sourceRevision !== 'string' || !isHash(sourceRevision)
|
|
113
|
+
|| (presence !== 'missing' && presence !== 'null' && presence !== 'value')
|
|
114
|
+
|| requiredLocale(sourceLocale, `card catalog requested field ${printId}`) !== locale) {
|
|
115
|
+
throw new Error(`invalid card catalog requested field ${printId}`);
|
|
116
|
+
}
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
kind: 'source',
|
|
119
|
+
sourcePrintId: value.sourcePrintId,
|
|
120
|
+
sourceLocale,
|
|
121
|
+
sourceRevision,
|
|
122
|
+
presence,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
throw new Error(`invalid card catalog requested field ${printId}`);
|
|
126
|
+
}
|
|
127
|
+
/** Copies resolved literal source and its optional effective-English identity. */
|
|
128
|
+
function copyResolvedField(value, printId, canonicalCardId, manifest) {
|
|
129
|
+
if (!isRecord(value) || !hasOnlyKeys(value, [
|
|
130
|
+
'sourcePrintId', 'sourceLocale', 'sourceRevision', 'field', 'effectiveBaseline',
|
|
131
|
+
])) {
|
|
132
|
+
throw new Error(`invalid card catalog resolved field ${printId}`);
|
|
133
|
+
}
|
|
134
|
+
const sourceLocaleValue = value.sourceLocale;
|
|
135
|
+
const sourceRevision = value.sourceRevision;
|
|
136
|
+
if (!isPositiveNumber(value.sourcePrintId) || typeof sourceLocaleValue !== 'string'
|
|
137
|
+
|| typeof sourceRevision !== 'string' || !isHash(sourceRevision)) {
|
|
138
|
+
throw new Error(`invalid card catalog resolved field ${printId}`);
|
|
139
|
+
}
|
|
140
|
+
const sourceLocale = requiredLocale(sourceLocaleValue, `card catalog resolved field ${printId}`);
|
|
141
|
+
const field = copySourceField(value.field, printId);
|
|
142
|
+
const effectiveBaseline = value.effectiveBaseline === undefined
|
|
143
|
+
? undefined
|
|
144
|
+
: copyEffectiveBaseline(value.effectiveBaseline, printId, canonicalCardId, manifest);
|
|
145
|
+
if (manifest.canonicalAliases[String(value.sourcePrintId)] !== canonicalCardId) {
|
|
146
|
+
throw new Error(`invalid card catalog resolved source print ${printId}`);
|
|
147
|
+
}
|
|
148
|
+
return Object.freeze({
|
|
149
|
+
sourcePrintId: value.sourcePrintId,
|
|
150
|
+
sourceLocale,
|
|
151
|
+
sourceRevision,
|
|
152
|
+
field,
|
|
153
|
+
...(effectiveBaseline == null ? {} : { effectiveBaseline }),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/** Validates a field's three lossless source states. */
|
|
157
|
+
function copySourceField(value, printId) {
|
|
158
|
+
if (!isRecord(value) || typeof value.state !== 'string') {
|
|
159
|
+
throw new Error(`invalid card catalog resolved source field ${printId}`);
|
|
160
|
+
}
|
|
161
|
+
if ((value.state === 'missing' || value.state === 'null') && hasExactKeys(value, ['state'])) {
|
|
162
|
+
return Object.freeze({ state: value.state });
|
|
163
|
+
}
|
|
164
|
+
if (value.state === 'value' && typeof value.value === 'string' && hasExactKeys(value, ['state', 'value'])) {
|
|
165
|
+
return Object.freeze({ state: 'value', value: value.value });
|
|
166
|
+
}
|
|
167
|
+
throw new Error(`invalid card catalog resolved source field ${printId}`);
|
|
168
|
+
}
|
|
169
|
+
/** Returns the requested-field state represented by a resolved lossless field. */
|
|
170
|
+
function sourceFieldPresence(field) {
|
|
171
|
+
return field.state;
|
|
172
|
+
}
|
|
173
|
+
/** Validates stale effective-English content against canonical contract identity. */
|
|
174
|
+
function copyEffectiveBaseline(value, printId, canonicalCardId, manifest) {
|
|
175
|
+
if (!isRecord(value) || !hasExactKeys(value, ['canonicalCardId', 'semanticFingerprint'])
|
|
176
|
+
|| value.canonicalCardId !== canonicalCardId || typeof value.semanticFingerprint !== 'string'
|
|
177
|
+
|| !isHash(value.semanticFingerprint)
|
|
178
|
+
|| manifest.contractFingerprints[String(canonicalCardId)] !== value.semanticFingerprint) {
|
|
179
|
+
throw new Error(`invalid card catalog effective baseline ${printId}`);
|
|
180
|
+
}
|
|
181
|
+
return Object.freeze({ canonicalCardId, semanticFingerprint: value.semanticFingerprint });
|
|
182
|
+
}
|
|
183
|
+
/** Copies full-field status without allowing segmentation debt into direct print fields. */
|
|
184
|
+
function copyPrintStatus(value, printId) {
|
|
185
|
+
if (!isRecord(value) || typeof value.kind !== 'string') {
|
|
186
|
+
throw new Error(`invalid card catalog print status ${printId}`);
|
|
187
|
+
}
|
|
188
|
+
if (value.kind === 'source' && hasExactKeys(value, ['kind']))
|
|
189
|
+
return Object.freeze({ kind: 'source' });
|
|
190
|
+
if (value.kind === 'fallback' && hasExactKeys(value, ['kind', 'reason'])
|
|
191
|
+
&& (value.reason === 'missingLocale' || value.reason === 'missingRequiredTitle' || value.reason === 'missingSourceField'
|
|
192
|
+
|| value.reason === 'staleEffectiveText')) {
|
|
193
|
+
return Object.freeze({ kind: 'fallback', reason: value.reason });
|
|
194
|
+
}
|
|
195
|
+
throw new Error(`invalid card catalog print status ${printId}`);
|
|
196
|
+
}
|
|
197
|
+
/** Creates a mutation-safe shallow snapshot of full-print JSON before hashing. */
|
|
198
|
+
export function snapshotPrints(value) {
|
|
199
|
+
if (!isRecord(value))
|
|
200
|
+
throw new Error('invalid card catalog prints');
|
|
201
|
+
const prints = Object.create(null);
|
|
202
|
+
for (const [printId, entry] of Object.entries(value)) {
|
|
203
|
+
if (!isRecord(entry) || !isRecord(entry.fields))
|
|
204
|
+
throw new Error(`invalid card catalog print ${printId}`);
|
|
205
|
+
const fields = Object.create(null);
|
|
206
|
+
for (const [field, fieldValue] of Object.entries(entry.fields)) {
|
|
207
|
+
if (!isRecord(fieldValue) || !isRecord(fieldValue.requested) || !isRecord(fieldValue.resolved)
|
|
208
|
+
|| !isRecord(fieldValue.status))
|
|
209
|
+
throw new Error(`invalid card catalog print field ${printId}`);
|
|
210
|
+
const resolved = { ...fieldValue.resolved };
|
|
211
|
+
if (isRecord(resolved.field))
|
|
212
|
+
resolved.field = { ...resolved.field };
|
|
213
|
+
if (isRecord(resolved.effectiveBaseline))
|
|
214
|
+
resolved.effectiveBaseline = { ...resolved.effectiveBaseline };
|
|
215
|
+
fields[field] = {
|
|
216
|
+
requested: { ...fieldValue.requested },
|
|
217
|
+
resolved: resolved,
|
|
218
|
+
status: { ...fieldValue.status },
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
prints[printId] = {
|
|
222
|
+
canonicalCardId: entry.canonicalCardId,
|
|
223
|
+
requestedPrintId: entry.requestedPrintId,
|
|
224
|
+
fields,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return prints;
|
|
228
|
+
}
|
|
229
|
+
/** Rejects unexpected remotely supplied field names. */
|
|
230
|
+
function hasExactKeys(value, keys) {
|
|
231
|
+
return Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
232
|
+
}
|
|
233
|
+
/** Allows one optional key while still rejecting unknown remote catalog fields. */
|
|
234
|
+
function hasOnlyKeys(value, keys) {
|
|
235
|
+
return Object.keys(value).every(key => keys.includes(key))
|
|
236
|
+
&& ['sourcePrintId', 'sourceLocale', 'sourceRevision', 'field'].every(key => Object.hasOwn(value, key));
|
|
237
|
+
}
|
|
238
|
+
/** Narrows parsed JSON to own enumerable data before structured validation. */
|
|
239
|
+
function isRecord(value) {
|
|
240
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
241
|
+
}
|
|
242
|
+
/** Validates positive integer ids from untrusted serialized catalog input. */
|
|
243
|
+
function isPositiveNumber(value) {
|
|
244
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
245
|
+
}
|
|
246
|
+
/** Validates a serialized positive integer object key. */
|
|
247
|
+
function isPositiveNumericId(value) {
|
|
248
|
+
return /^\d+$/.test(value) && isPositiveNumber(Number(value));
|
|
249
|
+
}
|
|
250
|
+
/** Ensures a loaded catalog uses an exact supported base locale identifier. */
|
|
251
|
+
function requiredLocale(value, subject) {
|
|
252
|
+
const locale = resolveLocale(value);
|
|
253
|
+
if (locale !== value)
|
|
254
|
+
throw new Error(`unsupported ${subject} locale ${value}`);
|
|
255
|
+
return locale;
|
|
256
|
+
}
|
|
257
|
+
/** Checks one lower-case SHA-256 digest. */
|
|
258
|
+
function isHash(value) {
|
|
259
|
+
return /^[a-f0-9]{64}$/.test(value);
|
|
260
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CardTextCatalogManifestInput, PreparedCardTextCatalog, PreparedMessageCatalog, SimulatorMessageCatalogInput } from './types.js';
|
|
2
|
+
/** Validates and compiles one UI catalog before it is exposed to synchronous formatters. */
|
|
3
|
+
export declare function prepareMessageCatalog(input: SimulatorMessageCatalogInput): PreparedMessageCatalog;
|
|
4
|
+
/**
|
|
5
|
+
* Validates, freezes, and integrity-checks one separately loaded locale shard.
|
|
6
|
+
* Loading may await Web Crypto; synchronous formatting never reads assets or hashes.
|
|
7
|
+
*/
|
|
8
|
+
export declare function prepareCardTextCatalog(manifestInput: CardTextCatalogManifestInput, input: unknown): Promise<PreparedCardTextCatalog>;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import IntlMessageFormat from 'intl-messageformat';
|
|
2
|
+
import { copyPrints, requiredPrints, snapshotPrints } from './card-print-catalog.js';
|
|
3
|
+
import { simulatorMessageIds, simulatorMessageParameterTypes } from './generated/message-types.js';
|
|
4
|
+
import { validateIcuMessage } from './icu.js';
|
|
5
|
+
import { resolveLocale } from './locale.js';
|
|
6
|
+
/** Validates and compiles one UI catalog before it is exposed to synchronous formatters. */
|
|
7
|
+
export function prepareMessageCatalog(input) {
|
|
8
|
+
if (input.formatVersion !== 1)
|
|
9
|
+
throw new Error(`unsupported message catalog format ${input.formatVersion}`);
|
|
10
|
+
const locale = requiredLocale(input.locale, 'message catalog');
|
|
11
|
+
const messages = Object.create(null);
|
|
12
|
+
const formatters = new Map();
|
|
13
|
+
for (const id of simulatorMessageIds) {
|
|
14
|
+
if (!Object.hasOwn(input.messages, id))
|
|
15
|
+
throw new Error(`message catalog lacks ${id}`);
|
|
16
|
+
const template = input.messages[id];
|
|
17
|
+
if (typeof template !== 'string')
|
|
18
|
+
throw new Error(`message catalog lacks ${id}`);
|
|
19
|
+
messages[id] = template;
|
|
20
|
+
formatters.set(id, validateIcuMessage(locale, id, template, simulatorMessageParameterTypes[id]));
|
|
21
|
+
}
|
|
22
|
+
if (Object.keys(input.messages).length !== simulatorMessageIds.length)
|
|
23
|
+
throw new Error('message catalog has unknown ids');
|
|
24
|
+
return Object.freeze({ locale, messages: Object.freeze(messages), formatters });
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Validates, freezes, and integrity-checks one separately loaded locale shard.
|
|
28
|
+
* Loading may await Web Crypto; synchronous formatting never reads assets or hashes.
|
|
29
|
+
*/
|
|
30
|
+
export async function prepareCardTextCatalog(manifestInput, input) {
|
|
31
|
+
const snapshot = snapshotCardCatalog(input);
|
|
32
|
+
if (snapshot.formatVersion !== 1 && snapshot.formatVersion !== 2) {
|
|
33
|
+
throw new Error(`unsupported card catalog format ${snapshot.formatVersion}`);
|
|
34
|
+
}
|
|
35
|
+
const locale = requiredLocale(snapshot.locale, 'card catalog');
|
|
36
|
+
validateManifestShape(manifestInput);
|
|
37
|
+
const manifest = copyManifest(manifestInput);
|
|
38
|
+
if (manifest.formatVersion !== snapshot.formatVersion) {
|
|
39
|
+
throw new Error('card catalog shard and manifest format mismatch');
|
|
40
|
+
}
|
|
41
|
+
const expectedShard = manifest.localeShards[locale];
|
|
42
|
+
if (expectedShard == null)
|
|
43
|
+
throw new Error(`card catalog manifest lacks ${locale} shard`);
|
|
44
|
+
const shardJson = JSON.stringify(snapshot, null, 2);
|
|
45
|
+
if (new TextEncoder().encode(shardJson).byteLength !== expectedShard.byteSize
|
|
46
|
+
|| await sha256(shardJson) !== expectedShard.contentHash) {
|
|
47
|
+
throw new Error(`card catalog ${locale} shard integrity failed`);
|
|
48
|
+
}
|
|
49
|
+
if (locale === 'en' && manifest.englishBaseline !== expectedShard.contentHash) {
|
|
50
|
+
throw new Error('card catalog English baseline integrity failed');
|
|
51
|
+
}
|
|
52
|
+
if (await sha256(JSON.stringify(manifestIdentity(manifest))) !== manifest.contentHash) {
|
|
53
|
+
throw new Error('card catalog manifest integrity failed');
|
|
54
|
+
}
|
|
55
|
+
const cards = copyCards(snapshot.cards, manifest, locale);
|
|
56
|
+
const prints = snapshot.formatVersion === 2
|
|
57
|
+
? copyPrints(requiredPrints(snapshot), manifest, locale)
|
|
58
|
+
: Object.freeze(Object.create(null));
|
|
59
|
+
return Object.freeze({ locale, manifest, cards, prints });
|
|
60
|
+
}
|
|
61
|
+
/** Ensures a loaded catalog uses an exact supported base locale identifier. */
|
|
62
|
+
function requiredLocale(value, subject) {
|
|
63
|
+
const locale = resolveLocale(value);
|
|
64
|
+
if (locale !== value)
|
|
65
|
+
throw new Error(`unsupported ${subject} locale ${value}`);
|
|
66
|
+
return locale;
|
|
67
|
+
}
|
|
68
|
+
/** Rejects malformed or incomplete compiler manifest data before its digest is trusted. */
|
|
69
|
+
function validateManifestShape(manifest) {
|
|
70
|
+
if ((manifest.formatVersion !== 1 && manifest.formatVersion !== 2)
|
|
71
|
+
|| !isHash(manifest.contentHash) || !isHash(manifest.sourceGeneration)
|
|
72
|
+
|| !isHash(manifest.englishBaseline)) {
|
|
73
|
+
throw new Error('invalid card text catalog format');
|
|
74
|
+
}
|
|
75
|
+
for (const locale of ['de', 'en', 'es', 'fr', 'it']) {
|
|
76
|
+
const shard = manifest.localeShards[locale];
|
|
77
|
+
if (shard == null || !isHash(shard.contentHash) || !Number.isSafeInteger(shard.byteSize) || shard.byteSize < 0) {
|
|
78
|
+
throw new Error(`invalid card text catalog ${locale} shard metadata`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const [cardId, fingerprint] of Object.entries(manifest.contractFingerprints)) {
|
|
82
|
+
if (!/^\d+$/.test(cardId) || !isHash(fingerprint))
|
|
83
|
+
throw new Error('invalid card catalog fingerprint');
|
|
84
|
+
}
|
|
85
|
+
for (const [printId, canonicalId] of Object.entries(manifest.canonicalAliases)) {
|
|
86
|
+
if (!/^\d+$/.test(printId) || !Number.isSafeInteger(canonicalId) || canonicalId <= 0) {
|
|
87
|
+
throw new Error('invalid card catalog alias');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Copies only own records, preserving no caller-owned mutable objects or prototype fields. */
|
|
92
|
+
function copyCards(source, manifest, locale) {
|
|
93
|
+
const cards = Object.create(null);
|
|
94
|
+
for (const [cardId, slots] of Object.entries(source)) {
|
|
95
|
+
if (!/^\d+$/.test(cardId) || slots == null || !Object.hasOwn(manifest.contractFingerprints, cardId)) {
|
|
96
|
+
throw new Error(`invalid card catalog card id ${cardId}`);
|
|
97
|
+
}
|
|
98
|
+
const copiedSlots = Object.create(null);
|
|
99
|
+
for (const [slot, entry] of Object.entries(slots)) {
|
|
100
|
+
if (slot.length === 0 || typeof entry?.plainText !== 'string') {
|
|
101
|
+
throw new Error(`invalid card catalog slot ${cardId}:${slot}`);
|
|
102
|
+
}
|
|
103
|
+
const sourceLocale = requiredLocale(entry.sourceLocale, `card catalog slot ${cardId}:${slot}`);
|
|
104
|
+
const status = copyStatus(entry.status, cardId, slot);
|
|
105
|
+
if (status.kind === 'notApplicable' && entry.plainText !== '') {
|
|
106
|
+
throw new Error(`invalid card catalog empty slot ${cardId}:${slot}`);
|
|
107
|
+
}
|
|
108
|
+
if ((status.kind === 'source' || status.kind === 'notApplicable') && sourceLocale !== locale) {
|
|
109
|
+
throw new Error(`card catalog source locale mismatch ${cardId}:${slot}`);
|
|
110
|
+
}
|
|
111
|
+
if (status.kind === 'fallback' && sourceLocale !== 'en') {
|
|
112
|
+
throw new Error(`card catalog fallback locale mismatch ${cardId}:${slot}`);
|
|
113
|
+
}
|
|
114
|
+
copiedSlots[slot] = Object.freeze({ plainText: entry.plainText, sourceLocale, status });
|
|
115
|
+
}
|
|
116
|
+
cards[cardId] = Object.freeze(copiedSlots);
|
|
117
|
+
}
|
|
118
|
+
return Object.freeze(cards);
|
|
119
|
+
}
|
|
120
|
+
/** Copies input before asynchronous hashing so caller mutation cannot change authenticated data. */
|
|
121
|
+
function snapshotCardCatalog(input) {
|
|
122
|
+
if (!isRecord(input) || typeof input.formatVersion !== 'number' || typeof input.locale !== 'string' || !isRecord(input.cards)) {
|
|
123
|
+
throw new Error('invalid card catalog input');
|
|
124
|
+
}
|
|
125
|
+
const cards = Object.create(null);
|
|
126
|
+
for (const [cardId, slots] of Object.entries(input.cards)) {
|
|
127
|
+
if (!isRecord(slots))
|
|
128
|
+
throw new Error(`invalid card catalog card id ${cardId}`);
|
|
129
|
+
const copiedSlots = Object.create(null);
|
|
130
|
+
for (const [slot, entry] of Object.entries(slots)) {
|
|
131
|
+
if (!isRecord(entry) || !isRecord(entry.status))
|
|
132
|
+
throw new Error(`invalid card catalog slot ${cardId}:${slot}`);
|
|
133
|
+
copiedSlots[slot] = {
|
|
134
|
+
plainText: entry.plainText,
|
|
135
|
+
sourceLocale: entry.sourceLocale,
|
|
136
|
+
status: { ...entry.status },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
cards[cardId] = copiedSlots;
|
|
140
|
+
}
|
|
141
|
+
const prints = input.prints === undefined ? undefined : snapshotPrints(input.prints);
|
|
142
|
+
return prints === undefined
|
|
143
|
+
? Object.freeze({ formatVersion: input.formatVersion, locale: input.locale, cards })
|
|
144
|
+
: Object.freeze({ formatVersion: input.formatVersion, locale: input.locale, cards, prints });
|
|
145
|
+
}
|
|
146
|
+
/** Narrows parsed JSON to own enumerable data before structured validation. */
|
|
147
|
+
function isRecord(value) {
|
|
148
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
149
|
+
}
|
|
150
|
+
/** Copies and narrows a compiler-emitted source state. */
|
|
151
|
+
function copyStatus(status, cardId, slot) {
|
|
152
|
+
if (status?.kind === 'source' || status?.kind === 'notApplicable')
|
|
153
|
+
return Object.freeze({ kind: status.kind });
|
|
154
|
+
if (status?.kind === 'fallback'
|
|
155
|
+
&& (status.reason === 'missingLocale' || status.reason === 'staleEffectiveText' || status.reason === 'unreviewedSegmentation')) {
|
|
156
|
+
return Object.freeze({ kind: 'fallback', reason: status.reason });
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`invalid card catalog status ${cardId}:${slot}`);
|
|
159
|
+
}
|
|
160
|
+
/** Makes an independently owned immutable manifest copy. */
|
|
161
|
+
function copyManifest(input) {
|
|
162
|
+
const localeShards = Object.create(null);
|
|
163
|
+
for (const [locale, shard] of Object.entries(input.localeShards)) {
|
|
164
|
+
localeShards[locale] = Object.freeze({ ...shard });
|
|
165
|
+
}
|
|
166
|
+
return Object.freeze({
|
|
167
|
+
...input,
|
|
168
|
+
localeShards: Object.freeze(localeShards),
|
|
169
|
+
canonicalAliases: Object.freeze({ ...input.canonicalAliases }),
|
|
170
|
+
contractFingerprints: Object.freeze({ ...input.contractFingerprints }),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/** Serializes exact Rust manifest identity, omitting only its self hash. */
|
|
174
|
+
function manifestIdentity(manifest) {
|
|
175
|
+
return {
|
|
176
|
+
formatVersion: manifest.formatVersion,
|
|
177
|
+
sourceGeneration: manifest.sourceGeneration,
|
|
178
|
+
englishBaseline: manifest.englishBaseline,
|
|
179
|
+
localeShards: manifest.localeShards,
|
|
180
|
+
canonicalAliases: manifest.canonicalAliases,
|
|
181
|
+
contractFingerprints: manifest.contractFingerprints,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** Uses Web Crypto during asset loading, where asynchronous work is explicitly allowed. */
|
|
185
|
+
async function sha256(value) {
|
|
186
|
+
const subtle = globalThis.crypto?.subtle;
|
|
187
|
+
if (subtle == null)
|
|
188
|
+
throw new Error('Web Crypto is required to prepare card text catalogs');
|
|
189
|
+
const bytes = new Uint8Array(await subtle.digest('SHA-256', new TextEncoder().encode(value)));
|
|
190
|
+
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
191
|
+
}
|
|
192
|
+
/** Checks one lower-case SHA-256 digest. */
|
|
193
|
+
function isHash(value) {
|
|
194
|
+
return /^[a-f0-9]{64}$/.test(value);
|
|
195
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CardTextDiagnosticSink, CardTextResolver, PreparedMessageCatalog } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Creates an isolated resolver that reports each unavailable historical reference once.
|
|
4
|
+
* Consumers choose the sink, so formatter code neither logs globally nor exposes raw keys.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createCardTextResolver(messages: PreparedMessageCatalog, reportDiagnostic?: CardTextDiagnosticSink): CardTextResolver;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { resolveCardText } from './fallback.js';
|
|
2
|
+
/**
|
|
3
|
+
* Creates an isolated resolver that reports each unavailable historical reference once.
|
|
4
|
+
* Consumers choose the sink, so formatter code neither logs globally nor exposes raw keys.
|
|
5
|
+
*/
|
|
6
|
+
export function createCardTextResolver(messages, reportDiagnostic) {
|
|
7
|
+
const reported = new Set();
|
|
8
|
+
return Object.freeze({
|
|
9
|
+
resolve(catalog, reference) {
|
|
10
|
+
return resolveCardText(catalog, messages, reference, diagnostic => {
|
|
11
|
+
const key = `${diagnostic.kind}:${diagnostic.cardId}:${diagnostic.slot}:${diagnostic.contractFingerprint}`;
|
|
12
|
+
if (reported.has(key))
|
|
13
|
+
return;
|
|
14
|
+
reported.add(key);
|
|
15
|
+
reportDiagnostic?.(diagnostic);
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { CardTextDiagnosticSink, CardTextRef, PreparedCardTextCatalog, PreparedMessageCatalog, ResolvedCardText } from './types.js';
|
|
2
|
+
/** Resolves literal card prose by exact card, stable slot, and semantic fingerprint. */
|
|
3
|
+
export declare function resolveCardText(catalog: PreparedCardTextCatalog, messages: PreparedMessageCatalog, reference: CardTextRef, reportDiagnostic?: CardTextDiagnosticSink): ResolvedCardText;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { formatMessage } from './format.js';
|
|
2
|
+
/** Resolves literal card prose by exact card, stable slot, and semantic fingerprint. */
|
|
3
|
+
export function resolveCardText(catalog, messages, reference, reportDiagnostic) {
|
|
4
|
+
const cardId = String(reference.cardId);
|
|
5
|
+
const expected = own(catalog.manifest.contractFingerprints, cardId);
|
|
6
|
+
if (expected !== reference.contractFingerprint)
|
|
7
|
+
return unavailable(messages, reference, 'fingerprintMismatch', reportDiagnostic);
|
|
8
|
+
const slots = own(catalog.cards, cardId);
|
|
9
|
+
const entry = slots == null ? undefined : own(slots, reference.slot);
|
|
10
|
+
if (entry == null)
|
|
11
|
+
return unavailable(messages, reference, 'unknownReference', reportDiagnostic);
|
|
12
|
+
if (entry.status.kind === 'notApplicable') {
|
|
13
|
+
return { plainText: '', resolvedLocale: catalog.locale, status: 'notApplicable', diagnostic: 'notApplicable' };
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
plainText: entry.plainText,
|
|
17
|
+
resolvedLocale: entry.sourceLocale,
|
|
18
|
+
status: entry.status.kind,
|
|
19
|
+
...(entry.status.kind === 'fallback' ? { fallbackReason: entry.status.reason } : {}),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Looks up an own catalog key so a user-controlled ref cannot traverse prototypes. */
|
|
23
|
+
function own(object, key) {
|
|
24
|
+
return Object.hasOwn(object, key) ? object[key] : undefined;
|
|
25
|
+
}
|
|
26
|
+
function unavailable(messages, reference, kind, reportDiagnostic) {
|
|
27
|
+
const diagnostic = {
|
|
28
|
+
kind,
|
|
29
|
+
cardId: reference.cardId,
|
|
30
|
+
slot: reference.slot,
|
|
31
|
+
contractFingerprint: reference.contractFingerprint,
|
|
32
|
+
};
|
|
33
|
+
reportDiagnostic?.(diagnostic);
|
|
34
|
+
return {
|
|
35
|
+
plainText: formatMessage(messages, 'abilityUnavailable'),
|
|
36
|
+
resolvedLocale: messages.locale,
|
|
37
|
+
status: 'unavailable',
|
|
38
|
+
diagnostic: kind,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ParameterlessSimulatorMessageId, type SimulatorMessageId, type SimulatorMessageParams } from './generated/message-types.js';
|
|
2
|
+
import type { LegacyLiteralText, PreparedMessageCatalog } from './types.js';
|
|
3
|
+
/** Formats a parameterless SDK-owned ICU message. */
|
|
4
|
+
export declare function formatMessage<TId extends ParameterlessSimulatorMessageId>(catalog: PreparedMessageCatalog, id: TId): string;
|
|
5
|
+
/** Formats a parameterized SDK-owned ICU message with its exact generated values. */
|
|
6
|
+
export declare function formatMessage<TId extends Exclude<SimulatorMessageId, ParameterlessSimulatorMessageId>>(catalog: PreparedMessageCatalog, id: TId, values: SimulatorMessageParams[TId]): string;
|
|
7
|
+
/** Returns recorded legacy prose as literal data, never as an ICU template. */
|
|
8
|
+
export declare function formatLegacyLiteral(value: LegacyLiteralText): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { simulatorMessageParameterTypes, } from './generated/message-types.js';
|
|
2
|
+
export function formatMessage(catalog, id, values) {
|
|
3
|
+
const formatter = catalog.formatters.get(id);
|
|
4
|
+
if (formatter == null)
|
|
5
|
+
throw new Error(`prepared message catalog lacks ${id}`);
|
|
6
|
+
validateIntegerValues(id, values);
|
|
7
|
+
const result = formatter.format(values);
|
|
8
|
+
return Array.isArray(result) ? result.join('') : String(result);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Guards generated integer fields at the one JavaScript boundary before ICU
|
|
12
|
+
* formatting. Rust stores these values as `i64`; accepting a float, non-finite
|
|
13
|
+
* number, or unsafe JavaScript integer would create display data Rust cannot
|
|
14
|
+
* serialize or compare faithfully.
|
|
15
|
+
*/
|
|
16
|
+
function validateIntegerValues(id, values) {
|
|
17
|
+
for (const [name, type] of Object.entries(simulatorMessageParameterTypes[id])) {
|
|
18
|
+
if (type !== 'integer')
|
|
19
|
+
continue;
|
|
20
|
+
const value = values?.[name];
|
|
21
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
|
22
|
+
throw new Error(`invalid integer message value ${id}.${name}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Returns recorded legacy prose as literal data, never as an ICU template. */
|
|
27
|
+
export function formatLegacyLiteral(value) {
|
|
28
|
+
return value.text;
|
|
29
|
+
}
|