@skinhub/viewer 0.1.1
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/EMBED.md +442 -0
- package/README.md +153 -0
- package/dist/SkinViewer.d.ts +18 -0
- package/dist/SkinViewer.d.ts.map +1 -0
- package/dist/SkinViewer.js +404 -0
- package/dist/SkinViewer.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/item.d.ts +118 -0
- package/dist/item.d.ts.map +1 -0
- package/dist/item.js +319 -0
- package/dist/item.js.map +1 -0
- package/dist/link.d.ts +30 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +18 -0
- package/dist/link.js.map +1 -0
- package/dist/protocol.d.ts +231 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +128 -0
- package/dist/protocol.js.map +1 -0
- package/dist/state.d.ts +107 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +351 -0
- package/dist/state.js.map +1 -0
- package/dist/types.d.ts +573 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +106 -0
- package/dist/types.js.map +1 -0
- package/dist/useSkinViewer.d.ts +3 -0
- package/dist/useSkinViewer.d.ts.map +1 -0
- package/dist/useSkinViewer.js +66 -0
- package/dist/useSkinViewer.js.map +1 -0
- package/dist/weapons.d.ts +109 -0
- package/dist/weapons.d.ts.map +1 -0
- package/dist/weapons.js +260 -0
- package/dist/weapons.js.map +1 -0
- package/package.json +65 -0
- package/src/SkinViewer.tsx +465 -0
- package/src/index.ts +88 -0
- package/src/item.ts +373 -0
- package/src/link.ts +33 -0
- package/src/protocol.ts +241 -0
- package/src/state.ts +389 -0
- package/src/types.ts +672 -0
- package/src/useSkinViewer.ts +80 -0
- package/src/weapons.ts +284 -0
package/src/item.ts
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* *** THE BRIDGE - an integrator's item into the frame's item, and back out again. ***
|
|
3
|
+
*
|
|
4
|
+
* This is the package's actual work. Everything else is an `<iframe>` and a message listener.
|
|
5
|
+
*
|
|
6
|
+
* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
7
|
+
* *** THE TWO VOCABULARIES, AND WHY WE DO NOT JUST PICK ONE. ***
|
|
8
|
+
*
|
|
9
|
+
* The frame speaks the renderer's names (`weaponType`) and the GAME's names (`sticker_id`,
|
|
10
|
+
* `offset_x`, `pattern`), because those are what the shader parameters, the WeaponPaints columns and
|
|
11
|
+
* the inspect codec all call the same six slots. That is right for the wire and wrong for a prop: an
|
|
12
|
+
* integrator writing `offset_x` in a React tree is writing protobuf into their view layer.
|
|
13
|
+
*
|
|
14
|
+
* So there are two, and exactly one file that knows both. A field renamed on either side breaks HERE,
|
|
15
|
+
* at compile time, rather than becoming a picture that is quietly missing a sticker.
|
|
16
|
+
*
|
|
17
|
+
* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
18
|
+
* *** AND THE PIECE THE FRAME CANNOT DO FOR ITSELF: AN INSPECT LINK'S IDENTITY. ***
|
|
19
|
+
*
|
|
20
|
+
* `?i=` carries an item's CONFIGURATION - float, seed, StatTrak, the plate, five stickers and the
|
|
21
|
+
* charm - and the frame decodes all of it. What it does not carry, for the frame, is WHICH WEAPON: a
|
|
22
|
+
* link says `defindex: 7` and the renderer wants `weapon_ak47`, and the app's own `SkinViewer`
|
|
23
|
+
* says so in a comment naming this package as the place the table lives:
|
|
24
|
+
*
|
|
25
|
+
* "the 63-row table between them lives in `@skinhub/viewer`'s `src/weapons.ts` - an in-flight file
|
|
26
|
+
* outside this app's dependency graph."
|
|
27
|
+
*
|
|
28
|
+
* Which makes `inspectLink` a first-class prop rather than a documented gap. This file resolves the
|
|
29
|
+
* identity here, on the host, and hands the frame both halves. An integrator never decodes a link to
|
|
30
|
+
* use us - That is stated as a requirement and it is the path the product exists for.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { buildInspectUrl, readInspectUrl } from '@skinhub/cdn/inspect'
|
|
34
|
+
import { emptyKeychain, emptySticker, makeSkinPlacement, type SkinPlacement } from '@skinhub/cdn/placement'
|
|
35
|
+
|
|
36
|
+
import type { FrameItem, PlacementSlots } from './protocol.js'
|
|
37
|
+
import type { SkinViewerCharm, SkinViewerError, SkinViewerItem, SkinViewerSticker, ViewerSubject } from './types.js'
|
|
38
|
+
import { defindexForWeaponId, normalizeWeaponId, weaponIdForDefindex } from './weapons.js'
|
|
39
|
+
|
|
40
|
+
/** `sticker_id === 0` is how the wire says "this slot is empty". */
|
|
41
|
+
const isPlaced = (placement: { sticker_id: number }) => placement.sticker_id > 0
|
|
42
|
+
|
|
43
|
+
/** Six empty slots. Rebuilt per call because the caller mutates the copy it is handed. */
|
|
44
|
+
const emptySlots = (): PlacementSlots => [
|
|
45
|
+
emptySticker(0),
|
|
46
|
+
emptySticker(1),
|
|
47
|
+
emptySticker(2),
|
|
48
|
+
emptySticker(3),
|
|
49
|
+
emptySticker(4),
|
|
50
|
+
emptyKeychain(),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The public sticker list and charm as the six-slot tuple the frame validates.
|
|
55
|
+
*
|
|
56
|
+
* *** ARRAY POSITION IS THE SLOT UNLESS A STICKER NAMES ITS OWN, *** which is what makes
|
|
57
|
+
* `stickers={[a, null, b]}` mean slots 0 and 2 - the shape a marketplace's own UI produces when a user
|
|
58
|
+
* has filled two of five holders. A sticker naming a slot outside `0..4` is dropped rather than
|
|
59
|
+
* wrapped, because a sixth sticker slot is the charm's and putting a sticker there would be a picture
|
|
60
|
+
* nobody asked for.
|
|
61
|
+
*
|
|
62
|
+
* RETURNS `undefined` WHEN THERE IS NOTHING PLACED, so an item with no stickers sends no `stickers`
|
|
63
|
+
* key at all and cannot disturb the frame's sticker draft. See `patch.ts`.
|
|
64
|
+
*/
|
|
65
|
+
export const toSlots = (
|
|
66
|
+
stickers: readonly (SkinViewerSticker | null)[] | undefined,
|
|
67
|
+
charm: SkinViewerCharm | null | undefined,
|
|
68
|
+
): PlacementSlots | undefined => {
|
|
69
|
+
if ((!stickers || stickers.length === 0) && !charm) return undefined
|
|
70
|
+
const slots = emptySlots()
|
|
71
|
+
|
|
72
|
+
stickers?.forEach((sticker, index) => {
|
|
73
|
+
if (!sticker) return
|
|
74
|
+
const slot = sticker.slot ?? index
|
|
75
|
+
if (slot < 0 || slot > 4) return
|
|
76
|
+
slots[slot as 0 | 1 | 2 | 3 | 4] = {
|
|
77
|
+
slot,
|
|
78
|
+
sticker_id: sticker.id,
|
|
79
|
+
wear: sticker.wear ?? 0,
|
|
80
|
+
// The game treats an unset scale as 1 and an inspect link cannot carry `scale <= 0`. It is not
|
|
81
|
+
// on the public surface because nothing in CS2 sets it per sticker.
|
|
82
|
+
scale: 1,
|
|
83
|
+
rotation: sticker.rotation ?? 0,
|
|
84
|
+
offset_x: sticker.offsetX ?? 0,
|
|
85
|
+
offset_y: sticker.offsetY ?? 0,
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
if (charm)
|
|
90
|
+
slots[5] = {
|
|
91
|
+
slot: 0,
|
|
92
|
+
sticker_id: charm.id,
|
|
93
|
+
// `pattern` IS the charm's seed - the keychain message is the sticker message reused, so the
|
|
94
|
+
// field it lands in is the one a sticker calls its pattern index.
|
|
95
|
+
pattern: charm.seed ?? 0,
|
|
96
|
+
offset_x: charm.offset?.[0] ?? 0,
|
|
97
|
+
offset_y: charm.offset?.[1] ?? 0,
|
|
98
|
+
offset_z: charm.offset?.[2] ?? 0,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return slots
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The six-slot tuple back into the public shape - what `onChange` hands an integrator. */
|
|
105
|
+
export const fromSlots = (
|
|
106
|
+
slots: PlacementSlots | undefined,
|
|
107
|
+
): { stickers: SkinViewerSticker[]; charm: SkinViewerCharm | null } => {
|
|
108
|
+
if (!slots) return { stickers: [], charm: null }
|
|
109
|
+
const stickers = slots.slice(0, 5).flatMap((placement): SkinViewerSticker[] => {
|
|
110
|
+
const sticker = placement as PlacementSlots[0]
|
|
111
|
+
if (!isPlaced(sticker)) return []
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
id: sticker.sticker_id,
|
|
115
|
+
slot: sticker.slot as SkinViewerSticker['slot'],
|
|
116
|
+
wear: sticker.wear,
|
|
117
|
+
rotation: sticker.rotation,
|
|
118
|
+
offsetX: sticker.offset_x,
|
|
119
|
+
offsetY: sticker.offset_y,
|
|
120
|
+
},
|
|
121
|
+
]
|
|
122
|
+
})
|
|
123
|
+
const keychain = slots[5]
|
|
124
|
+
const charm: SkinViewerCharm | null = isPlaced(keychain)
|
|
125
|
+
? {
|
|
126
|
+
id: keychain.sticker_id,
|
|
127
|
+
seed: keychain.pattern,
|
|
128
|
+
offset: [keychain.offset_x, keychain.offset_y, keychain.offset_z],
|
|
129
|
+
}
|
|
130
|
+
: null
|
|
131
|
+
return { stickers, charm }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
135
|
+
* THE SUBJECT
|
|
136
|
+
* ═══════════════════════════════════════════════════════════════════════════════════════════ */
|
|
137
|
+
|
|
138
|
+
export type ResolvedSubject =
|
|
139
|
+
| { item: FrameItem; inspectPayload: string | null; error: null }
|
|
140
|
+
/**
|
|
141
|
+
* *** NO ITEM AND NO GUESS. *** Every failure here produces a null item, and `SkinViewer` then asks
|
|
142
|
+
* the frame for its instruction card rather than letting it fall back to our default AK - which
|
|
143
|
+
* would look exactly like a successful render of the wrong gun, and is the one outcome worth more
|
|
144
|
+
* than all the rest of this file to avoid.
|
|
145
|
+
*/
|
|
146
|
+
| { item: null; inspectPayload: null; error: SkinViewerError }
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A `SkinPlacement` (whatever produced it) as the frame's item.
|
|
150
|
+
*
|
|
151
|
+
* Every field maps 1:1 with no rescaling: `g_vStickerNOffset` is `Range2(-0.5,-0.5, 0.5,0.5)` and the
|
|
152
|
+
* protobuf carries that range verbatim, so the offsets really are the same numbers.
|
|
153
|
+
*/
|
|
154
|
+
const fromPlacement = (placement: SkinPlacement, weaponType: string): FrameItem => {
|
|
155
|
+
const slots = emptySlots()
|
|
156
|
+
for (const sticker of placement.stickers ?? []) {
|
|
157
|
+
if (!isPlaced(sticker)) continue
|
|
158
|
+
const slot = sticker.slot
|
|
159
|
+
if (slot < 0 || slot > 4) continue
|
|
160
|
+
slots[slot as 0 | 1 | 2 | 3 | 4] = sticker
|
|
161
|
+
}
|
|
162
|
+
if (placement.keychain && isPlaced(placement.keychain)) slots[5] = placement.keychain
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
weaponType,
|
|
166
|
+
paintIndex: placement.paintindex,
|
|
167
|
+
float: placement.paintwear,
|
|
168
|
+
seed: placement.paintseed,
|
|
169
|
+
// `stattrak` present is what makes an item StatTrak and `0` is a real count - see the prop's own
|
|
170
|
+
// doc for why this collapses to one field rather than two.
|
|
171
|
+
statTrak: placement.stattrak ? (placement.stattrak_count ?? 0) : false,
|
|
172
|
+
nameTag: placement.nametag ?? null,
|
|
173
|
+
stickers: slots,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* *** THE ONE FUNCTION THE WHOLE PROP SURFACE FUNNELS THROUGH. *** Either arm of
|
|
179
|
+
* {@link ViewerSubject}, plus the runtime cases the types were supposed to prevent, into one item.
|
|
180
|
+
*
|
|
181
|
+
* IT IS PURE AND SYNCHRONOUS - no fetch, no React, no catalogue - which is what lets the component
|
|
182
|
+
* build the frame's URL in a `useState` initialiser and have the FIRST PAINT be the integrator's item
|
|
183
|
+
* rather than ours swapped a tick later.
|
|
184
|
+
*/
|
|
185
|
+
export const resolveSubject = (subject: Partial<ViewerSubject>): ResolvedSubject => {
|
|
186
|
+
const fail = (code: SkinViewerError['code'], message: string): ResolvedSubject => ({
|
|
187
|
+
item: null,
|
|
188
|
+
inspectPayload: null,
|
|
189
|
+
error: { code, message },
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
if (typeof subject.inspectLink === 'string' && subject.inspectLink.length > 0) {
|
|
193
|
+
let placement: SkinPlacement
|
|
194
|
+
try {
|
|
195
|
+
placement = readInspectUrl(subject.inspectLink)
|
|
196
|
+
} catch (cause) {
|
|
197
|
+
/*
|
|
198
|
+
* MASKED LINKS ONLY, and this is where an integrator finds that out. The `S…A…D…` / `M…`
|
|
199
|
+
* inventory and market forms carry no item data at all - they needed a Game Coordinator round
|
|
200
|
+
* trip Valve has shut down - so there is nothing in them to render and no partial answer to
|
|
201
|
+
* fall back to.
|
|
202
|
+
*/
|
|
203
|
+
return fail(
|
|
204
|
+
'bad-inspect-link',
|
|
205
|
+
`The inspect link did not decode: ${cause instanceof Error ? cause.message : String(cause)}. Only MASKED links (the long hex payload) carry item data; the S…A…D… and M… inventory forms needed a Game Coordinator round trip Valve has shut down and cannot be rendered by anyone.`,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
const weaponType = weaponIdForDefindex(placement.defindex)
|
|
209
|
+
if (!weaponType)
|
|
210
|
+
return fail(
|
|
211
|
+
'unknown-weapon',
|
|
212
|
+
`The inspect link decoded, but defindex ${placement.defindex} is not a weapon this build of @skinhub/viewer has an id for. Pass \`item={{ weapon: '…', paintIndex: ${placement.paintindex} }}\` instead, or update the package.`,
|
|
213
|
+
)
|
|
214
|
+
return {
|
|
215
|
+
item: fromPlacement(placement, weaponType),
|
|
216
|
+
/*
|
|
217
|
+
* THE ORIGINAL STRING IS KEPT AND FORWARDED AS `?i=` RATHER THAN RE-ENCODED. A round trip
|
|
218
|
+
* through our own encoder would be a second implementation of the codec in the URL path, free
|
|
219
|
+
* to disagree with the one the frame decodes with; forwarding the customer's own bytes means
|
|
220
|
+
* the frame reads exactly what Valve wrote.
|
|
221
|
+
*/
|
|
222
|
+
inspectPayload: subject.inspectLink,
|
|
223
|
+
error: null,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const item = subject.item
|
|
228
|
+
if (!item || typeof item !== 'object')
|
|
229
|
+
return fail(
|
|
230
|
+
'no-item',
|
|
231
|
+
'No item. <SkinViewer> needs exactly one of `inspectLink` or `item` - in TypeScript that is a compile error, so this is JavaScript, an `any`, or data that had not arrived yet.',
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
const weapon = item.weapon ?? (typeof item.defindex === 'number' ? weaponIdForDefindex(item.defindex) : undefined)
|
|
235
|
+
if (!weapon)
|
|
236
|
+
return typeof item.defindex === 'number'
|
|
237
|
+
? fail(
|
|
238
|
+
'unknown-weapon',
|
|
239
|
+
`defindex ${item.defindex} is not a weapon this build of @skinhub/viewer has an id for. Pass \`weapon\` instead, or update the package.`,
|
|
240
|
+
)
|
|
241
|
+
: fail(
|
|
242
|
+
'no-item',
|
|
243
|
+
'`item` named no weapon. Pass either `weapon: "weapon_ak47"` (from `skin.weapon.id` on a @skinhub/cdn row) or `defindex: 7`.',
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if (typeof item.paintIndex !== 'number')
|
|
247
|
+
return fail(
|
|
248
|
+
'no-item',
|
|
249
|
+
'`item.paintIndex` is required. `0` is the correct value for a vanilla item and renders the bare model.',
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
item: {
|
|
254
|
+
// HUD aliases folded here rather than at the frame: `skins.json` gives the twenty VANILLA knife
|
|
255
|
+
// rows an `sfui_wpnhud_*` id, so `row.weapon.id` off a vanilla Bayonet is a HUD string. The
|
|
256
|
+
// renderer resolves both to the same GLB; folding here also makes the value we echo back in
|
|
257
|
+
// `onChange` the item id rather than the alias.
|
|
258
|
+
weaponType: normalizeWeaponId(weapon),
|
|
259
|
+
paintIndex: item.paintIndex,
|
|
260
|
+
...(item.legacyModel !== undefined && { legacyModel: item.legacyModel }),
|
|
261
|
+
...(item.float !== undefined && { float: item.float }),
|
|
262
|
+
...(item.seed !== undefined && { seed: item.seed }),
|
|
263
|
+
...(item.statTrak !== undefined && { statTrak: item.statTrak }),
|
|
264
|
+
...(item.nameTag !== undefined && { nameTag: item.nameTag }),
|
|
265
|
+
...(() => {
|
|
266
|
+
const slots = toSlots(item.stickers, item.charm)
|
|
267
|
+
return slots ? { stickers: slots } : {}
|
|
268
|
+
})(),
|
|
269
|
+
},
|
|
270
|
+
inspectPayload: null,
|
|
271
|
+
error: null,
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The frame's item as the public one - what {@link SkinViewerProps.onChange} hands back.
|
|
277
|
+
*
|
|
278
|
+
* *** ALWAYS THE `{ weapon }` FORM, NEVER `{ defindex }`. *** The frame reports a `weaponType`, and
|
|
279
|
+
* turning it back into a number is a lookup that can fail for a weapon shipped after this package
|
|
280
|
+
* was built. An integrator who needs the number has `defindexForWeaponId` exported for it, where the
|
|
281
|
+
* failure is theirs to see rather than ours to hide.
|
|
282
|
+
*/
|
|
283
|
+
export const toPublicItem = (item: FrameItem): SkinViewerItem => {
|
|
284
|
+
const { stickers, charm } = fromSlots(item.stickers)
|
|
285
|
+
return {
|
|
286
|
+
weapon: item.weaponType,
|
|
287
|
+
paintIndex: item.paintIndex,
|
|
288
|
+
...(item.legacyModel !== undefined && { legacyModel: item.legacyModel }),
|
|
289
|
+
...(item.float !== undefined && { float: item.float }),
|
|
290
|
+
...(item.seed !== undefined && { seed: item.seed }),
|
|
291
|
+
...(item.statTrak !== undefined && { statTrak: item.statTrak }),
|
|
292
|
+
...(item.nameTag !== undefined && { nameTag: item.nameTag }),
|
|
293
|
+
stickers,
|
|
294
|
+
charm,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
299
|
+
* BACK OUT AGAIN — AN ITEM AS AN INSPECT LINK
|
|
300
|
+
*
|
|
301
|
+
* *** THE RETURN JOURNEY, AND IT IS THE HALF THAT WAS MISSING. *** Everything above turns an
|
|
302
|
+
* integrator's item INTO a picture. A skin picker is not finished when it can show you the item; it is
|
|
303
|
+
* finished when it can hand you the link. Without these two functions every integrator writes the same
|
|
304
|
+
* forty lines against `@skinhub/cdn`'s placement API - and writes them from the same three facts that
|
|
305
|
+
* are easy to get wrong and silent when you do:
|
|
306
|
+
*
|
|
307
|
+
* - `stattrak: 0` is a REAL, freshly-minted counter and `false` is no module. One boolean and one
|
|
308
|
+
* count, not one nullable number.
|
|
309
|
+
* - an unset sticker `scale` is `1`, not `0`. The WeaponPaints row default is `0` meaning "default",
|
|
310
|
+
* and an encoder rejects `scale <= 0`, so passing it through produces a link that will not build.
|
|
311
|
+
* - a charm's seed rides in `pattern`, because the keychain message is the sticker message reused.
|
|
312
|
+
*
|
|
313
|
+
* That list is the argument for these living here rather than in a docs snippet: they are the same
|
|
314
|
+
* three facts `toSlots` already encodes, and having them written twice is how the two copies disagree.
|
|
315
|
+
* ═══════════════════════════════════════════════════════════════════════════════════════════ */
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* A viewer item as a `SkinPlacement` — `@skinhub/cdn`'s decoded-inspect-link shape.
|
|
319
|
+
*
|
|
320
|
+
* Reach for this when you want the placement itself: to write WeaponPaints rows, to diff against a
|
|
321
|
+
* link you already hold, or to hand to another `@skinhub/cdn` helper. If you just want the link, use
|
|
322
|
+
* {@link toInspectLink}.
|
|
323
|
+
*
|
|
324
|
+
* *** THROWS ON A WEAPON WITH NO DEFINDEX, and that is the only honest answer. *** An inspect link
|
|
325
|
+
* identifies its item by number, so an id this build has no row for cannot be encoded at all. Returning
|
|
326
|
+
* a link with `defindex: 0` in it would produce a string that looks like a link, copies like a link and
|
|
327
|
+
* opens an empty CS2 inspect screen.
|
|
328
|
+
*/
|
|
329
|
+
export const toPlacement = (item: SkinViewerItem): SkinPlacement => {
|
|
330
|
+
const weapon = 'weapon' in item && item.weapon ? item.weapon : undefined
|
|
331
|
+
const defindex = weapon ? defindexForWeaponId(weapon) : item.defindex
|
|
332
|
+
if (typeof defindex !== 'number')
|
|
333
|
+
throw new Error(
|
|
334
|
+
`@skinhub/viewer: cannot build an inspect link for ${JSON.stringify(weapon)} — this build has no defindex for it. Pass \`defindex\` on the item, or update the package.`,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
const slots = toSlots(item.stickers, item.charm) ?? emptySlots()
|
|
338
|
+
return makeSkinPlacement({
|
|
339
|
+
defindex,
|
|
340
|
+
paintindex: item.paintIndex,
|
|
341
|
+
paintseed: item.seed ?? 0,
|
|
342
|
+
paintwear: item.float ?? 0,
|
|
343
|
+
...(item.nameTag ? { nametag: item.nameTag } : {}),
|
|
344
|
+
// `statTrak: 0` is a counter that has not counted yet. `false` is no module at all.
|
|
345
|
+
stattrak: item.statTrak !== undefined && item.statTrak !== false,
|
|
346
|
+
stattrak_count: typeof item.statTrak === 'number' ? item.statTrak : 0,
|
|
347
|
+
stickers: slots.slice(0, 5) as SkinPlacement['stickers'],
|
|
348
|
+
keychain: slots[5],
|
|
349
|
+
} as SkinPlacement)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* A viewer item as a masked Steam inspect link — the string a user pastes into the game.
|
|
354
|
+
*
|
|
355
|
+
* <button onClick={() => navigator.clipboard.writeText(toInspectLink(item))}>Copy inspect link</button>
|
|
356
|
+
*
|
|
357
|
+
* The inverse of passing `inspectLink` to `<SkinViewer>`, and it round-trips: a link built here decodes
|
|
358
|
+
* back to the same item through {@link fromInspectLink}.
|
|
359
|
+
*/
|
|
360
|
+
export const toInspectLink = (item: SkinViewerItem): string => buildInspectUrl(toPlacement(item))
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A masked inspect link as a viewer item — what `<SkinViewer inspectLink={…} />` does internally,
|
|
364
|
+
* exposed for a host that wants the fields rather than the picture (to seed an editor from a link, or
|
|
365
|
+
* to read a float out of one).
|
|
366
|
+
*
|
|
367
|
+
* *** RETURNS `null` RATHER THAN THROWING ON A LINK IT CANNOT READ, *** because the input is usually
|
|
368
|
+
* something a user pasted, and a paste being wrong is an ordinary event rather than an exception.
|
|
369
|
+
*/
|
|
370
|
+
export const fromInspectLink = (link: string): SkinViewerItem | null => {
|
|
371
|
+
const resolved = resolveSubject({ inspectLink: link })
|
|
372
|
+
return resolved.item ? toPublicItem(resolved.item) : null
|
|
373
|
+
}
|
package/src/link.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* *** THE PRIVATE CHANNEL BETWEEN THE HOOK AND THE COMPONENT. ***
|
|
3
|
+
*
|
|
4
|
+
* `useSkinViewer()` returns a handle; `<SkinViewer handle={…} />` fills it in. Something has to carry
|
|
5
|
+
* `reload` from the component that can do it to the object that exposes it, and carry `status` back
|
|
6
|
+
* the other way.
|
|
7
|
+
*
|
|
8
|
+
* *** A SYMBOL KEY RATHER THAN `__internal`, *** for one practical reason: a symbol does not appear in
|
|
9
|
+
* an editor's autocomplete on the handle, so the only members an integrator ever sees are the four
|
|
10
|
+
* documented ones. It is not hiding - anyone can reach it - it is keeping the public surface exactly
|
|
11
|
+
* as large as the documentation says it is.
|
|
12
|
+
*
|
|
13
|
+
* *** AND IT IS A MUTABLE OBJECT RATHER THAN A CALLBACK PROP, *** because the identity of the thing
|
|
14
|
+
* passed as `handle` must never change: it is a plain prop on a component that re-renders on every
|
|
15
|
+
* frame of a float drag, and a fresh object there would be one more thing to compare per frame.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { SkinViewerError, ViewerStatus } from './types.js'
|
|
19
|
+
|
|
20
|
+
export const LINK: unique symbol = Symbol('@skinhub/viewer/link')
|
|
21
|
+
|
|
22
|
+
export type ViewerSnapshot = {
|
|
23
|
+
status: ViewerStatus
|
|
24
|
+
error: SkinViewerError | null
|
|
25
|
+
problems: readonly string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ViewerLink = {
|
|
29
|
+
/** Replaced by the mounted component; a no-op before mount and after unmount. */
|
|
30
|
+
reload: () => void
|
|
31
|
+
/** The component pushing its state up. Bails when nothing moved - see `useSkinViewer`. */
|
|
32
|
+
publish: (snapshot: ViewerSnapshot) => void
|
|
33
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* *** THE HOST HALF OF THE WIRE. *** `@skinhub/viewer/protocol`.
|
|
3
|
+
*
|
|
4
|
+
* The frame's half is `app/frame/protocol.ts` inside the SkinHub app, which is a PRIVATE repository -
|
|
5
|
+
* it carries the renderer, the shader transcriptions and the export-fed material pipeline. That half
|
|
6
|
+
* is the specification. This is the same contract written from the other end of the channel.
|
|
7
|
+
*
|
|
8
|
+
* *** THE TWO FILES ARE NOT SHARED CODE AND CANNOT BE - and the reason is not that they now live in
|
|
9
|
+
* different repositories, it is what each one is MADE OF. *** The frame's half is typed in the
|
|
10
|
+
* renderer's own prop types and validates against the renderer's map list, its view enum and its
|
|
11
|
+
* placement defaults; a customer's bundle must not carry any of that. This half is typed in plain
|
|
12
|
+
* data and uses no vocabulary a customer cannot find documented in `EMBED.md`. Merging them means
|
|
13
|
+
* either shipping the renderer's surface to every integrator, or making the specification depend on a
|
|
14
|
+
* published package in order to describe itself.
|
|
15
|
+
*
|
|
16
|
+
* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
17
|
+
* *** SO THE DUPLICATION IS DELIBERATE - AND IT IS CHECKED BY A MACHINE, IN TWO PLACES. ***
|
|
18
|
+
*
|
|
19
|
+
* 1. `test/wire.test.ts`, HERE. Every name and every field this file puts on the wire is frozen in
|
|
20
|
+
* a literal, and the literals are checked EXHAUSTIVE against the types, so a field cannot be
|
|
21
|
+
* added to the wire without the freeze failing to compile. That is what makes an edit to this
|
|
22
|
+
* file deliberate rather than incidental, and it is the moment you are told to go and change the
|
|
23
|
+
* frame and bump {@link FRAME_PROTOCOL_VERSION}.
|
|
24
|
+
*
|
|
25
|
+
* 2. `app/frame/protocol.conformance.test.ts`, IN THE APP REPO - the only place both halves exist
|
|
26
|
+
* at once. It feeds this file's own `hostMessage()` output through the frame's real validator and
|
|
27
|
+
* requires ZERO rejected fields, feeds the frame's real events through {@link readFrameEvent},
|
|
28
|
+
* and compares the two key sets at the type level. That is the check that actually catches drift,
|
|
29
|
+
* because it exercises the frame's reader rather than a restatement of it.
|
|
30
|
+
*
|
|
31
|
+
* *** THE VERSION INTEGER IS THE BACKSTOP, NOT THE CHECK, and the difference is the whole point. ***
|
|
32
|
+
* A mismatch is terminal on both sides - the frame renders nothing and names which side is stale, this
|
|
33
|
+
* package stops sending - so a mismatch that HAPPENS is loud. But it only happens if somebody
|
|
34
|
+
* remembered to bump the integer. The failure the two checks above exist for is the other one: a field
|
|
35
|
+
* added to the frame, the integer left alone, both sides claiming `v: 1`, and the field silently
|
|
36
|
+
* dropped. A silently wrong render is worse than a blank frame with an explanation, and this project
|
|
37
|
+
* has been bitten by that class of failure repeatedly.
|
|
38
|
+
*
|
|
39
|
+
* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
40
|
+
* *** THE VOCABULARY HERE IS THE FRAME'S, NOT THE INTEGRATOR'S. ***
|
|
41
|
+
*
|
|
42
|
+
* `weaponType`, `sticker_id`, `offset_x` - the renderer's own names and the game's own field names.
|
|
43
|
+
* `item.ts` translates between this and `types.ts`, and that translation is the package's actual job:
|
|
44
|
+
* an integrator writing `offset_x` into a React tree is writing protobuf into their view layer.
|
|
45
|
+
*
|
|
46
|
+
* *** WHICH IS WHY THIS IS A SEPARATE ENTRY POINT AND NOT PART OF THE BARREL. *** It is exported at
|
|
47
|
+
* all because the wire is ALREADY public - `EMBED.md` §6 documents this envelope, this patch and these
|
|
48
|
+
* events in full, for the PHP / Vue / plain-`<script>` hosts the URL contract exists for - so
|
|
49
|
+
* withholding the TypeScript for a shape we publish in prose bought nothing, and made the conformance
|
|
50
|
+
* check above impossible to write. It carries no compatibility promise beyond the integer: it moves
|
|
51
|
+
* when the wire moves, which is the whole no-back-compat bargain. Reach for `SkinViewer` from the
|
|
52
|
+
* barrel unless you are writing a host in something that is not React.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import type { KeychainPlacement, StickerPlacement } from '@skinhub/cdn/placement'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The discriminator on every message in both directions.
|
|
59
|
+
*
|
|
60
|
+
* It exists because `window.postMessage` is a shared bus: React DevTools, Next's dev overlay, HMR,
|
|
61
|
+
* wallet extensions and analytics tags all post into and out of frames, and several post objects with
|
|
62
|
+
* a `type` field. Anything without this key is not ours and is dropped in silence.
|
|
63
|
+
*/
|
|
64
|
+
export const FRAME_CHANNEL = 'skinhub-viewer'
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The protocol version this package speaks.
|
|
68
|
+
*
|
|
69
|
+
* *** AN INTEGER, NOT A SEMVER RANGE, because there is no range: *** there is no back-compat window
|
|
70
|
+
* and never will be. Two integers either match or they do not, and the failure text can
|
|
71
|
+
* then say which side is behind.
|
|
72
|
+
*
|
|
73
|
+
* *** IT IS ALSO THE PACKAGE'S EXPIRY DATE, AND THAT IS THE INTENT. *** When we change the wire, every
|
|
74
|
+
* published copy of this package stops working loudly and at once. That is the cost of the no-back-compat
|
|
75
|
+
* decision and it is paid deliberately, in exchange for never shipping a viewer that renders a
|
|
76
|
+
* partly-understood item.
|
|
77
|
+
*/
|
|
78
|
+
export const FRAME_PROTOCOL_VERSION = 1
|
|
79
|
+
|
|
80
|
+
/* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
81
|
+
* THE STATE, IN THE FRAME'S WORDS
|
|
82
|
+
* ═══════════════════════════════════════════════════════════════════════════════════════════ */
|
|
83
|
+
|
|
84
|
+
/** Five stickers plus the charm in slot 5. The frame validates the SHAPE of this and nothing else. */
|
|
85
|
+
export type PlacementSlots = [
|
|
86
|
+
StickerPlacement,
|
|
87
|
+
StickerPlacement,
|
|
88
|
+
StickerPlacement,
|
|
89
|
+
StickerPlacement,
|
|
90
|
+
StickerPlacement,
|
|
91
|
+
KeychainPlacement,
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
export type FrameItem = {
|
|
95
|
+
/** `weapon_ak47`, or a glove id. The key the renderer's model table resolves. */
|
|
96
|
+
weaponType: string
|
|
97
|
+
paintIndex: number
|
|
98
|
+
legacyModel?: boolean
|
|
99
|
+
float?: number
|
|
100
|
+
seed?: number
|
|
101
|
+
statTrak?: number | false
|
|
102
|
+
nameTag?: string | null
|
|
103
|
+
stickers?: PlacementSlots
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type FrameSettings = {
|
|
107
|
+
camera?: { fov?: number; defaultZoom?: number }
|
|
108
|
+
quality?: { bloom?: number; bloomSpill?: number; renderScale?: number; antialias?: boolean; shadows?: boolean }
|
|
109
|
+
environment?: { map?: string | null; timeOfDay?: string; rain?: boolean; background?: string }
|
|
110
|
+
overlays?: {
|
|
111
|
+
stickerGizmo?: boolean
|
|
112
|
+
charmGizmo?: boolean
|
|
113
|
+
gizmoStyle?: { color?: string; shadowColor?: string }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type FrameInteractions = {
|
|
118
|
+
orbit?: boolean
|
|
119
|
+
zoom?: boolean
|
|
120
|
+
dragStickers?: boolean
|
|
121
|
+
dragCharm?: boolean
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Everything `/frame` holds. One field per prop of the renderer that a host can set. */
|
|
125
|
+
export type FrameState = {
|
|
126
|
+
item: FrameItem
|
|
127
|
+
view: 'gun' | 'hands' | 'agent'
|
|
128
|
+
agent: { id: number; pose?: string | null }
|
|
129
|
+
gloves: { type: string; paintIndex: number; float?: number; seed?: number } | null
|
|
130
|
+
settings: FrameSettings
|
|
131
|
+
interactions: FrameInteractions
|
|
132
|
+
editingSlot: number
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A partial write over {@link FrameState}.
|
|
137
|
+
*
|
|
138
|
+
* *** EVERY OBJECT MERGES BY FIELD AND AN ABSENT KEY MEANS "LEAVE IT ALONE". *** That rule is not a
|
|
139
|
+
* convenience, it is the cheap-update contract: `{ item: { float } }` keeps `weaponType`, so the
|
|
140
|
+
* frame's loading gate - which is keyed on the identity VALUES - does not move. A patch that replaced
|
|
141
|
+
* rather than merged would blank the weapon on every float tick.
|
|
142
|
+
*/
|
|
143
|
+
export type FramePatch = {
|
|
144
|
+
item?: Partial<FrameItem>
|
|
145
|
+
view?: FrameState['view']
|
|
146
|
+
agent?: Partial<FrameState['agent']>
|
|
147
|
+
gloves?: FrameState['gloves']
|
|
148
|
+
settings?: FrameSettings
|
|
149
|
+
interactions?: FrameInteractions
|
|
150
|
+
editingSlot?: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
154
|
+
* THE MESSAGES
|
|
155
|
+
* ═══════════════════════════════════════════════════════════════════════════════════════════ */
|
|
156
|
+
|
|
157
|
+
type Envelope = { channel: typeof FRAME_CHANNEL; v: number; from: 'host' | 'viewer' }
|
|
158
|
+
|
|
159
|
+
export type HostMessage = (Envelope & { type: 'hello' }) | (Envelope & { type: 'set'; patch: FramePatch })
|
|
160
|
+
|
|
161
|
+
export type FrameErrorCode = 'render-failed' | 'bad-inspect-link' | 'bad-message' | 'protocol-mismatch'
|
|
162
|
+
export type FrameError = { code: FrameErrorCode; message: string }
|
|
163
|
+
|
|
164
|
+
export type FrameEvent =
|
|
165
|
+
| (Envelope & { type: 'hello'; state: FrameState; problems: string[] })
|
|
166
|
+
| (Envelope & { type: 'ready' })
|
|
167
|
+
| (Envelope & { type: 'error'; error: FrameError })
|
|
168
|
+
| (Envelope & { type: 'change'; item: FrameItem })
|
|
169
|
+
| (Envelope & { type: 'editing-slot'; slot: number })
|
|
170
|
+
| (Envelope & { type: 'resize'; width: number; height: number; dpr: number })
|
|
171
|
+
|
|
172
|
+
/** Stamps the envelope, so no call site can forget the channel, the version or the direction. */
|
|
173
|
+
export const hostMessage = (patch?: FramePatch): HostMessage =>
|
|
174
|
+
patch
|
|
175
|
+
? { channel: FRAME_CHANNEL, v: FRAME_PROTOCOL_VERSION, from: 'host', type: 'set', patch }
|
|
176
|
+
: { channel: FRAME_CHANNEL, v: FRAME_PROTOCOL_VERSION, from: 'host', type: 'hello' }
|
|
177
|
+
|
|
178
|
+
/* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
179
|
+
* READING WHAT COMES BACK
|
|
180
|
+
* ═══════════════════════════════════════════════════════════════════════════════════════════ */
|
|
181
|
+
|
|
182
|
+
export type FrameEventReading =
|
|
183
|
+
/** Not ours - HMR, an extension, another library, or our own message echoing. Drop it silently. */
|
|
184
|
+
| { kind: 'ignore' }
|
|
185
|
+
/** Ours, and the versions disagree. Terminal. */
|
|
186
|
+
| { kind: 'mismatch'; error: FrameError }
|
|
187
|
+
| { kind: 'event'; event: FrameEvent }
|
|
188
|
+
|
|
189
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
190
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* One arriving `MessageEvent.data`, classified.
|
|
194
|
+
*
|
|
195
|
+
* *** THE HOST SIDE CHECKS THE VERSION TOO, AND IT IS NOT REDUNDANT WITH THE FRAME'S CHECK. *** The
|
|
196
|
+
* frame only learns about a mismatch once the host has SENT something. A host that configures the
|
|
197
|
+
* viewer entirely from props and never changes them still receives `hello`, `ready` and `change` - and
|
|
198
|
+
* if the frame is newer, those payloads may have moved. Reading them anyway is how a stale package
|
|
199
|
+
* hands its user a `change` event with half an item in it, which is the exact failure mode
|
|
200
|
+
* the no-back-compat rule forbids. So a mismatch heard here is fatal here as well.
|
|
201
|
+
*
|
|
202
|
+
* `from: 'viewer'` IS CHECKED because both directions share one channel and both have a `hello`.
|
|
203
|
+
* Without it a host that also embeds another host's frame could read its own outbound traffic.
|
|
204
|
+
*/
|
|
205
|
+
export const readFrameEvent = (data: unknown): FrameEventReading => {
|
|
206
|
+
if (!isRecord(data) || data.channel !== FRAME_CHANNEL || data.from !== 'viewer') return { kind: 'ignore' }
|
|
207
|
+
|
|
208
|
+
if (data.v !== FRAME_PROTOCOL_VERSION)
|
|
209
|
+
return { kind: 'mismatch', error: { code: 'protocol-mismatch', message: versionMessage(data.v) } }
|
|
210
|
+
|
|
211
|
+
if (typeof data.type !== 'string') return { kind: 'ignore' }
|
|
212
|
+
/*
|
|
213
|
+
* A VERB THIS BUILD DOES NOT HAVE, ON A VERSION IT DOES: only possible if the frame gained an event
|
|
214
|
+
* without bumping the integer, which is our mistake and not the integrator's. Dropped rather than
|
|
215
|
+
* reported, because there is nothing they could do about it and an error they cannot act on is
|
|
216
|
+
* noise in their console.
|
|
217
|
+
*/
|
|
218
|
+
if (!KNOWN_EVENTS.has(data.type)) return { kind: 'ignore' }
|
|
219
|
+
|
|
220
|
+
return { kind: 'event', event: data as unknown as FrameEvent }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const KNOWN_EVENTS: ReadonlySet<string> = new Set(['hello', 'ready', 'error', 'change', 'editing-slot', 'resize'])
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The sentence a developer reads when the versions disagree - the whole of what the no-back-compat
|
|
227
|
+
* decision buys, so it names the DIRECTION rather than just the numbers.
|
|
228
|
+
*
|
|
229
|
+
* A PACKAGE BEHIND THE EMBED is the ordinary case and the one with an action attached: they installed
|
|
230
|
+
* `@skinhub/viewer` some months ago and we have shipped a protocol change since. Updating fixes it.
|
|
231
|
+
*
|
|
232
|
+
* A PACKAGE AHEAD OF THE EMBED means the embedded document is stale - almost always a cached
|
|
233
|
+
* `/frame` - so the action is a reload rather than an install. Telling somebody to update a package
|
|
234
|
+
* that is already newer would send them to the one place that cannot help.
|
|
235
|
+
*/
|
|
236
|
+
const versionMessage = (received: unknown): string => {
|
|
237
|
+
const seen = typeof received === 'number' ? received : JSON.stringify(received)
|
|
238
|
+
if (typeof received === 'number' && received < FRAME_PROTOCOL_VERSION)
|
|
239
|
+
return `@skinhub/viewer speaks protocol ${FRAME_PROTOCOL_VERSION} and the embed answered with ${seen}. The embedded page is out of date - it is cached, or the origin you pointed at is running an older build. Nothing has been rendered, deliberately: a partly-understood message would render a partly-correct item.`
|
|
240
|
+
return `@skinhub/viewer speaks protocol ${FRAME_PROTOCOL_VERSION} and the embed answered with ${seen}. This package is out of date - update @skinhub/viewer. Nothing has been rendered, deliberately: a partly-understood message would render a partly-correct item.`
|
|
241
|
+
}
|