@flow-industries/id 0.23.4 → 0.24.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.
@@ -0,0 +1,408 @@
1
+ /**
2
+ * The cosmetic catalog, the space each item occupies, the surfaces it exposes
3
+ * for paint, and the single rule that decides whether two items may be worn at
4
+ * once.
5
+ *
6
+ * Pure — no DB, no I/O — and deliberately the ONLY implementation of that rule.
7
+ * TF2 evaluated it per equip path and its quickswitch path skipped the check
8
+ * entirely, so players equipped conflicting cosmetics that silently unequipped
9
+ * on restart (Source-1-Games#4055). Here the rule hangs off the surface
10
+ * registry and runs inside the one settings write path, which is why no route
11
+ * can forget it: a client may grey out conflicting picks for UX, but the save
12
+ * is the enforcer.
13
+ *
14
+ * The catalog is a code constant rather than a table, matching the appearance
15
+ * palette beside it — adding an item is a one-line edit and no migration. It
16
+ * promotes to a `cosmetic` table when non-engineers must edit it live.
17
+ */
18
+ import { isString } from "../json";
19
+ import hairItems from "./hair-items.json";
20
+ /**
21
+ * Region pairs that physically overlap, declared once in ONE direction and
22
+ * symmetrised at module load so a pair can never be half-declared. Not
23
+ * transitive: a hat overlaps `whole_head` and `whole_head` overlaps `face`,
24
+ * but a hat and a bandana coexist happily.
25
+ */
26
+ const REGION_OVERLAPS = [
27
+ ["whole_head", "hat"],
28
+ ["whole_head", "hair"],
29
+ ["whole_head", "face"],
30
+ ["whole_head", "glasses"],
31
+ ["whole_head", "ears"],
32
+ ["glasses", "face"],
33
+ ];
34
+ const OVERLAPS = (() => {
35
+ const map = new Map();
36
+ const link = (from, to) => {
37
+ const set = map.get(from) ?? new Set();
38
+ set.add(to);
39
+ map.set(from, set);
40
+ };
41
+ for (const [left, right] of REGION_OVERLAPS) {
42
+ link(left, right);
43
+ link(right, left);
44
+ }
45
+ return map;
46
+ })();
47
+ /**
48
+ * Every slot, in the order the customizer and the resolver walk them, with the
49
+ * name a player reads it by and whether an avatar may go without it filled.
50
+ *
51
+ * `required` is a property of the SLOT and lives here because this is the only
52
+ * place both halves of it can be read from: the schema projects it onto the
53
+ * slot's descriptor so a client stops offering "None", and the surface's
54
+ * cross-field rule refuses an empty value for it inside the write path. A rule
55
+ * written down in auth and mirrored by hand in the customizer is the shape
56
+ * AUTH-210 went silent in and the one AUTH-222 undid.
57
+ *
58
+ * `satisfies` makes a new slot a typecheck error here until someone has said
59
+ * what it is called and whether an avatar may go without it.
60
+ */
61
+ const SLOTS = {
62
+ hair: { label: "Hair", required: true },
63
+ hat: { label: "Headwear", required: false },
64
+ face: { label: "Face", required: false },
65
+ top: { label: "Top", required: true },
66
+ bottom: { label: "Bottom", required: true },
67
+ feet: { label: "Footwear", required: false },
68
+ };
69
+ function slotNames() {
70
+ // SAFETY: SLOTS satisfies Record<CosmeticSlot, _>, so its keys are exactly the slot names.
71
+ return Object.keys(SLOTS);
72
+ }
73
+ /** Every slot, in the order the customizer and the resolver walk them. */
74
+ export const COSMETIC_SLOTS = slotNames();
75
+ /** What a player reads this slot's row as. */
76
+ export function slotLabel(slot) {
77
+ return SLOTS[slot].label;
78
+ }
79
+ /** Whether an avatar must wear something in this slot. */
80
+ export function slotRequired(slot) {
81
+ return SLOTS[slot].required;
82
+ }
83
+ /** Namespaces the paint keys so they can never collide with a fixed setting. */
84
+ const PAINT_KEY_PREFIX = "paint";
85
+ /** Separates a paint key's segments. Forbidden inside an id or a material name. */
86
+ const PAINT_KEY_SEPARATOR = ".";
87
+ /** A catalog over `items`, refusing a duplicate id. */
88
+ export function catalogOf(items) {
89
+ const byId = new Map(items.map((item) => [item.id, item]));
90
+ // A duplicate id would silently shadow one of the two items — loud beats a
91
+ // cosmetic that cannot be equipped and no error anywhere.
92
+ if (byId.size !== items.length) {
93
+ throw new Error("duplicate cosmetic id in the catalog");
94
+ }
95
+ return byId;
96
+ }
97
+ /**
98
+ * Every item a player may equip. EACH ONE MUST HAVE GEOMETRY: an id here with
99
+ * no mesh behind it renders as an item that vanishes when worn, and the model's
100
+ * side of the pairing is `cosmetic_items` in
101
+ * `game/games/arena/player/luna_bodygroups.tres` — a catalog id absent from
102
+ * there names nothing the renderer can draw.
103
+ */
104
+ export const COSMETICS = [
105
+ ...hairItems.map((item) => ({
106
+ ...item,
107
+ slot: "hair",
108
+ regions: item.id === "hair_original" ? [] : ["hair"],
109
+ hides: item.id === "hair_original" ? [] : ["hair"],
110
+ paintable: [],
111
+ })),
112
+ {
113
+ id: "round_glasses",
114
+ slot: "face",
115
+ name: "Round glasses",
116
+ regions: ["glasses"],
117
+ hides: [],
118
+ // `LUNA_glasses.glb` carries exactly one material, and its name is the
119
+ // model's own — recolouring it paints the frames.
120
+ paintable: ["outline"],
121
+ },
122
+ {
123
+ id: "hoodie",
124
+ slot: "top",
125
+ name: "Hoodie",
126
+ regions: ["torso", "arms"],
127
+ // Long sleeves, so both arm segments go; the cuff stops at the wrist and
128
+ // the collar sits below the jaw, which is why the hands and `body_neck`
129
+ // stay. `body_pelvis` stays too — where a hoodie hem falls on the model is
130
+ // unknown until the cosmetic is authored, and hiding a region a cosmetic only
131
+ // half covers opens a hole, which reads far worse than the clipping it
132
+ // would have prevented.
133
+ hides: [
134
+ "body_chest",
135
+ "body_arm_upper_l",
136
+ "body_arm_upper_r",
137
+ "body_arm_lower_l",
138
+ "body_arm_lower_r",
139
+ ],
140
+ // The model's torso cosmetic is `cosmetic_tshirt`, whose one material is
141
+ // `tshirt`. That material is carried by no other mesh, which is why the
142
+ // body palette no longer names it: a `tshirt_color` row could only ever
143
+ // reach this item, and this row already owns it (AUTH-226).
144
+ paintable: ["tshirt"],
145
+ },
146
+ {
147
+ id: "cargo_shorts",
148
+ slot: "bottom",
149
+ name: "Cargo shorts",
150
+ regions: ["legs"],
151
+ // Exactly what the shipped `cosmetic_shorts` hides. The thigh is its own
152
+ // region below the shorts hem, so a longer cargo cut covers only part of
153
+ // it and claiming the whole thing would amputate the leg.
154
+ hides: ["body_pelvis"],
155
+ paintable: ["shorts"],
156
+ },
157
+ {
158
+ id: "boots",
159
+ slot: "feet",
160
+ name: "Boots",
161
+ // `feet` rather than `legs`, even though the shaft sheathes the calf: the
162
+ // shorts occupy `legs`, and a boot that fought them over it would be
163
+ // unwearable with the only bottom there is.
164
+ regions: ["feet"],
165
+ // The shipped `cosmetic_boots`. NOT `body_foot_*` — the model's toes stand
166
+ // proud of the boot, so hiding the feet cuts a hole in the avatar rather
167
+ // than preventing a clip (`cosmetic_coverage_test.gd` scores that pair as
168
+ // one of its two historical faults).
169
+ hides: ["body_leg_lower_l", "body_leg_lower_r"],
170
+ // `LUNA_boots.glb` names its one material `Shoes`, capital and all.
171
+ paintable: ["Shoes"],
172
+ },
173
+ ];
174
+ /** Every item the shipped model can wear. */
175
+ export const CATALOG = catalogOf(COSMETICS);
176
+ /**
177
+ * What a player is wearing before they have dressed themselves — the outfit the
178
+ * model ships in, named in catalog terms.
179
+ *
180
+ * It is a DEFAULT of the appearance surface, resolved server-side exactly the
181
+ * way `randomDefaults` resolves a palette colour, and that is the whole point:
182
+ * a stored loadout is then the entire truth about what an avatar wears, and no
183
+ * renderer has to guess whether an empty slot means "new player" or "wants
184
+ * nothing there". AUTH-223 is what guessing cost — the client synthesised the
185
+ * starting outfit whenever the loadout named nothing at all, so equipping a
186
+ * single item anywhere silently stripped the other two.
187
+ *
188
+ * Every slot is listed, `""` for the ones a player starts bare in, so
189
+ * `satisfies` makes a new slot a typecheck error here until someone has said
190
+ * what a new player wears in it. A slot {@link SLOTS} marks required must name
191
+ * an item: it is the value an unnamed slot falls back to, so a mandatory slot
192
+ * starting at `""` would be a refusal every new player is born holding.
193
+ */
194
+ export const STARTING_OUTFIT = {
195
+ hair: "hair_original",
196
+ hat: "",
197
+ face: "",
198
+ top: "hoodie",
199
+ bottom: "cargo_shorts",
200
+ feet: "",
201
+ };
202
+ export function cosmeticById(id, catalog = CATALOG) {
203
+ return catalog.get(id);
204
+ }
205
+ export function cosmeticsForSlot(slot, catalog = CATALOG) {
206
+ return [...catalog.values()].filter((item) => item.slot === slot);
207
+ }
208
+ /**
209
+ * Every region an item occupying `regions` cannot share a body with: those
210
+ * regions themselves, plus everything each of them overlaps.
211
+ *
212
+ * Closing the set here is what lets a client grey out an impossible pick
213
+ * without a copy of {@link REGION_OVERLAPS}. The schema projects this per
214
+ * catalog item, so a collision becomes a set intersection: nothing to
215
+ * symmetrise, and no room to assume overlap is transitive (it is not). A
216
+ * mirrored vocabulary is what AUTH-210 cost.
217
+ */
218
+ export function conflictingRegions(regions) {
219
+ const closed = new Set(regions);
220
+ for (const region of regions) {
221
+ for (const overlap of OVERLAPS.get(region) ?? [])
222
+ closed.add(overlap);
223
+ }
224
+ return [...closed];
225
+ }
226
+ /**
227
+ * The first region two items both occupy, or null. A region trivially occupies
228
+ * itself, so "sharing a region" and "occupying overlapping regions" are the
229
+ * same check rather than two rules that can drift apart.
230
+ *
231
+ * Resolved through {@link conflictingRegions}, so the rule the save path
232
+ * enforces and the projection a client greys out from are ONE function rather
233
+ * than two that happen to agree today.
234
+ */
235
+ export function sharedRegion(a, b) {
236
+ const blocked = new Set(conflictingRegions(b));
237
+ return a.find((region) => blocked.has(region)) ?? null;
238
+ }
239
+ /** The catalog items a blob names, in slot order, skipping empty and unknown ids. */
240
+ function equippedItems(settings, catalog) {
241
+ const worn = [];
242
+ for (const slot of COSMETIC_SLOTS) {
243
+ const id = settings[slot];
244
+ if (!isString(id) || id === "")
245
+ continue;
246
+ const item = cosmeticById(id, catalog);
247
+ // An id filed under the wrong slot is as unusable as an unknown one: the
248
+ // catalog, not the blob, decides where an item is worn.
249
+ if (!item || item.slot !== slot)
250
+ continue;
251
+ worn.push({ slot, item });
252
+ }
253
+ return worn;
254
+ }
255
+ /** The first pair of equipped items whose regions collide, or null. THE rule. */
256
+ export function equipConflict(settings, catalog = CATALOG) {
257
+ const accepted = [];
258
+ for (const entry of equippedItems(settings, catalog)) {
259
+ for (const prior of accepted) {
260
+ const region = sharedRegion(prior.item.regions, entry.item.regions);
261
+ if (region) {
262
+ return {
263
+ region,
264
+ items: [prior.item.id, entry.item.id],
265
+ slots: [prior.slot, entry.slot],
266
+ };
267
+ }
268
+ }
269
+ accepted.push(entry);
270
+ }
271
+ return null;
272
+ }
273
+ /**
274
+ * The first mandatory slot the loadout explicitly empties, or null.
275
+ *
276
+ * Explicitly is the whole subtlety: `""` is the unequip verb, while a slot the
277
+ * blob never names falls back to its default — the starting outfit, which fills
278
+ * every mandatory slot. So this refuses the request that undresses an avatar
279
+ * and never a partial patch that simply did not mention the slot.
280
+ *
281
+ * A slot the catalog sells nothing for is exempt for the same reason it is not
282
+ * a setting: there is nothing to put in it, so demanding one would lock every
283
+ * save out until content lands.
284
+ */
285
+ export function bareSlot(settings, catalog = CATALOG) {
286
+ for (const slot of COSMETIC_SLOTS) {
287
+ if (!slotRequired(slot))
288
+ continue;
289
+ if (cosmeticsForSlot(slot, catalog).length === 0)
290
+ continue;
291
+ if (settings[slot] === "")
292
+ return slot;
293
+ }
294
+ return null;
295
+ }
296
+ /**
297
+ * The appearance surface's cross-field rule as the registry consumes it. Wired
298
+ * onto the surface rather than called from a route, so every write path gets
299
+ * it by construction.
300
+ *
301
+ * Two rules, one hook: a pair of items that cannot share a body, and a slot an
302
+ * avatar may not go without. Both are relations over the whole loadout rather
303
+ * than properties of one key, which is why neither can live in `sanitize`.
304
+ */
305
+ export function appearanceConflict(settings, catalog = CATALOG) {
306
+ const conflict = equipConflict(settings, catalog);
307
+ if (conflict) {
308
+ const [first, second] = conflict.items.map((id) => cosmeticById(id, catalog)?.name ?? id);
309
+ return {
310
+ code: "equip_region_conflict",
311
+ message: `${first} and ${second} both occupy the ${conflict.region} region`,
312
+ keys: [...conflict.slots],
313
+ };
314
+ }
315
+ const bare = bareSlot(settings, catalog);
316
+ if (bare) {
317
+ return {
318
+ code: "slot_required",
319
+ message: `${slotLabel(bare)} cannot be left empty`,
320
+ keys: [bare],
321
+ };
322
+ }
323
+ return null;
324
+ }
325
+ /**
326
+ * The equipped items that actually render: catalog-resolved, in slot order,
327
+ * with any item colliding with one already accepted dropped.
328
+ *
329
+ * Dropping here is a self-heal, not a second copy of the rule — the save path
330
+ * refuses conflicts, so a stored blob can only become conflicting when a
331
+ * catalog edit changes an item's regions underneath it. Both paths call
332
+ * {@link sharedRegion}, so there is still exactly one definition of collision.
333
+ *
334
+ * Everything downstream of a loadout resolves it through here, which is what
335
+ * keeps the colour pickers a player is offered and the equipped set a game
336
+ * server receives describing the same avatar.
337
+ */
338
+ function wornItems(settings, catalog) {
339
+ const worn = [];
340
+ for (const entry of equippedItems(settings, catalog)) {
341
+ const collides = worn.some((prior) => sharedRegion(prior.item.regions, entry.item.regions) !== null);
342
+ if (!collides)
343
+ worn.push(entry);
344
+ }
345
+ return worn;
346
+ }
347
+ /**
348
+ * The `appearance` key holding one item's colour for one of its materials.
349
+ *
350
+ * Segmented rather than opaque so it can GROW without a second key space:
351
+ * GAME-178 paints a masked region of an item instead of its whole surface,
352
+ * which is one more segment here. The separator is legal in a JSON key and
353
+ * illegal in an id and a material name, which `cosmetics.test.ts` enforces —
354
+ * without that a key would be ambiguous the day someone names an item
355
+ * `round.glasses`.
356
+ */
357
+ export function paintKey(itemId, material) {
358
+ return [PAINT_KEY_PREFIX, itemId, material].join(PAINT_KEY_SEPARATOR);
359
+ }
360
+ /**
361
+ * Every recolourable surface the equipped set exposes, in slot order: one
362
+ * entry per paintable material on each worn item.
363
+ *
364
+ * This is what makes the appearance surface dynamic — the colour pickers a
365
+ * player sees are a function of what they are wearing, so a colour cannot
366
+ * outlive the item it painted.
367
+ */
368
+ export function paintSlots(settings, catalog = CATALOG) {
369
+ return wornItems(settings, catalog).flatMap(({ slot, item }) => item.paintable.map((material) => ({
370
+ key: paintKey(item.id, material),
371
+ slot,
372
+ item,
373
+ material,
374
+ })));
375
+ }
376
+ /**
377
+ * The colours a blob holds for one item's paintable materials, skipping any it
378
+ * does not name. Absent rather than blank: no colour is an instruction to
379
+ * leave the item as it was authored, which is not the same as painting it
380
+ * nothing.
381
+ */
382
+ function paintFor(settings, item) {
383
+ const paint = [];
384
+ for (const material of item.paintable) {
385
+ const color = settings[paintKey(item.id, material)];
386
+ if (isString(color) && color !== "")
387
+ paint.push({ material, color });
388
+ }
389
+ return paint;
390
+ }
391
+ /**
392
+ * The equipped set as `/api/session/verify` hands it to a game server: the
393
+ * items that render, each carrying the regions it occupies, the body it
394
+ * suppresses, and the paint to apply to it.
395
+ */
396
+ export function equippedFor(settings, catalog = CATALOG) {
397
+ const equipped = {};
398
+ for (const { slot, item } of wornItems(settings, catalog)) {
399
+ equipped[slot] = {
400
+ id: item.id,
401
+ name: item.name,
402
+ regions: item.regions,
403
+ hides: item.hides,
404
+ paint: paintFor(settings, item),
405
+ };
406
+ }
407
+ return equipped;
408
+ }