@skinhub/viewer 0.1.1 → 0.2.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/src/protocol.ts CHANGED
@@ -121,9 +121,35 @@ export type FrameInteractions = {
121
121
  dragCharm?: boolean
122
122
  }
123
123
 
124
- /** Everything `/frame` holds. One field per prop of the renderer that a host can set. */
124
+ /**
125
+ * *** THE FRAME'S FIVE SUBJECT KINDS. *** `weapon` covers gloves too - a glove is a `weaponType`.
126
+ *
127
+ * NOTE THE WORD: the wire says `agent` where `types.ts` says `operator`. That is the translation this
128
+ * package exists to do (see `item.ts`'s header): the frame's own subject vocabulary has always called
129
+ * the person an agent, and an integrator holding a weapon-modifier prop already named `agent` needs a
130
+ * different word for the standalone picture. One rename, in one file.
131
+ */
132
+ export type FrameSubjectKind = 'weapon' | 'sticker' | 'charm' | 'collectible' | 'agent'
133
+
134
+ /** The three standalone item groups, in the frame's words. An id, and at most one number. */
135
+ export type FrameSticker = { id: number; wear?: number }
136
+ export type FrameCharm = { id: number; pattern?: number }
137
+ export type FrameCollectible = { id: number }
138
+
139
+ /**
140
+ * Everything `/frame` holds. One field per prop of the renderer that a host can set.
141
+ *
142
+ * *** THE FIVE SUBJECTS ARE HELD AT ONCE AND `subject` PICKS ONE. *** A patch naming `sticker` does
143
+ * NOT make the sticker the subject - only `subject` does. That matters here in particular because this
144
+ * package restates its whole prop set on every render: if a group write switched the picture, a host
145
+ * holding both a weapon and a pin would flip between them on any render mentioning both.
146
+ */
125
147
  export type FrameState = {
148
+ subject: FrameSubjectKind
126
149
  item: FrameItem
150
+ sticker: FrameSticker
151
+ charm: FrameCharm
152
+ collectible: FrameCollectible
127
153
  view: 'gun' | 'hands' | 'agent'
128
154
  agent: { id: number; pose?: string | null }
129
155
  gloves: { type: string; paintIndex: number; float?: number; seed?: number } | null
@@ -141,7 +167,12 @@ export type FrameState = {
141
167
  * rather than merged would blank the weapon on every float tick.
142
168
  */
143
169
  export type FramePatch = {
170
+ /** The subject switch, and the only one - see {@link FrameState}. An IDENTITY change in every direction. */
171
+ subject?: FrameSubjectKind
144
172
  item?: Partial<FrameItem>
173
+ sticker?: Partial<FrameSticker>
174
+ charm?: Partial<FrameCharm>
175
+ collectible?: Partial<FrameCollectible>
145
176
  view?: FrameState['view']
146
177
  agent?: Partial<FrameState['agent']>
147
178
  gloves?: FrameState['gloves']
package/src/state.ts CHANGED
@@ -31,8 +31,18 @@
31
31
  * So the diff is structural: a field is in the patch only if its VALUE moved.
32
32
  */
33
33
 
34
- import type { FrameInteractions, FrameItem, FramePatch, FrameSettings, PlacementSlots } from './protocol.js'
35
- import { resolveSubject } from './item.js'
34
+ import type {
35
+ FrameCharm,
36
+ FrameCollectible,
37
+ FrameInteractions,
38
+ FrameItem,
39
+ FramePatch,
40
+ FrameSettings,
41
+ FrameSticker,
42
+ FrameSubjectKind,
43
+ PlacementSlots,
44
+ } from './protocol.js'
45
+ import { resolveStandalone, resolveSubject } from './item.js'
36
46
  import type { SkinViewerError, SkinViewerProps, ViewerGloves, ViewerView } from './types.js'
37
47
 
38
48
  /**
@@ -51,8 +61,23 @@ export type HelpReason = 'no-item' | 'bad-link' | 'unknown-weapon'
51
61
  * caller did not mention.
52
62
  */
53
63
  export type DesiredState = {
54
- /** `null` when the props named no renderable item; {@link help} then says why. */
64
+ /**
65
+ * WHICH OF THE FIVE IS ON SCREEN.
66
+ *
67
+ * *** THE ONE FIELD HERE THAT IS ALWAYS SET, AND THE EXCEPTION IS EARNED. *** Everything else in
68
+ * this type obeys "absent means say nothing", because our defaults must not be frozen into somebody
69
+ * else's URL. The subject cannot: going BACK to a weapon from a sticker has to be expressible, and a
70
+ * diff can only express it if there is a value to compare against. So it is always resolved - and
71
+ * {@link frameUrl} then declines to write `?subject=weapon`, which keeps the same promise in the one
72
+ * place it can be kept.
73
+ */
74
+ subject: FrameSubjectKind
75
+ /** `null` when the props named no renderable item, or named a subject that is not a weapon. */
55
76
  item: FrameItem | null
77
+ /** Set only when {@link subject} names it. Same rule as `item`, one per standalone subject. */
78
+ sticker?: FrameSticker
79
+ charm?: FrameCharm
80
+ collectible?: FrameCollectible
56
81
  /** The integrator's own inspect link, forwarded verbatim as `?i=`. See `item.ts`. */
57
82
  inspectPayload: string | null
58
83
  help: HelpReason | null
@@ -88,14 +113,9 @@ const HELP_FOR: Record<SkinViewerError['code'], HelpReason | null> = {
88
113
  * rather than a `TypeError`.
89
114
  */
90
115
  export const resolveState = (props: Partial<SkinViewerProps>): DesiredState => {
91
- const subject = resolveSubject(props)
92
116
  const settings = toFrameSettings(props.settings)
93
-
94
- return {
95
- item: subject.item,
96
- inspectPayload: subject.inspectPayload,
97
- help: subject.error ? HELP_FOR[subject.error.code] : null,
98
- subjectError: subject.error,
117
+ /* Everything true of every subject. Hoisted so the two returns below cannot drift apart. */
118
+ const common = {
99
119
  ...(props.view !== undefined && { view: props.view }),
100
120
  ...(props.agent !== undefined && { agent: props.agent }),
101
121
  ...(props.gloves !== undefined && { gloves: props.gloves }),
@@ -103,6 +123,40 @@ export const resolveState = (props: Partial<SkinViewerProps>): DesiredState => {
103
123
  ...(props.interactions !== undefined && { interactions: { ...props.interactions } }),
104
124
  ...(props.editingSlot !== undefined && { editingSlot: props.editingSlot }),
105
125
  }
126
+
127
+ /*
128
+ * THE OTHER FOUR SUBJECTS FIRST, because each is named by its own prop and the weapon is the
129
+ * fall-through. `item` stays null for them: there IS no weapon, and inventing one would put a rifle
130
+ * into this package's URL that the frame would then hold behind a subject nobody is looking at.
131
+ *
132
+ * `agent` IS WRITTEN LAST for the operator, so `operator={{ pose }}` beats an `agent={{ pose }}` a
133
+ * host also happened to pass. Two props answering one question is only reachable through the types'
134
+ * back door, and the subject arm is the more specific statement.
135
+ */
136
+ const standalone = resolveStandalone(props)
137
+ if (standalone)
138
+ return {
139
+ subject: standalone.subject,
140
+ item: null,
141
+ ...(standalone.sticker && { sticker: standalone.sticker }),
142
+ ...(standalone.charm && { charm: standalone.charm }),
143
+ ...(standalone.collectible && { collectible: standalone.collectible }),
144
+ inspectPayload: null,
145
+ help: standalone.error ? HELP_FOR[standalone.error.code] : null,
146
+ subjectError: standalone.error,
147
+ ...common,
148
+ ...(standalone.agent && { agent: standalone.agent }),
149
+ }
150
+
151
+ const subject = resolveSubject(props)
152
+ return {
153
+ subject: 'weapon',
154
+ item: subject.item,
155
+ inspectPayload: subject.inspectPayload,
156
+ help: subject.error ? HELP_FOR[subject.error.code] : null,
157
+ subjectError: subject.error,
158
+ ...common,
159
+ }
106
160
  }
107
161
 
108
162
  /**
@@ -160,6 +214,31 @@ export const frameUrl = (origin: string, desired: DesiredState): { src: string;
160
214
 
161
215
  if (desired.help) params.set('help', desired.help)
162
216
 
217
+ /*
218
+ * ── THE SUBJECT ──────────────────────────────────────────────────────────────────────────
219
+ *
220
+ * `?subject=` IS WRITTEN FOR EVERY NON-WEAPON AND NEVER FOR A WEAPON. Not writing it for the weapon
221
+ * keeps this file's promise about defaults: `?subject=weapon` in a customer's `<iframe src>` would
222
+ * be OUR default frozen into THEIR embed. Writing it for the other four is not the same thing - it
223
+ * is the caller's own statement, and for the operator it is the only way to say it, because
224
+ * `?agent=` on its own has always meant "who holds the weapon".
225
+ *
226
+ * The id params are what the frame reads back; `?sticker=`/`?charm=`/`?collectible=` each imply the
227
+ * subject on their own, so the pair is redundant by design rather than by accident - a URL that says
228
+ * the same thing twice cannot be half-copied into meaning something else.
229
+ */
230
+ if (desired.subject !== 'weapon') params.set('subject', desired.subject)
231
+ if (desired.sticker) {
232
+ params.set('sticker', String(desired.sticker.id))
233
+ num(params, 'wear', desired.sticker.wear)
234
+ }
235
+ if (desired.charm) {
236
+ params.set('charm', String(desired.charm.id))
237
+ // `?pattern=` and not `?seed=` - on this URL `seed` is already the WEAPON's paint seed.
238
+ num(params, 'pattern', desired.charm.pattern)
239
+ }
240
+ if (desired.collectible) params.set('collectible', String(desired.collectible.id))
241
+
163
242
  if (item) {
164
243
  params.set('weapon', item.weaponType)
165
244
  params.set('paint', String(item.paintIndex))
@@ -291,6 +370,46 @@ export const diffState = (previous: DesiredState, next: DesiredState): FramePatc
291
370
  if (Object.keys(item).length > 0) patch.item = item
292
371
  }
293
372
 
373
+ /*
374
+ * ── THE OTHER FOUR SUBJECTS ───────────────────────────────────────────────────────────────
375
+ *
376
+ * A GROUP IS DIFFED EVEN WHEN THE SUBJECT DID NOT MOVE, so a `wear` drag on a sticker is one small
377
+ * patch per tick and nothing else - the identical shape a `float` drag on a rifle has, and cheap for
378
+ * the identical reason: the id is not in the patch, so nothing keyed on the id can move.
379
+ *
380
+ * THE SUBJECT IS DIFFED SEPARATELY AND CARRIES NO GROUP WITH IT. Switching to a sticker whose id has
381
+ * not changed sends `{ subject: 'sticker' }` alone, because the frame still holds the id it was given
382
+ * before - which is what makes flipping between two subjects cost one field.
383
+ */
384
+ if (next.subject !== previous.subject) {
385
+ patch.subject = next.subject
386
+ changed = true
387
+ }
388
+ /*
389
+ * *** FIELD BY FIELD, NOT WHOLE-GROUP, AND THE TEST BELOW IS WHY. *** Sending the whole group would
390
+ * be correct on the wire - the frame merges by field either way - and would still break the
391
+ * contract, because `coversCanvas` reads `sticker.id !== undefined` to decide whether to raise the
392
+ * caller's `loading` slot. A `wear` drag that restated the id would raise it sixty times a second
393
+ * over a picture the frame never covered, which from outside is indistinguishable from a reload.
394
+ * The item above is diffed per field for the same reason; gloves are the one group sent wholesale,
395
+ * and that is because a partial pair is a state the renderer cannot resolve.
396
+ */
397
+ const sticker = diffGroup(previous.sticker, next.sticker)
398
+ if (sticker) {
399
+ patch.sticker = sticker
400
+ changed = true
401
+ }
402
+ const charm = diffGroup(previous.charm, next.charm)
403
+ if (charm) {
404
+ patch.charm = charm
405
+ changed = true
406
+ }
407
+ const collectible = diffGroup(previous.collectible, next.collectible)
408
+ if (collectible) {
409
+ patch.collectible = collectible
410
+ changed = true
411
+ }
412
+
294
413
  if (next.view !== undefined && next.view !== previous.view) {
295
414
  patch.view = next.view
296
415
  changed = true
@@ -330,6 +449,26 @@ export const diffState = (previous: DesiredState, next: DesiredState): FramePatc
330
449
  return changed ? patch : undefined
331
450
  }
332
451
 
452
+ /**
453
+ * One flat group, per field. `undefined` when nothing moved, so the caller can skip the key entirely.
454
+ *
455
+ * A KEY THE NEXT RENDER DID NOT MENTION IS NOT DIFFED AWAY - there is no way to say "unset" over the
456
+ * wire, and the alternative reading would make a conditional prop destructive. Same rule as
457
+ * {@link diffSettings} one level in.
458
+ */
459
+ const diffGroup = <T extends object>(previous: T | undefined, next: T | undefined): Partial<T> | undefined => {
460
+ if (!next) return undefined
461
+ if (!previous) return { ...next }
462
+ const out: Partial<T> = {}
463
+ let changed = false
464
+ for (const key of Object.keys(next) as (keyof T)[])
465
+ if (!Object.is(previous[key], next[key])) {
466
+ out[key] = next[key]
467
+ changed = true
468
+ }
469
+ return changed ? out : undefined
470
+ }
471
+
333
472
  const glovesEqual = (a: ViewerGloves | null | undefined, b: ViewerGloves | null | undefined) => {
334
473
  if (a === b) return true
335
474
  if (!a || !b) return false
@@ -378,12 +517,24 @@ const overlaysEqual = (a: FrameSettings['overlays'], b: FrameSettings['overlays'
378
517
  return a.stickerGizmo === b.stickerGizmo && a.charmGizmo === b.charmGizmo && shallowEqual(a.gizmoStyle, b.gizmoStyle)
379
518
  }
380
519
 
381
- /** True when a patch would cover the canvas - see {@link IDENTITY_FIELDS}. */
382
- export const coversCanvas = (patch: FramePatch, view: ViewerView | undefined): boolean => {
520
+ /**
521
+ * True when a patch would cover the canvas - see {@link IDENTITY_FIELDS} and `CHEAP_FIELDS`.
522
+ *
523
+ * IT TAKES THE WHOLE NEXT STATE rather than just the view, because one of the answers depends on which
524
+ * SUBJECT the patch lands on: an operator's id covers under `subject: 'agent'` for the same reason it
525
+ * covers under `view: 'agent'`, and does not under `hands`.
526
+ */
527
+ export const coversCanvas = (patch: FramePatch, next: Pick<DesiredState, 'subject' | 'view'>): boolean => {
528
+ // A different KIND of subject is a different renderer. Always a reload, in every direction.
529
+ if (patch.subject !== undefined) return true
383
530
  if (patch.view !== undefined) return true
384
531
  if (patch.item && IDENTITY_FIELDS.some(field => patch.item?.[field] !== undefined)) return true
385
- // The operator is identity in the `agent` view, where its `<Suspense>` tears the subtree down, and
386
- // cheap in `hands`, where the arms are already mounted. The asymmetry is the renderer's, not ours.
387
- if (patch.agent?.id !== undefined && view === 'agent') return true
532
+ // An id is identity for all four standalone subjects; their second field (`wear`, `pattern`) is not.
533
+ if (patch.sticker?.id !== undefined || patch.charm?.id !== undefined || patch.collectible?.id !== undefined)
534
+ return true
535
+ // The operator is identity when they ARE the subject, and in the `agent` view, where their
536
+ // `<Suspense>` tears the subtree down; cheap in `hands`, where the arms are already mounted. The
537
+ // asymmetry is the renderer's, not ours.
538
+ if (patch.agent?.id !== undefined && (next.subject === 'agent' || next.view === 'agent')) return true
388
539
  return false
389
540
  }
package/src/types.ts CHANGED
@@ -145,6 +145,17 @@ type ItemConfiguration = {
145
145
  * This list is the FIRST half. It is here, as a value, because {@link SkinViewerProps.loading} is
146
146
  * raised off its complement - see `SkinViewer.tsx`'s `isIdentityChange` - and because a contract an
147
147
  * integrator relies on should be readable without opening the renderer.
148
+ *
149
+ * *** AND THE SAME SPLIT FOR THE OTHER FOUR SUBJECTS, which is a shorter table because they are
150
+ * smaller items: ***
151
+ *
152
+ * `sticker` identity `id`, cheap `wear`
153
+ * `charm` identity `id`, cheap `pattern`
154
+ * `collectible` identity `id`, and NOTHING is cheap - there is no other field to change
155
+ * `operator` identity `id`, cheap `pose`
156
+ *
157
+ * *** CHANGING WHICH SUBJECT YOU PASS IS ALWAYS AN IDENTITY CHANGE, *** in every direction: a weapon
158
+ * and a pin are drawn by different renderers, so the picture is rebuilt from nothing.
148
159
  */
149
160
  export const CHEAP_FIELDS = ['float', 'seed', 'statTrak', 'nameTag', 'stickers', 'charm'] as const
150
161
 
@@ -180,6 +191,109 @@ export type SkinViewerItem = ItemConfiguration &
180
191
  }
181
192
  )
182
193
 
194
+ /* ═════════════════════════════════════════════════════════════════════════════════════════════
195
+ * THE OTHER FOUR SUBJECTS
196
+ *
197
+ * *** A WEAPON IS ONE OF FIVE THINGS THE VIEWER CAN SHOW, NOT THE ONLY ONE. *** Owner: *"Weapons and
198
+ * gloves only... why not? it's already working in our system, I want them all to be inspectable
199
+ * through the viewer."* A GLOVE IS NOT ON THIS LIST because a glove is a `weapon` id like any other
200
+ * (`'sporty_gloves'`) - it always was one of the arms above.
201
+ *
202
+ * *** YOU NAME ONE BY PASSING ITS PROP, AND EXACTLY ONE. *** See {@link ViewerSubject}: the same
203
+ * `?: never` enforcement the `inspectLink` / `item` pair already had, widened to six arms. There is no
204
+ * `kind` or `type` discriminator to get wrong, because the prop you passed IS the discriminator.
205
+ * ═══════════════════════════════════════════════════════════════════════════════════════════ */
206
+
207
+ /**
208
+ * ONE STICKER, ON NOTHING - what `/sticker/:id` shows on our own site.
209
+ *
210
+ * *** NOT {@link SkinViewerSticker}, AND THE MISSING FIELDS ARE THE DIFFERENCE. *** That type is a
211
+ * sticker APPLIED to a weapon and carries a slot, a rotation and an offset; all three are statements
212
+ * about the sticker being ON something. Here the quad is the whole surface, so what is left is the id
213
+ * and how scratched it is.
214
+ *
215
+ * IT IS THE REAL SHADER AND NOT AN IMAGE ON A PLANE: holo, foil and glitter are functions of the view
216
+ * direction and of screen-space derivatives, so they exist only in 3D and only under lighting. That is
217
+ * the entire reason to embed a sticker rather than show your catalogue's icon.
218
+ */
219
+ export type ViewerStickerSubject = {
220
+ /** `sticker_id` - the id in `@skinhub/cdn`'s `stickers.json`. */
221
+ id: number
222
+ /** Scratch, `0` (mint) to `1` (scraped off). Default `0`. Updates IN PLACE - see {@link CHEAP_FIELDS}. */
223
+ wear?: number
224
+ }
225
+
226
+ /**
227
+ * ONE CHARM, OFF THE GUN.
228
+ *
229
+ * It hangs in the pose its own model authors and does not swing: the physics exists to answer to how a
230
+ * rifle is being turned, and there is no rifle here. Everything that is the charm's own - its model,
231
+ * its per-id material, its template - is unchanged.
232
+ */
233
+ export type ViewerCharmSubject = {
234
+ /** The id in `@skinhub/cdn`'s `keychains.json`. */
235
+ id: number
236
+ /**
237
+ * The charm's template. NOT a variant: it drives a hue/saturation/brightness adjust on the charm's
238
+ * own albedo, so two charms with the same `id` and a different value are the same model in different
239
+ * colours. Default `0`. Updates IN PLACE.
240
+ *
241
+ * *** SPELLED `pattern` HERE AND `seed` ON {@link SkinViewerCharm}, WHICH IS DELIBERATE. *** On a
242
+ * weapon the charm sits beside a paint `seed` and calling it a seed reads naturally; as a SUBJECT it
243
+ * sits beside nothing, and `pattern` is what the wire, the game and the URL (`?pattern=`) all call
244
+ * it. The one word that could not be reused in both places is `seed`, because on the URL that is
245
+ * already the weapon's paint seed.
246
+ */
247
+ pattern?: number
248
+ }
249
+
250
+ /**
251
+ * ONE PIN, COIN, MEDAL OR TROPHY.
252
+ *
253
+ * *** IT HAS NOTHING ELSE, AND THAT IS THE WHOLE TYPE. *** No float, no seed, no wear, no template, no
254
+ * pose: two copies of the same medal ARE the same object. If you are drawing controls for an embedded
255
+ * collectible, there is nothing to draw except the camera.
256
+ */
257
+ export type ViewerCollectibleSubject = {
258
+ /** The item definition index in `@skinhub/cdn`'s `collectibles.json`. */
259
+ id: number
260
+ }
261
+
262
+ /**
263
+ * ONE OPERATOR, ALONE - the agent as the SUBJECT rather than as the person holding a weapon.
264
+ *
265
+ * ═════════════════════════════════════════════════════════════════════════════════════════════
266
+ * *** WHY THIS IS NOT {@link ViewerAgent}, WHEN IT NAMES THE SAME CATALOGUE ROW. ***
267
+ *
268
+ * They are two pictures, and the prop you pass is which one you get:
269
+ *
270
+ * `agent={{ id }}` WHO IS HOLDING IT. A modifier on the weapon subject, visible in the `hands`
271
+ * and `agent` views, ignored in `gun`. The weapon is what is being shown.
272
+ * `operator={{ id }}` THE PERSON IS WHAT IS BEING SHOWN. No weapon is drawn at all.
273
+ *
274
+ * A marketplace with an agent row in an inventory wants the second: they asked for Sir Bloody Darryl,
275
+ * and rendering him holding an AK-47 nobody named would be a picture of two items, one of them
276
+ * invented. (Our own site made the opposite call for its own routes, for a reason that does not carry:
277
+ * in the app there is always a weapon being configured, so `/agent/:id` folds into `?view=agent`
278
+ * rather than duplicating a surface. An embed has no such weapon.)
279
+ *
280
+ * PASSING BOTH IS NOT POSSIBLE for the subject - `?: never` - and passing `agent` ALONGSIDE `operator`
281
+ * simply does nothing, because there is no weapon for anyone to hold.
282
+ */
283
+ export type ViewerOperatorSubject = {
284
+ /** An agent's item definition index; `5036` is the default Terrorist. */
285
+ id: number
286
+ /**
287
+ * Which main-menu performance they play, by clip leaf name, or `null` (the default) for their
288
+ * team's own knife idle - what CS2's main menu opens on.
289
+ *
290
+ * 285 leaves across 44 weapon families are reachable. THE WEAPON EACH CLIP HOLDS IS NOT DRAWN, so
291
+ * `Look at gun` is an operator studying an empty hand; that is a property of the performance rather
292
+ * than a defect. Updates IN PLACE - the clip is swapped on a rig that keeps running.
293
+ */
294
+ pose?: string | null
295
+ }
296
+
183
297
  /* ═════════════════════════════════════════════════════════════════════════════════════════════
184
298
  * PRESENTATION
185
299
  * ═══════════════════════════════════════════════════════════════════════════════════════════ */
@@ -188,6 +302,9 @@ export type SkinViewerItem = ItemConfiguration &
188
302
  * What the item is being shown ON. All three are the same item under the same lighting, finish,
189
303
  * stickers and charm - the difference is the camera and what is holding the weapon.
190
304
  *
305
+ * *** IT IS A PROPERTY OF THE WEAPON SUBJECT AND IS IGNORED BY THE OTHER FOUR. *** A sticker is not a
306
+ * way of showing a weapon, so there is nothing for it to choose between.
307
+ *
191
308
  * `gun` the item alone, orbitable, framed to the viewport. The default.
192
309
  * `hands` CS2's first-person viewmodel, driven by the game's own clips. Orbit is off here because
193
310
  * there is nothing to orbit: the weapon is welded to the eye.
@@ -494,8 +611,34 @@ export type ViewerResize = { width: number; height: number; dpr: number }
494
611
  * was still `undefined` when the component mounted: the frame renders a short instruction card rather
495
612
  * than a blank box or - worse - our default AK-47, which would look like a successful render of the
496
613
  * wrong item. `onError` fires with `no-item` at the same time. See `SkinViewer.tsx`.
614
+ *
615
+ * *** AND IT IS SIX ARMS NOW RATHER THAN TWO, on exactly the same rule. *** A weapon, an inspect link
616
+ * and the four subjects above are six ways of naming ONE thing to render, and the same `?: never`
617
+ * enforcement covers all of them: passing two is an excess-property error, passing none is a
618
+ * missing-property error, and there is no `kind` discriminator anywhere because the prop you passed is
619
+ * the discriminator.
497
620
  */
498
- export type ViewerSubject = { inspectLink: string; item?: never } | { item: SkinViewerItem; inspectLink?: never }
621
+ type SubjectArms = {
622
+ inspectLink: string
623
+ item: SkinViewerItem
624
+ sticker: ViewerStickerSubject
625
+ charm: ViewerCharmSubject
626
+ collectible: ViewerCollectibleSubject
627
+ operator: ViewerOperatorSubject
628
+ }
629
+
630
+ /** One arm present, the other five forbidden. Written once so six arms cannot disagree about five. */
631
+ type OnlySubject<K extends keyof SubjectArms> = { [P in K]: SubjectArms[P] } & {
632
+ [P in Exclude<keyof SubjectArms, K>]?: never
633
+ }
634
+
635
+ export type ViewerSubject =
636
+ | OnlySubject<'inspectLink'>
637
+ | OnlySubject<'item'>
638
+ | OnlySubject<'sticker'>
639
+ | OnlySubject<'charm'>
640
+ | OnlySubject<'collectible'>
641
+ | OnlySubject<'operator'>
499
642
 
500
643
  export type SkinViewerProps = ViewerSubject & {
501
644
  /* ── Presentation ──────────────────────────────────────────────────────────────────────── */