@skinhub/viewer 0.3.3 → 0.4.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.
@@ -200,8 +200,9 @@ export const SkinViewer = (props: SkinViewerProps) => {
200
200
  * whose query briefly returned `undefined` - must not record "no item" as the thing the frame is
201
201
  * showing, or the next real item would be diffed against a hole and sent in full for no reason.
202
202
  */
203
+ const previous = sent.current
203
204
  sent.current = { ...next, item: next.item ?? sent.current.item }
204
- if (coversCanvas(patch, next)) {
205
+ if (coversCanvas(patch, next, previous)) {
205
206
  setStatus('loading')
206
207
  // A NEW ITEM IS A NEW CHANCE. A lost GL context or a 404 on one model says nothing about the
207
208
  // next one, so the fallback comes down and the frame is allowed to try. A protocol mismatch is
package/src/index.ts CHANGED
@@ -6,14 +6,15 @@
6
6
  * <SkinViewer item={{ weapon: 'weapon_ak47', paintIndex: 1449, float: 0.27 }} style={{ height: 420 }} />
7
7
  * <SkinViewer inspectLink={tradeOffer.inspectLink} style={{ height: 420 }} />
8
8
  *
9
- * A weapon (or a glove) is one of SIX ways to name a subject, and each is its own prop:
9
+ * A weapon (or a glove) is one of SEVEN ways to name a subject, and each is its own prop:
10
10
  *
11
11
  * <SkinViewer sticker={{ id: 37, wear: 0.2 }} /> one sticker, the real holo/foil shader
12
12
  * <SkinViewer charm={{ id: 5 }} /> one charm, off the gun
13
13
  * <SkinViewer collectible={{ id: 874 }} /> one pin, coin, medal or trophy
14
14
  * <SkinViewer operator={{ id: 5036 }} /> one agent, alone
15
+ * <SkinViewer pet={{ id: 4, stage: 'hen' }} /> one chicken pet
15
16
  *
16
- * Exactly one of the six, enforced in the types - see `ViewerSubject`.
17
+ * Exactly one of the seven, enforced in the types - see `ViewerSubject`.
17
18
  *
18
19
  * The 3D is not in here. It is a page on our origin that this component embeds and drives over
19
20
  * `postMessage`, which is why installing this pulls in no `three`, no `@react-three/fiber` and no
@@ -60,6 +61,12 @@ export { fromInspectLink, toInspectLink, toPlacement } from './item.js'
60
61
 
61
62
  export type {
62
63
  MapName,
64
+ PetBackdrop,
65
+ PetEffect,
66
+ PetHat,
67
+ PetLook,
68
+ PetNames,
69
+ PetStage,
63
70
  SkinViewerCharm,
64
71
  SkinViewerError,
65
72
  SkinViewerErrorCode,
@@ -67,7 +74,6 @@ export type {
67
74
  SkinViewerItem,
68
75
  SkinViewerProps,
69
76
  SkinViewerSticker,
70
- TimeOfDay,
71
77
  ViewerAgent,
72
78
  ViewerBackground,
73
79
  ViewerCameraSettings,
@@ -80,6 +86,7 @@ export type {
80
86
  ViewerLocaleSettings,
81
87
  ViewerOperatorSubject,
82
88
  ViewerOverlaySettings,
89
+ ViewerPetSubject,
83
90
  ViewerQualitySettings,
84
91
  ViewerResize,
85
92
  ViewerSettings,
package/src/item.ts CHANGED
@@ -37,6 +37,7 @@ import type {
37
37
  FrameCharm,
38
38
  FrameCollectible,
39
39
  FrameItem,
40
+ FramePet,
40
41
  FrameSticker,
41
42
  FrameSubjectKind,
42
43
  PlacementSlots,
@@ -215,6 +216,7 @@ export type ResolvedStandalone = {
215
216
  sticker?: FrameSticker
216
217
  charm?: FrameCharm
217
218
  collectible?: FrameCollectible
219
+ pet?: FramePet
218
220
  agent?: { id: number; pose?: string | null }
219
221
  error: SkinViewerError | null
220
222
  }
@@ -251,6 +253,37 @@ export const resolveStandalone = (props: Partial<ViewerSubject>): ResolvedStanda
251
253
  ? { subject: 'agent', agent: dropUndefined({ id: props.operator.id, pose: props.operator.pose }), error: null }
252
254
  : bad('agent', 'operator', props.operator.id)
253
255
 
256
+ /* A PET CARRIES ITS WHOLE LOOK IN ONE GROUP - stage, colour group, seed, pose and the look sliders -
257
+ and only the id is identity. `look` is copied so a host mutating its own object cannot change what
258
+ the next diff compares against. */
259
+ if (props.pet)
260
+ return ok(props.pet.id)
261
+ ? {
262
+ subject: 'pet',
263
+ pet: dropUndefined({
264
+ id: props.pet.id,
265
+ stage: props.pet.stage,
266
+ variant: props.pet.variant,
267
+ petSeed: props.pet.petSeed,
268
+ pose: props.pet.pose,
269
+ look: props.pet.look
270
+ ? {
271
+ ...(props.pet.look.attributes && { attributes: { ...props.pet.look.attributes } }),
272
+ ...(props.pet.look.shape && { shape: { ...props.pet.look.shape } }),
273
+ }
274
+ : props.pet.look,
275
+ // The photo booth and the names (0.4.2). `names` copied for `look`'s reason.
276
+ hat: props.pet.hat,
277
+ backdrop: props.pet.backdrop,
278
+ light: props.pet.light,
279
+ effect: props.pet.effect,
280
+ names: props.pet.names ? { ...props.pet.names } : props.pet.names,
281
+ nameLabel: props.pet.nameLabel,
282
+ }),
283
+ error: null,
284
+ }
285
+ : bad('pet', 'pet', props.pet.id)
286
+
254
287
  return null
255
288
  }
256
289
 
package/src/protocol.ts CHANGED
@@ -134,7 +134,7 @@ export type FrameLocale = {
134
134
  export type FrameSettings = {
135
135
  camera?: { fov?: number; defaultZoom?: number }
136
136
  quality?: { bloom?: number; bloomSpill?: number; renderScale?: number; antialias?: boolean; shadows?: boolean }
137
- environment?: { map?: string | null; timeOfDay?: string; rain?: boolean; background?: string }
137
+ environment?: { map?: string | null; rain?: boolean; background?: string }
138
138
  overlays?: {
139
139
  stickerGizmo?: boolean
140
140
  charmGizmo?: boolean
@@ -158,12 +158,36 @@ export type FrameInteractions = {
158
158
  * the person an agent, and an integrator holding a weapon-modifier prop already named `agent` needs a
159
159
  * different word for the standalone picture. One rename, in one file.
160
160
  */
161
- export type FrameSubjectKind = 'weapon' | 'sticker' | 'charm' | 'collectible' | 'agent'
161
+ export type FrameSubjectKind = 'weapon' | 'sticker' | 'charm' | 'collectible' | 'agent' | 'pet'
162
162
 
163
163
  /** The three standalone item groups, in the frame's words. An id, and at most one number. */
164
164
  export type FrameSticker = { id: number; wear?: number }
165
165
  export type FrameCharm = { id: number; pattern?: number }
166
166
  export type FrameCollectible = { id: number }
167
+ /**
168
+ * One chicken pet (0.4.1). `id` is the `pet_definitions` row. Identity is `id` and, for a breed, a
169
+ * `stage` move between pullet and hen (the frame re-frames and says `ready` again); the rest update in
170
+ * place. `variant: null` lets the seed pick the colour group, `pose: null` is the idle.
171
+ */
172
+ export type FramePet = {
173
+ id: number
174
+ stage?: 'egg' | 'chick' | 'pullet' | 'hen'
175
+ variant?: number | null
176
+ petSeed?: number
177
+ pose?: string | null
178
+ look?: { attributes?: Record<string, number>; shape?: Record<string, number> } | null
179
+ /*
180
+ * THE PHOTO BOOTH AND THE NAMES (0.4.2) - all cheap, none of them identity. Plain strings on the wire;
181
+ * the frame checks each against its own table and names a word it does not know (`types.ts` has the
182
+ * unions). `names` REPLACES the held set rather than merging per stage.
183
+ */
184
+ hat?: string | null
185
+ backdrop?: string | null
186
+ light?: string | null
187
+ effect?: string | null
188
+ names?: { chick?: string; pullet?: string; hen?: string } | null
189
+ nameLabel?: boolean
190
+ }
167
191
 
168
192
  /**
169
193
  * Everything `/frame` holds. One field per prop of the renderer that a host can set.
@@ -179,6 +203,7 @@ export type FrameState = {
179
203
  sticker: FrameSticker
180
204
  charm: FrameCharm
181
205
  collectible: FrameCollectible
206
+ pet: FramePet
182
207
  view: 'gun' | 'hands' | 'agent'
183
208
  agent: { id: number; pose?: string | null }
184
209
  gloves: { type: string; paintIndex: number; float?: number; seed?: number } | null
@@ -217,6 +242,7 @@ export type FramePatch = {
217
242
  sticker?: Partial<FrameSticker>
218
243
  charm?: Partial<FrameCharm>
219
244
  collectible?: Partial<FrameCollectible>
245
+ pet?: Partial<FramePet>
220
246
  view?: FrameState['view']
221
247
  agent?: Partial<FrameState['agent']>
222
248
  gloves?: FrameState['gloves']
package/src/state.ts CHANGED
@@ -38,6 +38,7 @@ import type {
38
38
  FrameItem,
39
39
  FrameLabels,
40
40
  FramePatch,
41
+ FramePet,
41
42
  FrameSettings,
42
43
  FrameSticker,
43
44
  FrameSubjectKind,
@@ -79,6 +80,7 @@ export type DesiredState = {
79
80
  sticker?: FrameSticker
80
81
  charm?: FrameCharm
81
82
  collectible?: FrameCollectible
83
+ pet?: FramePet
82
84
  /** The integrator's own inspect link, forwarded verbatim as `?i=`. See `item.ts`. */
83
85
  inspectPayload: string | null
84
86
  help: HelpReason | null
@@ -168,6 +170,7 @@ export const resolveState = (props: Partial<SkinViewerProps>): DesiredState => {
168
170
  ...(standalone.sticker && { sticker: standalone.sticker }),
169
171
  ...(standalone.charm && { charm: standalone.charm }),
170
172
  ...(standalone.collectible && { collectible: standalone.collectible }),
173
+ ...(standalone.pet && { pet: standalone.pet }),
171
174
  inspectPayload: null,
172
175
  help: standalone.error ? HELP_FOR[standalone.error.code] : null,
173
176
  subjectError: standalone.error,
@@ -290,6 +293,34 @@ export const frameUrl = (origin: string, desired: DesiredState): { src: string;
290
293
  num(params, 'pattern', desired.charm.pattern)
291
294
  }
292
295
  if (desired.collectible) params.set('collectible', String(desired.collectible.id))
296
+ /*
297
+ * THE PET, and its `?pose=` is the pet's clip: the frame reads `?pose=` as a pet clip whenever
298
+ * `?pet=` is present, and an operator's pose has nothing to animate on a pet page. `?petseed=` and
299
+ * not `?seed=` for the charm's reason - `seed` is the weapon's paint seed on this URL.
300
+ */
301
+ if (desired.pet) {
302
+ const pet = desired.pet
303
+ params.set('pet', String(pet.id))
304
+ if (pet.stage) params.set('stage', pet.stage)
305
+ if (pet.variant !== undefined) params.set('variant', pet.variant === null ? '' : String(pet.variant))
306
+ num(params, 'petseed', pet.petSeed)
307
+ if (pet.pose) params.set('pose', pet.pose)
308
+ const look = petLookParam(pet.look)
309
+ if (look) params.set('look', look)
310
+ /* THE PHOTO BOOTH AND THE NAMES (0.4.2). `null` is written as the frame's explicit off (`none`, or an
311
+ empty `?light=`) so a first paint says exactly what the prop says; absent says nothing. The names
312
+ go per stage (`?chickname=`...), never `?name=`, which the frame reads against a stage the host
313
+ may not have named - and only the named ones: a fresh frame's names are already none. */
314
+ if (pet.hat !== undefined) params.set('hat', pet.hat ?? 'none')
315
+ if (pet.backdrop !== undefined) params.set('backdrop', pet.backdrop ?? 'none')
316
+ if (pet.light !== undefined) params.set('light', pet.light?.replace(/^#/, '') ?? '')
317
+ if (pet.effect !== undefined) params.set('fx', pet.effect ?? 'none')
318
+ for (const stage of PET_NAMED_STAGES) {
319
+ const name = pet.names?.[stage]
320
+ if (name) params.set(`${stage}name`, name)
321
+ }
322
+ if (pet.nameLabel !== undefined) flag(params, 'namelabel', pet.nameLabel)
323
+ }
293
324
 
294
325
  if (item) {
295
326
  params.set('weapon', item.weaponType)
@@ -314,7 +345,7 @@ export const frameUrl = (origin: string, desired: DesiredState): { src: string;
314
345
 
315
346
  if (desired.view) params.set('view', desired.view)
316
347
  if (desired.agent?.id !== undefined) params.set('agent', String(desired.agent.id))
317
- if (desired.agent?.pose) params.set('pose', desired.agent.pose)
348
+ if (desired.agent?.pose && !desired.pet?.pose) params.set('pose', desired.agent.pose)
318
349
  if (desired.gloves !== undefined) params.set('glove', desired.gloves ? gloveParam(desired.gloves) : 'none')
319
350
 
320
351
  const s = desired.settings
@@ -327,7 +358,6 @@ export const frameUrl = (origin: string, desired: DesiredState): { src: string;
327
358
  flag(params, 'shadows', s?.quality?.shadows)
328
359
  // `?map=none` is the calibrated reference rig, which is what `map: null` means on the prop.
329
360
  if (s?.environment?.map !== undefined) params.set('map', s.environment.map ?? 'none')
330
- if (s?.environment?.timeOfDay) params.set('time', s.environment.timeOfDay)
331
361
  flag(params, 'rain', s?.environment?.rain)
332
362
  if (s?.environment?.background) params.set('bg', s.environment.background)
333
363
  flag(params, 'stickergizmo', s?.overlays?.stickerGizmo)
@@ -383,6 +413,15 @@ export const frameUrl = (origin: string, desired: DesiredState): { src: string;
383
413
  return { src: `${origin.replace(/\/+$/, '')}/frame?${params.toString()}`, expressed }
384
414
  }
385
415
 
416
+ /** `$ChickenHue:0.42,fatness:0.7` - the frame's `?look=`, keys sorted so one look is one URL. */
417
+ const petLookParam = (look: FramePet['look']): string | null => {
418
+ const pairs = [...Object.entries(look?.attributes ?? {}), ...Object.entries(look?.shape ?? {})]
419
+ .filter(([, value]) => Number.isFinite(value))
420
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
421
+ .map(([key, value]) => `${key}:${Math.round(Math.min(Math.max(value, 0), 1) * 1000) / 1000}`)
422
+ return pairs.length ? pairs.join(',') : null
423
+ }
424
+
386
425
  /** `type:paintIndex[:float[:seed]]` - one param and not four, because they are one item. */
387
426
  const gloveParam = (gloves: ViewerGloves) =>
388
427
  [gloves.type, gloves.paintIndex, gloves.float, gloves.seed].filter(part => part !== undefined).join(':')
@@ -496,6 +535,13 @@ export const diffState = (previous: DesiredState, next: DesiredState): FramePatc
496
535
  patch.collectible = collectible
497
536
  changed = true
498
537
  }
538
+ /* The pet's `look` is the one nested field in any standalone group, and a host rebuilds it on every
539
+ render - so it is compared by value, or every render would post a `set`. */
540
+ const pet = diffGroup(previous.pet, next.pet, { look: petLookEqual, names: petNamesEqual })
541
+ if (pet) {
542
+ patch.pet = pet
543
+ changed = true
544
+ }
499
545
 
500
546
  if (next.view !== undefined && next.view !== previous.view) {
501
547
  patch.view = next.view
@@ -555,19 +601,34 @@ export const diffState = (previous: DesiredState, next: DesiredState): FramePatc
555
601
  * wire, and the alternative reading would make a conditional prop destructive. Same rule as
556
602
  * {@link diffSettings} one level in.
557
603
  */
558
- const diffGroup = <T extends object>(previous: T | undefined, next: T | undefined): Partial<T> | undefined => {
604
+ const diffGroup = <T extends object>(
605
+ previous: T | undefined,
606
+ next: T | undefined,
607
+ /** Per-key equality for the rare nested field; everything else is `Object.is`. */
608
+ equal: { [K in keyof T]?: (a: T[K], b: T[K]) => boolean } = {},
609
+ ): Partial<T> | undefined => {
559
610
  if (!next) return undefined
560
611
  if (!previous) return { ...next }
561
612
  const out: Partial<T> = {}
562
613
  let changed = false
563
614
  for (const key of Object.keys(next) as (keyof T)[])
564
- if (!Object.is(previous[key], next[key])) {
615
+ if (!(equal[key] ?? Object.is)(previous[key], next[key])) {
565
616
  out[key] = next[key]
566
617
  changed = true
567
618
  }
568
619
  return changed ? out : undefined
569
620
  }
570
621
 
622
+ const petLookEqual = (a: FramePet['look'], b: FramePet['look']) =>
623
+ a === b || (!!a && !!b && petLookParam(a) === petLookParam(b))
624
+
625
+ /** The stages a pet can be named at, in order - the egg cannot be. */
626
+ const PET_NAMED_STAGES = ['chick', 'pullet', 'hen'] as const
627
+
628
+ /** `names` is the other nested pet field a host rebuilds every render - compared by value like `look`. */
629
+ const petNamesEqual = (a: FramePet['names'], b: FramePet['names']) =>
630
+ a === b || (!!a && !!b && PET_NAMED_STAGES.every(stage => (a[stage] ?? '') === (b[stage] ?? '')))
631
+
571
632
  const glovesEqual = (a: ViewerGloves | null | undefined, b: ViewerGloves | null | undefined) => {
572
633
  if (a === b) return true
573
634
  if (!a || !b) return false
@@ -638,18 +699,48 @@ const localeEqual = (a: FrameSettings['locale'], b: FrameSettings['locale']) =>
638
699
  * IT TAKES THE WHOLE NEXT STATE rather than just the view, because one of the answers depends on which
639
700
  * SUBJECT the patch lands on: an operator's id covers under `subject: 'agent'` for the same reason it
640
701
  * covers under `view: 'agent'`, and does not under `hands`.
702
+ *
703
+ * AND THE PREVIOUS ONE, OPTIONALLY, for the pet's stage (see {@link drawnPetStage}): a stage patch only
704
+ * says the raw value moved, and whether the DRAWN stage moved depends on where it came from.
641
705
  */
642
- export const coversCanvas = (patch: FramePatch, next: Pick<DesiredState, 'subject' | 'view'>): boolean => {
706
+ export const coversCanvas = (
707
+ patch: FramePatch,
708
+ next: Pick<DesiredState, 'subject' | 'view' | 'pet'>,
709
+ previous?: Pick<DesiredState, 'pet'>,
710
+ ): boolean => {
643
711
  // A different KIND of subject is a different renderer. Always a reload, in every direction.
644
712
  if (patch.subject !== undefined) return true
645
713
  if (patch.view !== undefined) return true
646
714
  if (patch.item && IDENTITY_FIELDS.some(field => patch.item?.[field] !== undefined)) return true
647
715
  // An id is identity for all four standalone subjects; their second field (`wear`, `pattern`) is not.
648
- if (patch.sticker?.id !== undefined || patch.charm?.id !== undefined || patch.collectible?.id !== undefined)
716
+ if (
717
+ patch.sticker?.id !== undefined ||
718
+ patch.charm?.id !== undefined ||
719
+ patch.collectible?.id !== undefined ||
720
+ patch.pet?.id !== undefined
721
+ )
649
722
  return true
723
+ // A breed's stage is identity too: pullet and hen are one model, but the frame re-frames the bird
724
+ // and says `ready` again. Only when the drawn stage moves - anything else never gets that `ready`.
725
+ if (patch.pet?.stage !== undefined && previous && drawnPetStage(next.pet) !== drawnPetStage(previous.pet)) return true
650
726
  // The operator is identity when they ARE the subject, and in the `agent` view, where their
651
727
  // `<Suspense>` tears the subtree down; cheap in `hands`, where the arms are already mounted. The
652
728
  // asymmetry is the renderer's, not ours.
653
729
  if (patch.agent?.id !== undefined && (next.subject === 'agent' || next.view === 'agent')) return true
654
730
  return false
655
731
  }
732
+
733
+ /** The three breeds (`3` Catalana, `4` Silkie, `5` Polish) - the only pets with more than one stage. */
734
+ const PET_BREED_IDS: readonly number[] = [3, 4, 5]
735
+
736
+ /**
737
+ * *** THE STAGE THE FRAME WILL DRAW, OR `undefined` WHEN THE STAGE CANNOT CHANGE THE PICTURE. ***
738
+ *
739
+ * The frame clamps a stage to what the definition can be: an egg is only an egg, a chick only a chick,
740
+ * and a breed is a `pullet` or (anything else, or nothing) a `hen`. So `{ id: 3 }` -> `{ id: 3, stage:
741
+ * 'hen' }` is a patch that draws the same bird, and raising `loading` for it would wait for a `ready`
742
+ * that never comes. An id this package does not know is answered `undefined` for the same reason: a
743
+ * missed cover costs a few frames, a false one leaves the host's loading slot up for good.
744
+ */
745
+ const drawnPetStage = (pet: FramePet | undefined) =>
746
+ pet && PET_BREED_IDS.includes(pet.id) ? (pet.stage === 'pullet' ? 'pullet' : 'hen') : undefined
package/src/types.ts CHANGED
@@ -160,6 +160,8 @@ type ItemConfiguration = {
160
160
  * `charm` identity `id`, cheap `pattern`
161
161
  * `collectible` identity `id`, and NOTHING is cheap - there is no other field to change
162
162
  * `operator` identity `id`, cheap `pose`
163
+ * `pet` identity `id` and a breed's drawn `stage`, cheap `variant`, `petSeed`, `pose`, `look`,
164
+ * and (0.4.2) `hat`, `backdrop`, `light`, `effect`, `names`, `nameLabel`
163
165
  *
164
166
  * *** CHANGING WHICH SUBJECT YOU PASS IS ALWAYS AN IDENTITY CHANGE, *** in every direction: a weapon
165
167
  * and a pin are drawn by different renderers, so the picture is rebuilt from nothing.
@@ -301,6 +303,121 @@ export type ViewerOperatorSubject = {
301
303
  pose?: string | null
302
304
  }
303
305
 
306
+ /** A pet's growth stage - the item's `upgrade level` attribute, 0 egg .. 3 hen. */
307
+ export type PetStage = 'egg' | 'chick' | 'pullet' | 'hen'
308
+
309
+ /**
310
+ * The look sliders: per-key overrides over what the pet seed rolls, every value `0`..`1`. `attributes`
311
+ * is keyed by render attribute (`$ChickenHue`), `shape` by body characteristic (`fatness`). A key you do
312
+ * not pass is the seed's.
313
+ */
314
+ export type PetLook = { attributes?: Record<string, number>; shape?: Record<string, number> }
315
+
316
+ /**
317
+ * ONE CHICKEN PET (CS2 1.41.8.2) - what `/pet/:id` shows on our own site.
318
+ *
319
+ * `id` is the `pet_definitions` row, which is the `pet id` attribute on the item: `1` the egg, `2` the
320
+ * chick, `3` Catalana, `4` Silkie, `5` Polish. Everything else is how that pet looks:
321
+ *
322
+ * `stage` `'egg' | 'chick' | 'pullet' | 'hen'`. An egg is only an egg and a chick only a chick;
323
+ * the three breeds are a pullet or a hen (the default). IDENTITY for a breed - see below.
324
+ * `variant` the colour group index, or `null` (default) to let the seed pick it. Updates IN PLACE.
325
+ * `petSeed` the `pet seed` attribute, a uint32. Default `0`. Updates IN PLACE. What a seed looks
326
+ * like is our closest reconstruction of the game's rule and may differ slightly.
327
+ * `pose` a clip name from the pet's own model, or `null` (default) for its idle. IN PLACE. On the
328
+ * egg only, `'chicknegg_hatch01'` / `'chicknegg_hatch02'` play the hatch (the shell breaks
329
+ * and the chick climbs out; 02 is the shorter take). Any other pet idles on those two.
330
+ * `look` per-key overrides of the seed's roll - see {@link PetLook}. IN PLACE.
331
+ *
332
+ * IDENTITY IS `id` AND `stage`. A different id is a different model. A breed's pullet and hen are the
333
+ * same model, but the stage swaps the body proportions, so moving between them re-frames the bird:
334
+ * `loading` goes up and `onReady` fires again, the same as for a new id, only faster. A stage the pet
335
+ * cannot be (a hen for the egg) is drawn as the stage it can, and changes nothing. Everything else
336
+ * re-renders the bird already on screen.
337
+ */
338
+ export type ViewerPetSubject = {
339
+ id: number
340
+ stage?: PetStage
341
+ variant?: number | null
342
+ petSeed?: number
343
+ pose?: string | null
344
+ look?: PetLook | null
345
+ /* ── The photo booth and the names (0.4.2). All update IN PLACE and none sends `onReady` again; only
346
+ `backdrop` moves the camera (see there). ── */
347
+ /** A photo booth hat, or `null` (default) for none. Ignored on the egg, which has no head. */
348
+ hat?: PetHat | null
349
+ /**
350
+ * The photo studio's paper backdrop behind the bird, by colour, or `null` (default) for none.
351
+ *
352
+ * IT MOVES THE CAMERA: switching one on seats the camera where the game's booth camera stands, frames
353
+ * the bird looser (room for a hat and the effects) and limits a drag to 30 degrees either side of the
354
+ * seat. A view you pinned (`settings.camera`) keeps its seat; the looser framing and the drag limit
355
+ * still apply, centred on it. Switching it off gives the camera back.
356
+ */
357
+ backdrop?: PetBackdrop | null
358
+ /**
359
+ * The studio key light, six hex digits (`'ffe0c0'`, a leading `#` is accepted), or `null` (default) for
360
+ * the booth's own warm white `fff2e6`. It lights the studio, so it only shows with a {@link backdrop}.
361
+ */
362
+ light?: string | null
363
+ /**
364
+ * A photo booth effect, replaying until you clear it, or `null` (default) for none. Not drawn on the egg.
365
+ * The game offers `beam`, `lasers` and `sparks` from a pullet on; this draws what you ask for on a chick too.
366
+ */
367
+ effect?: PetEffect | null
368
+ /**
369
+ * The pet's names, one per growth stage, as the item stores them (the egg cannot be named). Each is cut to
370
+ * 20 characters and loses `{ } < >`, as the game's own box does. A stage without a name goes by the
371
+ * nearest named one - its own, then the younger stages, then the older ones. Passing `names` REPLACES
372
+ * the set: send every stage you want kept.
373
+ */
374
+ names?: PetNames | null
375
+ /** Draw the shown stage's name above the bird. Default `false`. */
376
+ nameLabel?: boolean
377
+ }
378
+
379
+ /** The ten photo booth hats, by the game's own names. */
380
+ export type PetHat =
381
+ | 'helmet'
382
+ | 'armor'
383
+ | 'alien'
384
+ | 'banana'
385
+ | 'glasses'
386
+ | 'nose_glasses'
387
+ | 'party'
388
+ | 'sprout'
389
+ | 'top_hat'
390
+ | 'wizard_hat'
391
+
392
+ /** The photo studio's paper colours. `wallpaper` exists in the game files but the booth never offers it. */
393
+ export type PetBackdrop =
394
+ | 'grey'
395
+ | 'blue'
396
+ | 'green'
397
+ | 'purple'
398
+ | 'yellow'
399
+ | 'brown'
400
+ | 'red'
401
+ | 'black'
402
+ | 'sky'
403
+ | 'abstract'
404
+ | 'wallpaper'
405
+
406
+ /** The nine photo booth effects, by the game's particle names (`lasers`, `feathers`). */
407
+ export type PetEffect =
408
+ | 'explosion'
409
+ | 'lightning'
410
+ | 'fire'
411
+ | 'beam'
412
+ | 'lasers'
413
+ | 'sparks'
414
+ | 'confetti'
415
+ | 'bubbles'
416
+ | 'feathers'
417
+
418
+ /** One name per stage - the item's `custom name attr` (chick), `... 2` (pullet) and `... 3` (hen). */
419
+ export type PetNames = { chick?: string; pullet?: string; hen?: string }
420
+
304
421
  /* ═════════════════════════════════════════════════════════════════════════════════════════════
305
422
  * PRESENTATION
306
423
  * ═══════════════════════════════════════════════════════════════════════════════════════════ */
@@ -371,9 +488,17 @@ export type ViewerGloves = {
371
488
  * upgraded. That is the deliberate trade - the rule is *"no back-compat
372
489
  * boilerplate, upgrade when you need to"* - and the failure is legible either way: the frame reports
373
490
  * an unknown map in its `problems` and keeps the lighting it had.
491
+ *
492
+ * *** THREE NAMES MOVED IN 0.4.0 AND THAT IS THE RULE BEING CHARGED. *** There was a `timeOfDay`
493
+ * field beside {@link ViewerEnvironmentSettings.map}, and a map that had two videos was named by the
494
+ * PAIR. It is one field now: `'Ancient'` became `'Ancient (Day)'` and `'Ancient (Night)'`, and
495
+ * `'Train'` became `'Train (Night)'` - a correction as much as a rename, since `de_train` ships one
496
+ * vanity scene and that scene is night. An embed still passing `'Ancient'` reports an unknown map in
497
+ * `problems` and keeps the lighting it had, which is this list's documented failure and not a new one.
374
498
  */
375
499
  export const MAP_NAMES = [
376
- 'Ancient',
500
+ 'Ancient (Day)',
501
+ 'Ancient (Night)',
377
502
  'Anubis',
378
503
  'Baggage',
379
504
  'Cache',
@@ -384,13 +509,12 @@ export const MAP_NAMES = [
384
509
  'Nuke',
385
510
  'Office',
386
511
  'Overpass',
387
- 'Train',
512
+ 'Train (Night)',
388
513
  'Vertigo',
389
514
  'Warehouse',
390
515
  ] as const
391
516
 
392
517
  export type MapName = (typeof MAP_NAMES)[number]
393
- export type TimeOfDay = 'Day' | 'Night'
394
518
 
395
519
  /**
396
520
  * What is BEHIND the item.
@@ -466,7 +590,7 @@ export type ViewerQualitySettings = {
466
590
 
467
591
  export type ViewerEnvironmentSettings = {
468
592
  /**
469
- * WHICH MAP'S LIGHT. Default `'Ancient'`; `null` is our calibrated reference rig, which is what
593
+ * WHICH MAP'S LIGHT. Default `'Dust II'`; `null` is our calibrated reference rig, which is what
470
594
  * every fidelity measurement behind this renderer was taken against.
471
595
  *
472
596
  * *** THIS IS THE LIGHT, {@link background} IS THE PICTURE, AND THEY ARE SEPARATE ON PURPOSE. ***
@@ -475,8 +599,6 @@ export type ViewerEnvironmentSettings = {
475
599
  * your page behind it.
476
600
  */
477
601
  map?: MapName | null
478
- /** Default `'Night'`. Falls back on its own for a map that has only one. */
479
- timeOfDay?: TimeOfDay
480
602
  /** Wet surfaces on maps whose own data says it rains. Default `true`. */
481
603
  rain?: boolean
482
604
  /** See {@link ViewerBackground}. Default `'transparent'`. */
@@ -716,6 +838,8 @@ type SubjectArms = {
716
838
  charm: ViewerCharmSubject
717
839
  collectible: ViewerCollectibleSubject
718
840
  operator: ViewerOperatorSubject
841
+ /** Added in 0.4.1 - a seventh arm on the same rule. See {@link ViewerPetSubject}. */
842
+ pet: ViewerPetSubject
719
843
  }
720
844
 
721
845
  /** One arm present, the other five forbidden. Written once so six arms cannot disagree about five. */
@@ -730,6 +854,7 @@ export type ViewerSubject =
730
854
  | OnlySubject<'charm'>
731
855
  | OnlySubject<'collectible'>
732
856
  | OnlySubject<'operator'>
857
+ | OnlySubject<'pet'>
733
858
 
734
859
  export type SkinViewerProps = ViewerSubject & {
735
860
  /* ── Presentation ──────────────────────────────────────────────────────────────────────── */
package/src/weapons.ts CHANGED
@@ -16,7 +16,10 @@
16
16
  * it is derived from the export itself (`data/skins.json`, `weapon.weapon_id` → `weapon.id`,
17
17
  * generated 2026-08-15, verified: 63 distinct defindexes, no defindex mapping to two DIFFERENT
18
18
  * weapons — the 20 knives each carry a second `sfui_wpnhud_*` alias for their vanilla row, which
19
- * `getWeaponModelPath` already resolves to the same model).
19
+ * `getWeaponModelPath` already resolves to the same model). Plus one hand-added row, the C4
20
+ * (49 -> `weapon_c4`, since CS2 1.41.8.2 put stickers on it): the 2026-08-15 `skins.json` the other
21
+ * 63 came from predates the 1.41.8.2 export and has no C4. From that export on it carries a vanilla
22
+ * `weapon_c4` row with `weapon_id: 49`, so the row is derivable like the rest - 64 rows in all.
20
23
  *
21
24
  * The alternative is fetching a 4.4 MB `skins.json` to answer "what is 7", which would make the
22
25
  * inspect-link path — the one the product exists for — cost four megabytes before the first frame.
@@ -33,7 +36,7 @@
33
36
  *
34
37
  * This is `skin.weapon.id` in `@skinhub/cdn`'s `skins.json` rows, verbatim — so
35
38
  * `<SkinViewer weapon={row.weapon.id} paintIndex={…} />` typechecks against a catalogue row with no
36
- * conversion. Type it into an editor and autocomplete lists all 71.
39
+ * conversion. Type it into an editor and autocomplete lists all 72.
37
40
  */
38
41
  export const WEAPON_IDS = [
39
42
  // Pistols
@@ -77,6 +80,8 @@ export const WEAPON_IDS = [
77
80
  'weapon_xm1014',
78
81
  // Other
79
82
  'weapon_taser',
83
+ // The C4 - no finish, but stickers, a charm and a name tag since CS2 1.41.8.2. See the defindex table.
84
+ 'weapon_c4',
80
85
  // Knives
81
86
  'weapon_bayonet',
82
87
  'weapon_knife_butterfly',
@@ -117,7 +122,7 @@ export type KnownWeaponId = (typeof WEAPON_IDS)[number]
117
122
  * `'weapon_ak47'`.
118
123
  *
119
124
  * The `(string & {})` arm is deliberate and is not a widening mistake: it keeps autocomplete listing
120
- * the 71 known ids while still ACCEPTING an id this build has never heard of, so a new weapon in a
125
+ * the 72 known ids while still ACCEPTING an id this build has never heard of, so a new weapon in a
121
126
  * fresh export renders the day it ships rather than the day the package is upgraded. An unknown id
122
127
  * that the asset export also does not know resolves to no model, which surfaces as `unknown-weapon`.
123
128
  */
@@ -161,6 +166,16 @@ export const WEAPON_ID_BY_DEFINDEX: Readonly<Record<number, KnownWeaponId>> = {
161
166
  38: 'weapon_scar20',
162
167
  39: 'weapon_sg556',
163
168
  40: 'weapon_ssg08',
169
+ /**
170
+ * THE ONE ROW ADDED BY HAND, because the `skins.json` this table was derived from predates the
171
+ * 1.41.8.2 export. From that export on, `skins.json` carries a vanilla C4 row (`weapon_id: 49`) and
172
+ * this row is derivable like the other 63. CS2 1.41.8.2 (2026-09-22) gave the `c4` prefab
173
+ * `weapon_supports_stickers`, so a stickered C4 is now a real item with an inspect link that says
174
+ * `defindex: 49` - and without this row it resolved to `unknown-weapon`. `weapon_c4` is the item
175
+ * definition's own `name` in `items_game.txt`, and the renderer's model table has carried it since
176
+ * the same update.
177
+ */
178
+ 49: 'weapon_c4',
164
179
  60: 'weapon_m4a1_silencer',
165
180
  61: 'weapon_usp_silencer',
166
181
  63: 'weapon_cz75a',