@flow-industries/id 0.24.0 → 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.
- package/README.md +76 -0
- package/dist/sdk/browser-contract.d.ts +1 -0
- package/dist/sdk/browser-contract.js +1 -0
- package/dist/sdk/browser-session-route.js +3 -3
- package/dist/sdk/client/atproto.d.ts +5 -0
- package/dist/sdk/client/atproto.js +49 -0
- package/dist/sdk/client/create-flow.js +2 -1
- package/dist/sdk/db/handle.d.ts +11 -0
- package/dist/sdk/db/handle.js +0 -0
- package/dist/sdk/db/schema.d.ts +4626 -0
- package/dist/sdk/db/schema.js +782 -0
- package/dist/sdk/oauth/encryption.d.ts +4 -0
- package/dist/sdk/oauth/encryption.js +29 -0
- package/dist/sdk/oauth/stores.d.ts +16 -0
- package/dist/sdk/oauth/stores.js +159 -0
- package/dist/sdk/settings/appearance.d.ts +3 -0
- package/dist/sdk/settings/appearance.js +167 -0
- package/dist/sdk/settings/cosmetics.d.ts +149 -0
- package/dist/sdk/settings/cosmetics.js +408 -0
- package/dist/sdk/settings/hair-items.json +312 -0
- package/dist/sdk/settings/registry.d.ts +11 -0
- package/dist/sdk/settings/registry.js +136 -0
- package/dist/sdk/settings/surfaces.d.ts +4 -0
- package/dist/sdk/settings/surfaces.js +37 -0
- package/dist/sdk/types/account-data.d.ts +11 -0
- package/dist/sdk/types/atproto-storage.d.ts +9 -0
- package/dist/sdk/types/atproto-storage.js +0 -0
- package/dist/sdk/types/atproto.d.ts +25 -0
- package/dist/sdk/types/atproto.js +0 -0
- package/dist/sdk/types/auth.d.ts +1 -0
- package/dist/sdk/types/index.d.ts +1 -0
- package/package.json +6 -2
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
export function createCredentialCipher(encodedKey) {
|
|
3
|
+
const key = Buffer.from(encodedKey, "base64");
|
|
4
|
+
if (key.length !== 32)
|
|
5
|
+
throw new Error("OAuth storage key must encode 32 bytes");
|
|
6
|
+
return {
|
|
7
|
+
seal(value, context) {
|
|
8
|
+
const nonce = randomBytes(12);
|
|
9
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
10
|
+
cipher.setAAD(Buffer.from(context));
|
|
11
|
+
const body = Buffer.concat([
|
|
12
|
+
cipher.update(JSON.stringify(value), "utf8"),
|
|
13
|
+
cipher.final(),
|
|
14
|
+
]);
|
|
15
|
+
return Buffer.concat([nonce, cipher.getAuthTag(), body]).toString("base64");
|
|
16
|
+
},
|
|
17
|
+
open(value, context) {
|
|
18
|
+
const data = Buffer.from(value, "base64");
|
|
19
|
+
const cipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, 12));
|
|
20
|
+
cipher.setAAD(Buffer.from(context));
|
|
21
|
+
cipher.setAuthTag(data.subarray(12, 28));
|
|
22
|
+
// SAFETY: authenticated ciphertext is written only by seal with the same domain context.
|
|
23
|
+
return JSON.parse(Buffer.concat([
|
|
24
|
+
cipher.update(data.subarray(28)),
|
|
25
|
+
cipher.final(),
|
|
26
|
+
]).toString("utf8"));
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AppDb } from "../db/handle";
|
|
2
|
+
export interface OAuthCredentialStore<T> {
|
|
3
|
+
set(id: string, value: T): Promise<void>;
|
|
4
|
+
get(id: string): Promise<T | undefined>;
|
|
5
|
+
del(id: string): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare function withOAuthTransaction<T>(database: AppDb, fn: () => Promise<T>, sessionGuard?: (providerId: string, accountId: string) => Promise<void>): Promise<T>;
|
|
8
|
+
export declare function withOAuthSessionLock<T>(database: AppDb, providerId: string, accountId: string, fn: () => Promise<T>): Promise<T>;
|
|
9
|
+
export declare function createOAuthStores<State, Session>(database: AppDb, storageKey: string, providerId: string): {
|
|
10
|
+
stateStore: OAuthCredentialStore<State>;
|
|
11
|
+
sessionStore: OAuthCredentialStore<Session>;
|
|
12
|
+
requestLock: <T>(name: string, fn: () => T | PromiseLike<T>) => Promise<T>;
|
|
13
|
+
};
|
|
14
|
+
export declare function cleanupOAuthCredentials(database: AppDb): Promise<void>;
|
|
15
|
+
export declare function peekOAuthState<State>(database: AppDb, storageKey: string, providerId: string, id: string): Promise<State | undefined>;
|
|
16
|
+
export declare function lockOAuthSession(database: AppDb, providerId: string, accountId: string): Promise<void>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { and, eq, lt, notExists, sql } from "drizzle-orm";
|
|
3
|
+
import { account, oauthSession, oauthState } from "../db/schema";
|
|
4
|
+
import { createCredentialCipher } from "./encryption";
|
|
5
|
+
const transactionContext = new AsyncLocalStorage();
|
|
6
|
+
const sessionGuardContext = new AsyncLocalStorage();
|
|
7
|
+
export function withOAuthTransaction(database, fn, sessionGuard) {
|
|
8
|
+
const guard = sessionGuard ?? sessionGuardContext.getStore();
|
|
9
|
+
return transactionContext.run(database, () => guard ? sessionGuardContext.run(guard, fn) : fn());
|
|
10
|
+
}
|
|
11
|
+
export async function withOAuthSessionLock(database, providerId, accountId, fn) {
|
|
12
|
+
const current = transactionContext.getStore();
|
|
13
|
+
if (current) {
|
|
14
|
+
await lockOAuthSession(current, providerId, accountId);
|
|
15
|
+
await sessionGuardContext.getStore()?.(providerId, accountId);
|
|
16
|
+
return fn();
|
|
17
|
+
}
|
|
18
|
+
const outcome = await database.transaction(async (tx) => {
|
|
19
|
+
await lockOAuthSession(tx, providerId, accountId);
|
|
20
|
+
try {
|
|
21
|
+
return { ok: true, value: await withOAuthTransaction(tx, fn) };
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return { ok: false, error };
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
if (!outcome.ok)
|
|
28
|
+
throw outcome.error;
|
|
29
|
+
return outcome.value;
|
|
30
|
+
}
|
|
31
|
+
export function createOAuthStores(database, storageKey, providerId) {
|
|
32
|
+
const cipher = createCredentialCipher(storageKey);
|
|
33
|
+
const currentDatabase = () => transactionContext.getStore() ?? database;
|
|
34
|
+
const stateStore = {
|
|
35
|
+
async set(id, value) {
|
|
36
|
+
const data = cipher.seal(value, JSON.stringify(["oauth-state", providerId, id]));
|
|
37
|
+
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
|
38
|
+
await currentDatabase()
|
|
39
|
+
.insert(oauthState)
|
|
40
|
+
.values({ providerId, id, data, expiresAt })
|
|
41
|
+
.onConflictDoUpdate({
|
|
42
|
+
target: [oauthState.providerId, oauthState.id],
|
|
43
|
+
set: { data, expiresAt },
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
async get(id) {
|
|
47
|
+
// Consuming before token exchange makes callback replay fail across replicas.
|
|
48
|
+
const [row] = await currentDatabase()
|
|
49
|
+
.delete(oauthState)
|
|
50
|
+
.where(and(eq(oauthState.providerId, providerId), eq(oauthState.id, id)))
|
|
51
|
+
.returning();
|
|
52
|
+
if (!row || row.expiresAt.getTime() <= Date.now())
|
|
53
|
+
return undefined;
|
|
54
|
+
return cipher.open(row.data, JSON.stringify(["oauth-state", providerId, id]));
|
|
55
|
+
},
|
|
56
|
+
async del(id) {
|
|
57
|
+
await currentDatabase()
|
|
58
|
+
.delete(oauthState)
|
|
59
|
+
.where(and(eq(oauthState.providerId, providerId), eq(oauthState.id, id)));
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
const sessionStore = {
|
|
63
|
+
async set(accountId, value) {
|
|
64
|
+
const data = cipher.seal(value, JSON.stringify(["oauth-session", providerId, accountId]));
|
|
65
|
+
const updatedAt = new Date();
|
|
66
|
+
await withOAuthSessionLock(database, providerId, accountId, async () => currentDatabase()
|
|
67
|
+
.insert(oauthSession)
|
|
68
|
+
.values({ providerId, accountId, data, updatedAt, formatVersion: 2 })
|
|
69
|
+
.onConflictDoUpdate({
|
|
70
|
+
target: [oauthSession.providerId, oauthSession.accountId],
|
|
71
|
+
set: { data, updatedAt, formatVersion: 2 },
|
|
72
|
+
}));
|
|
73
|
+
},
|
|
74
|
+
async get(accountId) {
|
|
75
|
+
const [row] = await currentDatabase()
|
|
76
|
+
.select()
|
|
77
|
+
.from(oauthSession)
|
|
78
|
+
.where(and(eq(oauthSession.providerId, providerId), eq(oauthSession.accountId, accountId)));
|
|
79
|
+
return row
|
|
80
|
+
? cipher.open(row.data, sessionContext(providerId, accountId, row.formatVersion))
|
|
81
|
+
: undefined;
|
|
82
|
+
},
|
|
83
|
+
async del(accountId) {
|
|
84
|
+
await withOAuthSessionLock(database, providerId, accountId, async () => currentDatabase()
|
|
85
|
+
.delete(oauthSession)
|
|
86
|
+
.where(and(eq(oauthSession.providerId, providerId), eq(oauthSession.accountId, accountId))));
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const requestLock = async (name, fn) => {
|
|
90
|
+
const current = transactionContext.getStore();
|
|
91
|
+
if (current) {
|
|
92
|
+
await current.execute(sql `select pg_advisory_xact_lock(hashtext(${lockName(providerId, name)}))`);
|
|
93
|
+
return await fn();
|
|
94
|
+
}
|
|
95
|
+
const outcome = await database.transaction(async (tx) => {
|
|
96
|
+
await tx.execute(sql `select pg_advisory_xact_lock(hashtext(${lockName(providerId, name)}))`);
|
|
97
|
+
try {
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
value: await transactionContext.run(tx, fn),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
// SDK invalidation must commit even when token refresh fails.
|
|
105
|
+
return { ok: false, error };
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
if (!outcome.ok)
|
|
109
|
+
throw outcome.error;
|
|
110
|
+
return outcome.value;
|
|
111
|
+
};
|
|
112
|
+
return { stateStore, sessionStore, requestLock };
|
|
113
|
+
}
|
|
114
|
+
export async function cleanupOAuthCredentials(database) {
|
|
115
|
+
await database.execute(sql `delete from oauth_state where (provider_id, id) in (select provider_id, id from oauth_state where expires_at < (now() at time zone 'UTC') limit 100)`);
|
|
116
|
+
const cutoff = new Date(Date.now() - 60 * 60_000);
|
|
117
|
+
const candidates = await database
|
|
118
|
+
.select({
|
|
119
|
+
providerId: oauthSession.providerId,
|
|
120
|
+
accountId: oauthSession.accountId,
|
|
121
|
+
})
|
|
122
|
+
.from(oauthSession)
|
|
123
|
+
.where(and(lt(oauthSession.updatedAt, cutoff), notExists(database
|
|
124
|
+
.select()
|
|
125
|
+
.from(account)
|
|
126
|
+
.where(and(eq(account.providerId, oauthSession.providerId), eq(account.accountId, oauthSession.accountId))))))
|
|
127
|
+
.limit(100);
|
|
128
|
+
for (const { providerId, accountId } of candidates) {
|
|
129
|
+
await database.transaction(async (tx) => {
|
|
130
|
+
await lockOAuthSession(tx, providerId, accountId);
|
|
131
|
+
await tx.delete(oauthSession).where(and(eq(oauthSession.providerId, providerId), eq(oauthSession.accountId, accountId), lt(oauthSession.updatedAt, cutoff), notExists(tx
|
|
132
|
+
.select()
|
|
133
|
+
.from(account)
|
|
134
|
+
.where(and(eq(account.providerId, providerId), eq(account.accountId, accountId))))));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export async function peekOAuthState(database, storageKey, providerId, id) {
|
|
139
|
+
const [row] = await database
|
|
140
|
+
.select()
|
|
141
|
+
.from(oauthState)
|
|
142
|
+
.where(and(eq(oauthState.providerId, providerId), eq(oauthState.id, id)));
|
|
143
|
+
if (!row || row.expiresAt.getTime() <= Date.now())
|
|
144
|
+
return undefined;
|
|
145
|
+
return createCredentialCipher(storageKey).open(row.data, JSON.stringify(["oauth-state", providerId, id]));
|
|
146
|
+
}
|
|
147
|
+
function sessionContext(providerId, accountId, formatVersion) {
|
|
148
|
+
if (formatVersion === 2)
|
|
149
|
+
return JSON.stringify(["oauth-session", providerId, accountId]);
|
|
150
|
+
if (formatVersion === 1 && providerId === "atproto")
|
|
151
|
+
return `session:${accountId}`;
|
|
152
|
+
throw new Error("Unsupported OAuth credential format");
|
|
153
|
+
}
|
|
154
|
+
function lockName(providerId, name) {
|
|
155
|
+
return JSON.stringify(["oauth-lock", providerId, name]);
|
|
156
|
+
}
|
|
157
|
+
export async function lockOAuthSession(database, providerId, accountId) {
|
|
158
|
+
await database.execute(sql `select pg_advisory_xact_lock(hashtext(${lockName(providerId, accountId)}))`);
|
|
159
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { isString } from "../json";
|
|
2
|
+
import { appearanceConflict, CATALOG, COSMETIC_SLOTS, conflictingRegions, cosmeticsForSlot, paintKey, paintSlots, STARTING_OUTFIT, slotLabel, slotRequired, } from "./cosmetics";
|
|
3
|
+
const HAIR_COLORS = [
|
|
4
|
+
{ value: "#26201d", label: "Black" },
|
|
5
|
+
{ value: "#6b4226", label: "Brown" },
|
|
6
|
+
{ value: "#8b5a2b", label: "Chestnut" },
|
|
7
|
+
{ value: "#b5532a", label: "Copper" },
|
|
8
|
+
{ value: "#d9b380", label: "Blonde" },
|
|
9
|
+
{ value: "#9b9b9b", label: "Grey" },
|
|
10
|
+
{ value: "#ececec", label: "White" },
|
|
11
|
+
];
|
|
12
|
+
const SKIN_COLORS = [
|
|
13
|
+
{ value: "#f5d0b5", label: "Porcelain" },
|
|
14
|
+
{ value: "#eab98f", label: "Fair" },
|
|
15
|
+
{ value: "#d9a066", label: "Tan" },
|
|
16
|
+
{ value: "#b97a4e", label: "Bronze" },
|
|
17
|
+
{ value: "#96613a", label: "Amber" },
|
|
18
|
+
{ value: "#7a4b2a", label: "Sienna" },
|
|
19
|
+
{ value: "#5c3823", label: "Umber" },
|
|
20
|
+
{ value: "#3d2419", label: "Ebony" },
|
|
21
|
+
];
|
|
22
|
+
function cosmeticSlotSettings(catalog) {
|
|
23
|
+
return COSMETIC_SLOTS.filter((slot) => cosmeticsForSlot(slot, catalog).length > 0).map((slot) => ({
|
|
24
|
+
key: slot,
|
|
25
|
+
group: "cosmetics",
|
|
26
|
+
label: slotLabel(slot),
|
|
27
|
+
type: "cosmetic",
|
|
28
|
+
default: STARTING_OUTFIT[slot],
|
|
29
|
+
slot,
|
|
30
|
+
required: slotRequired(slot),
|
|
31
|
+
items: cosmeticsForSlot(slot, catalog).map((item) => cosmeticOffer(item)),
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
const PAINT_COLORS = [
|
|
35
|
+
{ value: "#f2f2f2", label: "White" },
|
|
36
|
+
{ value: "#9b9b9b", label: "Grey" },
|
|
37
|
+
{ value: "#2a2a2e", label: "Charcoal" },
|
|
38
|
+
{ value: "#26262b", label: "Black" },
|
|
39
|
+
{ value: "#d63c3c", label: "Red" },
|
|
40
|
+
{ value: "#e8863a", label: "Orange" },
|
|
41
|
+
{ value: "#e6c545", label: "Yellow" },
|
|
42
|
+
{ value: "#47a35e", label: "Green" },
|
|
43
|
+
{ value: "#4c6b45", label: "Olive" },
|
|
44
|
+
{ value: "#3d6ef0", label: "Blue" },
|
|
45
|
+
{ value: "#3a5bbf", label: "Navy" },
|
|
46
|
+
{ value: "#8a5bd6", label: "Purple" },
|
|
47
|
+
{ value: "#eab98f", label: "Tan" },
|
|
48
|
+
{ value: "#6b4226", label: "Brown" },
|
|
49
|
+
{ value: "#4a3527", label: "Leather" },
|
|
50
|
+
];
|
|
51
|
+
const RETIRED_GARMENT_COLORS = [
|
|
52
|
+
{ key: "tshirt_color", paint: paintKey("hoodie", "tshirt") },
|
|
53
|
+
{ key: "shorts_color", paint: paintKey("cargo_shorts", "shorts") },
|
|
54
|
+
];
|
|
55
|
+
function rgb(hex) {
|
|
56
|
+
if (!/^#[0-9a-f]{6}$/i.test(hex))
|
|
57
|
+
return null;
|
|
58
|
+
return [
|
|
59
|
+
Number.parseInt(hex.slice(1, 3), 16),
|
|
60
|
+
Number.parseInt(hex.slice(3, 5), 16),
|
|
61
|
+
Number.parseInt(hex.slice(5, 7), 16),
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
function nearestSwatch(color, options) {
|
|
65
|
+
const target = rgb(color);
|
|
66
|
+
if (!target)
|
|
67
|
+
return null;
|
|
68
|
+
const [red, green, blue] = target;
|
|
69
|
+
let best = null;
|
|
70
|
+
for (const option of options) {
|
|
71
|
+
const candidate = rgb(option.value);
|
|
72
|
+
if (!candidate)
|
|
73
|
+
continue;
|
|
74
|
+
const distance = (candidate[0] - red) ** 2 +
|
|
75
|
+
(candidate[1] - green) ** 2 +
|
|
76
|
+
(candidate[2] - blue) ** 2;
|
|
77
|
+
if (!best || distance < best.distance) {
|
|
78
|
+
best = { value: option.value, distance };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return best?.value ?? null;
|
|
82
|
+
}
|
|
83
|
+
function retireGarmentPalette(stored) {
|
|
84
|
+
const next = { ...stored };
|
|
85
|
+
for (const { key, paint } of RETIRED_GARMENT_COLORS) {
|
|
86
|
+
const color = next[key];
|
|
87
|
+
delete next[key];
|
|
88
|
+
if (paint in next || !isString(color))
|
|
89
|
+
continue;
|
|
90
|
+
const nearest = nearestSwatch(color, PAINT_COLORS);
|
|
91
|
+
if (nearest)
|
|
92
|
+
next[paint] = nearest;
|
|
93
|
+
}
|
|
94
|
+
return next;
|
|
95
|
+
}
|
|
96
|
+
function dressMandatorySlots(stored) {
|
|
97
|
+
const next = { ...stored };
|
|
98
|
+
for (const slot of COSMETIC_SLOTS) {
|
|
99
|
+
if (slotRequired(slot) && next[slot] === "")
|
|
100
|
+
delete next[slot];
|
|
101
|
+
}
|
|
102
|
+
return next;
|
|
103
|
+
}
|
|
104
|
+
function appearanceV2(stored) {
|
|
105
|
+
return dressMandatorySlots(retireGarmentPalette(stored));
|
|
106
|
+
}
|
|
107
|
+
const UNPAINTED = {
|
|
108
|
+
skin: "#eab98f",
|
|
109
|
+
brownhair: "#6b4226",
|
|
110
|
+
tshirt: "#3d6ef0",
|
|
111
|
+
shorts: "#2a2a2e",
|
|
112
|
+
Shoes: "#4a3527",
|
|
113
|
+
outline: "#26262b",
|
|
114
|
+
};
|
|
115
|
+
function paintDescriptor(item, material) {
|
|
116
|
+
return {
|
|
117
|
+
key: paintKey(item.id, material),
|
|
118
|
+
group: "paint",
|
|
119
|
+
label: item.name,
|
|
120
|
+
type: "paint",
|
|
121
|
+
default: UNPAINTED[material],
|
|
122
|
+
options: PAINT_COLORS,
|
|
123
|
+
cosmetic: item.id,
|
|
124
|
+
material,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function cosmeticOffer(item) {
|
|
128
|
+
return {
|
|
129
|
+
...item,
|
|
130
|
+
conflicts: conflictingRegions(item.regions),
|
|
131
|
+
paint: item.paintable.map((material) => paintDescriptor(item, material)),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function paintSettings(settings, catalog) {
|
|
135
|
+
return paintSlots(settings, catalog).map((paint) => paintDescriptor(paint.item, paint.material));
|
|
136
|
+
}
|
|
137
|
+
function appearanceSettings(catalog) {
|
|
138
|
+
return [
|
|
139
|
+
{
|
|
140
|
+
key: "hair_color",
|
|
141
|
+
group: "colors",
|
|
142
|
+
label: "Hair",
|
|
143
|
+
type: "enum",
|
|
144
|
+
default: "#6b4226",
|
|
145
|
+
options: HAIR_COLORS,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
key: "skin_color",
|
|
149
|
+
group: "colors",
|
|
150
|
+
label: "Skin",
|
|
151
|
+
type: "enum",
|
|
152
|
+
default: "#eab98f",
|
|
153
|
+
options: SKIN_COLORS,
|
|
154
|
+
},
|
|
155
|
+
...cosmeticSlotSettings(catalog),
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
export function appearanceSurface(catalog = CATALOG) {
|
|
159
|
+
return {
|
|
160
|
+
version: 2,
|
|
161
|
+
settings: appearanceSettings(catalog),
|
|
162
|
+
migrations: [{ to: 2, up: appearanceV2 }],
|
|
163
|
+
randomDefaults: true,
|
|
164
|
+
validate: (settings) => appearanceConflict(settings, catalog),
|
|
165
|
+
derived: (settings) => paintSettings(settings, catalog),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
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 type { CosmeticItem, CosmeticMaterial, CosmeticSlot, EquipConflict, EquippedCosmetics, EquipRegion, PaintSlot, SettingsConflict } from "../types/cosmetics";
|
|
19
|
+
import type { Settings } from "./registry";
|
|
20
|
+
/** Every slot, in the order the customizer and the resolver walk them. */
|
|
21
|
+
export declare const COSMETIC_SLOTS: readonly CosmeticSlot[];
|
|
22
|
+
/** What a player reads this slot's row as. */
|
|
23
|
+
export declare function slotLabel(slot: CosmeticSlot): string;
|
|
24
|
+
/** Whether an avatar must wear something in this slot. */
|
|
25
|
+
export declare function slotRequired(slot: CosmeticSlot): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* A catalog: the items on sale, resolved by id.
|
|
28
|
+
*
|
|
29
|
+
* A parameter rather than a module constant everywhere below, because the
|
|
30
|
+
* region rules have to stay testable against items that are not products.
|
|
31
|
+
* AUTH-223 is what the alternative costs: four cosmetics with no mesh anywhere
|
|
32
|
+
* — a beanie, earmuffs, a diving helmet, a bandana — were added to the shipped
|
|
33
|
+
* catalog purely so the composite and second-region collisions had something to
|
|
34
|
+
* collide, and a customizer duly offered all four for sale. The fixtures now
|
|
35
|
+
* live in `cosmetics.test.ts` and build a catalog of their own.
|
|
36
|
+
*/
|
|
37
|
+
export type Catalog = ReadonlyMap<string, CosmeticItem>;
|
|
38
|
+
/** A catalog over `items`, refusing a duplicate id. */
|
|
39
|
+
export declare function catalogOf(items: readonly CosmeticItem[]): Catalog;
|
|
40
|
+
/**
|
|
41
|
+
* Every item a player may equip. EACH ONE MUST HAVE GEOMETRY: an id here with
|
|
42
|
+
* no mesh behind it renders as an item that vanishes when worn, and the model's
|
|
43
|
+
* side of the pairing is `cosmetic_items` in
|
|
44
|
+
* `game/games/arena/player/luna_bodygroups.tres` — a catalog id absent from
|
|
45
|
+
* there names nothing the renderer can draw.
|
|
46
|
+
*/
|
|
47
|
+
export declare const COSMETICS: readonly CosmeticItem[];
|
|
48
|
+
/** Every item the shipped model can wear. */
|
|
49
|
+
export declare const CATALOG: Catalog;
|
|
50
|
+
/**
|
|
51
|
+
* What a player is wearing before they have dressed themselves — the outfit the
|
|
52
|
+
* model ships in, named in catalog terms.
|
|
53
|
+
*
|
|
54
|
+
* It is a DEFAULT of the appearance surface, resolved server-side exactly the
|
|
55
|
+
* way `randomDefaults` resolves a palette colour, and that is the whole point:
|
|
56
|
+
* a stored loadout is then the entire truth about what an avatar wears, and no
|
|
57
|
+
* renderer has to guess whether an empty slot means "new player" or "wants
|
|
58
|
+
* nothing there". AUTH-223 is what guessing cost — the client synthesised the
|
|
59
|
+
* starting outfit whenever the loadout named nothing at all, so equipping a
|
|
60
|
+
* single item anywhere silently stripped the other two.
|
|
61
|
+
*
|
|
62
|
+
* Every slot is listed, `""` for the ones a player starts bare in, so
|
|
63
|
+
* `satisfies` makes a new slot a typecheck error here until someone has said
|
|
64
|
+
* what a new player wears in it. A slot {@link SLOTS} marks required must name
|
|
65
|
+
* an item: it is the value an unnamed slot falls back to, so a mandatory slot
|
|
66
|
+
* starting at `""` would be a refusal every new player is born holding.
|
|
67
|
+
*/
|
|
68
|
+
export declare const STARTING_OUTFIT: {
|
|
69
|
+
hair: string;
|
|
70
|
+
hat: string;
|
|
71
|
+
face: string;
|
|
72
|
+
top: string;
|
|
73
|
+
bottom: string;
|
|
74
|
+
feet: string;
|
|
75
|
+
};
|
|
76
|
+
export declare function cosmeticById(id: string, catalog?: Catalog): CosmeticItem | undefined;
|
|
77
|
+
export declare function cosmeticsForSlot(slot: CosmeticSlot, catalog?: Catalog): readonly CosmeticItem[];
|
|
78
|
+
/**
|
|
79
|
+
* Every region an item occupying `regions` cannot share a body with: those
|
|
80
|
+
* regions themselves, plus everything each of them overlaps.
|
|
81
|
+
*
|
|
82
|
+
* Closing the set here is what lets a client grey out an impossible pick
|
|
83
|
+
* without a copy of {@link REGION_OVERLAPS}. The schema projects this per
|
|
84
|
+
* catalog item, so a collision becomes a set intersection: nothing to
|
|
85
|
+
* symmetrise, and no room to assume overlap is transitive (it is not). A
|
|
86
|
+
* mirrored vocabulary is what AUTH-210 cost.
|
|
87
|
+
*/
|
|
88
|
+
export declare function conflictingRegions(regions: readonly EquipRegion[]): readonly EquipRegion[];
|
|
89
|
+
/**
|
|
90
|
+
* The first region two items both occupy, or null. A region trivially occupies
|
|
91
|
+
* itself, so "sharing a region" and "occupying overlapping regions" are the
|
|
92
|
+
* same check rather than two rules that can drift apart.
|
|
93
|
+
*
|
|
94
|
+
* Resolved through {@link conflictingRegions}, so the rule the save path
|
|
95
|
+
* enforces and the projection a client greys out from are ONE function rather
|
|
96
|
+
* than two that happen to agree today.
|
|
97
|
+
*/
|
|
98
|
+
export declare function sharedRegion(a: readonly EquipRegion[], b: readonly EquipRegion[]): EquipRegion | null;
|
|
99
|
+
/** The first pair of equipped items whose regions collide, or null. THE rule. */
|
|
100
|
+
export declare function equipConflict(settings: Settings, catalog?: Catalog): EquipConflict | null;
|
|
101
|
+
/**
|
|
102
|
+
* The first mandatory slot the loadout explicitly empties, or null.
|
|
103
|
+
*
|
|
104
|
+
* Explicitly is the whole subtlety: `""` is the unequip verb, while a slot the
|
|
105
|
+
* blob never names falls back to its default — the starting outfit, which fills
|
|
106
|
+
* every mandatory slot. So this refuses the request that undresses an avatar
|
|
107
|
+
* and never a partial patch that simply did not mention the slot.
|
|
108
|
+
*
|
|
109
|
+
* A slot the catalog sells nothing for is exempt for the same reason it is not
|
|
110
|
+
* a setting: there is nothing to put in it, so demanding one would lock every
|
|
111
|
+
* save out until content lands.
|
|
112
|
+
*/
|
|
113
|
+
export declare function bareSlot(settings: Settings, catalog?: Catalog): CosmeticSlot | null;
|
|
114
|
+
/**
|
|
115
|
+
* The appearance surface's cross-field rule as the registry consumes it. Wired
|
|
116
|
+
* onto the surface rather than called from a route, so every write path gets
|
|
117
|
+
* it by construction.
|
|
118
|
+
*
|
|
119
|
+
* Two rules, one hook: a pair of items that cannot share a body, and a slot an
|
|
120
|
+
* avatar may not go without. Both are relations over the whole loadout rather
|
|
121
|
+
* than properties of one key, which is why neither can live in `sanitize`.
|
|
122
|
+
*/
|
|
123
|
+
export declare function appearanceConflict(settings: Settings, catalog?: Catalog): SettingsConflict | null;
|
|
124
|
+
/**
|
|
125
|
+
* The `appearance` key holding one item's colour for one of its materials.
|
|
126
|
+
*
|
|
127
|
+
* Segmented rather than opaque so it can GROW without a second key space:
|
|
128
|
+
* GAME-178 paints a masked region of an item instead of its whole surface,
|
|
129
|
+
* which is one more segment here. The separator is legal in a JSON key and
|
|
130
|
+
* illegal in an id and a material name, which `cosmetics.test.ts` enforces —
|
|
131
|
+
* without that a key would be ambiguous the day someone names an item
|
|
132
|
+
* `round.glasses`.
|
|
133
|
+
*/
|
|
134
|
+
export declare function paintKey(itemId: string, material: CosmeticMaterial): string;
|
|
135
|
+
/**
|
|
136
|
+
* Every recolourable surface the equipped set exposes, in slot order: one
|
|
137
|
+
* entry per paintable material on each worn item.
|
|
138
|
+
*
|
|
139
|
+
* This is what makes the appearance surface dynamic — the colour pickers a
|
|
140
|
+
* player sees are a function of what they are wearing, so a colour cannot
|
|
141
|
+
* outlive the item it painted.
|
|
142
|
+
*/
|
|
143
|
+
export declare function paintSlots(settings: Settings, catalog?: Catalog): PaintSlot[];
|
|
144
|
+
/**
|
|
145
|
+
* The equipped set as `/api/session/verify` hands it to a game server: the
|
|
146
|
+
* items that render, each carrying the regions it occupies, the body it
|
|
147
|
+
* suppresses, and the paint to apply to it.
|
|
148
|
+
*/
|
|
149
|
+
export declare function equippedFor(settings: Settings, catalog?: Catalog): EquippedCosmetics;
|