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