diff --git a/scripts/sentry/dashboards.ts b/scripts/sentry/dashboards.ts index 84ad315e..731fcb66 100644 --- a/scripts/sentry/dashboards.ts +++ b/scripts/sentry/dashboards.ts @@ -162,6 +162,10 @@ const METRICS = { "video.upload.duration": MS, // a video edited in the composer's editor (path: convert = trim/crop/turn applied by the converter | render = frames rendered; reason: pixels | angle) "video.edit": C, + // cosmetics (action: equip | unequip; kind: the server's kind key; error: a CosmeticError name or errorKind) + "cosmetic.equip": C, + // a worn cosmetic this build could not draw (reason: missing_row | unknown_kind | unknown_entry | unimplemented_slug | payload_rejected | option_rejected) + "cosmetic.render.failed": C, } as const satisfies Record; type MetricName = keyof typeof METRICS; @@ -344,6 +348,9 @@ const PRODUCT: WidgetDraft[] = [ by("Video plays by source", M("video.play", "result:ok"), "source", { w: 3, description: "inline: silent autoplay in the chat; viewer: opened full size" }), by("Video edits by path", M("video.edit"), "path", { w: 3, description: "convert: trim/crop/turn applied by the converter, no frame rendered; render: drawn on, frames rendered" }), bars("Video edits rendered by reason", M("video.edit", "path:render"), "reason", "sum", { w: 3, description: "pixels: drawing, text, stickers, adjustments; angle: a free rotation" }), + by("Cosmetics equipped by kind", M("cosmetic.equip", "result:ok"), "kind", { w: 2, description: "Successful equips and unequips, per kind" }), + bars("Cosmetic equip refusals by error", M("cosmetic.equip", "result:failed"), "error", "sum", { w: 2, description: "CosmeticError names from the server, or a transport errorKind" }), + bars("Cosmetics not drawn by reason", M("cosmetic.render.failed"), "reason", "sum", { w: 2, description: "Worn cosmetics this build skipped; counted once per item per session" }), ]; const CALLS: WidgetDraft[] = [ diff --git a/src/cosmetics/defineCosmeticKind.ts b/src/cosmetics/defineCosmeticKind.ts new file mode 100644 index 00000000..ee21b5b1 --- /dev/null +++ b/src/cosmetics/defineCosmeticKind.ts @@ -0,0 +1,5 @@ +import type { CosmeticKindModule } from "@/cosmetics/types"; + +export function defineCosmeticKind(module: CosmeticKindModule): CosmeticKindModule { + return module; +} diff --git a/src/cosmetics/kinds/avatar-decoration.kind.ts b/src/cosmetics/kinds/avatar-decoration.kind.ts new file mode 100644 index 00000000..fd95655c --- /dev/null +++ b/src/cosmetics/kinds/avatar-decoration.kind.ts @@ -0,0 +1,13 @@ +import type { AvatarDecorationPayload } from "@/cosmetics/kinds/avatar-decoration"; +import { defineCosmeticKind } from "@/cosmetics/defineCosmeticKind"; +import { avatarDecorationOf } from "@/cosmetics/wire"; + +export default defineCosmeticKind({ + key: "avatar.decoration", + surfaces: ["avatar"], + primitive: "imageLayer", + layer: 200, + labelKey: "cosmetic_kind_avatar_decoration", + + parsePayload: avatarDecorationOf, +}); diff --git a/src/cosmetics/kinds/nickname-style.kind.ts b/src/cosmetics/kinds/nickname-style.kind.ts new file mode 100644 index 00000000..d50662d0 --- /dev/null +++ b/src/cosmetics/kinds/nickname-style.kind.ts @@ -0,0 +1,22 @@ +import { + NICKNAME_STYLE_KIND, + type NicknameStyleTuning, +} from "@/cosmetics/kinds/nickname-style"; +import { defineCosmeticKind } from "@/cosmetics/defineCosmeticKind"; + +/** Bare: there are no rows of it. A worn look is its two axes plus its wearer's own tuning. */ +export default defineCosmeticKind({ + key: NICKNAME_STYLE_KIND, + surfaces: ["profileCard", "ownProfile", "nicknameInMessages", "memberListRow"], + primitive: "textStyle", + layer: 400, + labelKey: "cosmetic_kind_nickname_style", + bare: true, + + facets: [ + { id: "font", labelKey: "cosmetic_facet_font", optionKindKey: "option.font" }, + { id: "effect", labelKey: "cosmetic_facet_effect", optionKindKey: "option.text-effect" }, + ], + + parsePayload: () => null, +}); diff --git a/src/cosmetics/kinds/nickname-style.ts b/src/cosmetics/kinds/nickname-style.ts index 1e2f0bea..2158dea3 100644 --- a/src/cosmetics/kinds/nickname-style.ts +++ b/src/cosmetics/kinds/nickname-style.ts @@ -13,6 +13,8 @@ * of it. Everything anybody sees comes from its two axes, the font and the treatment, and from * this. */ +export const NICKNAME_STYLE_KIND = "nickname.style"; + export type NicknameGradientShape = "linear" | "radial" | "conic"; export interface NicknameStyleTuning { diff --git a/src/cosmetics/kinds/option-font.kind.ts b/src/cosmetics/kinds/option-font.kind.ts new file mode 100644 index 00000000..ade14d09 --- /dev/null +++ b/src/cosmetics/kinds/option-font.kind.ts @@ -0,0 +1,13 @@ +import type { FontOptionPayload } from "@/cosmetics/kinds/option-font"; +import { defineCosmeticKind } from "@/cosmetics/defineCosmeticKind"; +import { fontOptionOf } from "@/cosmetics/wire"; + +export default defineCosmeticKind({ + key: "option.font", + surfaces: [], + primitive: "textStyle", + layer: 0, + labelKey: "cosmetic_kind_option_font", + + parsePayload: fontOptionOf, +}); diff --git a/src/cosmetics/kinds/option-text-effect.kind.ts b/src/cosmetics/kinds/option-text-effect.kind.ts new file mode 100644 index 00000000..5a3d4d93 --- /dev/null +++ b/src/cosmetics/kinds/option-text-effect.kind.ts @@ -0,0 +1,19 @@ +import { + textEffect, + type TextEffectOptionPayload, +} from "@/cosmetics/kinds/option-text-effect"; +import { defineCosmeticKind } from "@/cosmetics/defineCosmeticKind"; +import { textEffectOptionOf } from "@/cosmetics/wire"; + +/** A row is drawable only when this build ships the effect module named by its slug. */ +export default defineCosmeticKind({ + key: "option.text-effect", + surfaces: [], + primitive: "textStyle", + layer: 0, + labelKey: "cosmetic_kind_option_text_effect", + + parsePayload: textEffectOptionOf, + + implementsSlug: slug => textEffect(slug) !== undefined, +}); diff --git a/src/cosmetics/kinds/profile-frame.kind.ts b/src/cosmetics/kinds/profile-frame.kind.ts new file mode 100644 index 00000000..4cba80af --- /dev/null +++ b/src/cosmetics/kinds/profile-frame.kind.ts @@ -0,0 +1,22 @@ +import { + frameHidesEdges, + frameInsets, + frameOutsets, + type ProfileFramePayload, +} from "@/cosmetics/kinds/profile-frame"; +import { defineCosmeticKind } from "@/cosmetics/defineCosmeticKind"; +import { profileFrameOf } from "@/cosmetics/wire"; + +export default defineCosmeticKind({ + key: "profile.frame", + surfaces: ["profileCard", "ownProfile"], + primitive: "frameAssembly", + layer: 600, + labelKey: "cosmetic_kind_profile_frame", + + parsePayload: profileFrameOf, + + insetsOf: item => frameInsets(item.payload), + outsetsOf: item => frameOutsets(item.payload), + hidesEdges: item => frameHidesEdges(item.payload), +}); diff --git a/src/cosmetics/optionOn.ts b/src/cosmetics/optionOn.ts new file mode 100644 index 00000000..a1b38824 --- /dev/null +++ b/src/cosmetics/optionOn.ts @@ -0,0 +1,9 @@ +import type { + CosmeticFacet, + ResolvedCosmetic, + ResolvedOption, +} from "@/cosmetics/types"; + +export function optionOn(cosmetic: ResolvedCosmetic, facetId: CosmeticFacet["id"]): ResolvedOption | undefined { + return cosmetic.options.find(option => option.facetId === facetId); +} diff --git a/src/cosmetics/registry.ts b/src/cosmetics/registry.ts new file mode 100644 index 00000000..86c9d872 --- /dev/null +++ b/src/cosmetics/registry.ts @@ -0,0 +1,38 @@ +import { logger } from "@argon/core"; + +import type { + CosmeticKindModule, + CosmeticSurface, +} from "@/cosmetics/types"; + +const kindModules = import.meta.glob("./kinds/*.kind.ts", { + eager: true, + import: "default", +}) as Record; + +const byKey = new Map(); + +for (const [path, module] of Object.entries(kindModules)) { + if (!module?.key) { + logger.error("Cosmetic kind file exports no key", path); + continue; + } + + if (byKey.has(module.key)) { + logger.error("Two cosmetic kinds claim the same key", module.key, path); + continue; + } + + byKey.set(module.key, module); +} + +/** Every kind this build ships: the directory listing of `kinds/*.kind.ts`. */ +export const cosmeticKinds: readonly CosmeticKindModule[] = [...byKey.values()]; + +export function resolveKind(kindKey: string): CosmeticKindModule | undefined { + return byKey.get(kindKey); +} + +export function kindsForSurface(surface: CosmeticSurface): CosmeticKindModule[] { + return cosmeticKinds.filter(kind => kind.surfaces.includes(surface)); +} diff --git a/src/cosmetics/resolver.ts b/src/cosmetics/resolver.ts new file mode 100644 index 00000000..92b05034 --- /dev/null +++ b/src/cosmetics/resolver.ts @@ -0,0 +1,312 @@ +import type { + CatalogueCosmetic, + IWornCosmetic, + WornNickname, +} from "@argon/glue"; +import { logger } from "@argon/core"; + +import type { + CosmeticFacet, + CosmeticKindModule, + CosmeticSurface, + ResolvedCosmetic, + ResolvedOption, +} from "@/cosmetics/types"; +import { + assetsOf, + nicknameAxesOf, + tuningOf, +} from "@/cosmetics/wire"; +import { + asWornItem, + asWornNickname, +} from "@/cosmetics/wornEntry"; +import { + isNicknameStyleTuningEmpty, + NICKNAME_STYLE_KIND, +} from "@/cosmetics/kinds/nickname-style"; +import { resolveKind } from "@/cosmetics/registry"; + +export type RenderFailure = + | "missing_row" + | "unknown_kind" + | "unknown_entry" + | "unimplemented_slug" + | "payload_rejected" + | "option_rejected"; + +export interface ResolverContext { + /** The catalogue, keyed by `cosmeticIdKey`. */ + readonly items: ReadonlyMap; + readonly enabledKinds: ReadonlySet; + report(kindKey: string, reason: RenderFailure, subject: string): void; +} + +export interface CosmeticResolver { + item(cosmeticId: string | null | undefined): CatalogueCosmetic | null; + resolveRow(item: CatalogueCosmetic): ResolvedCosmetic | null; + resolveWorn(worn: readonly IWornCosmetic[], surface: CosmeticSurface): ResolvedCosmetic[]; + kindKeyOf(worn: IWornCosmetic): string | null; +} + +/** Guids reach the client as text from more than one service; which of them capitalises is not ours to rely on. */ +export function cosmeticIdKey(cosmeticId: string): string { + return String(cosmeticId).toLowerCase(); +} + +/** + * Turns worn entries into drawable cosmetics against one catalogue. + * + * Synchronous and memoised per row and per worn nickname: a member list asks for thousands of rows + * while it renders. A new catalogue means a new resolver, which is what drops the memo. + */ +export function createCosmeticResolver(context: ResolverContext): CosmeticResolver { + const resolvedRows = new WeakMap(); + const resolvedNicknames = new WeakMap(); + + function item(cosmeticId: string | null | undefined): CatalogueCosmetic | null { + if (!cosmeticId) { + return null; + } + + return context.items.get(cosmeticIdKey(cosmeticId)) ?? null; + } + + function parseRow(kind: CosmeticKindModule, row: CatalogueCosmetic): unknown { + try { + return kind.parsePayload(row.payload); + } catch (error) { + logger.warn("A cosmetic payload could not be read", row.slug, error); + + return null; + } + } + + function readRow(row: CatalogueCosmetic): ResolvedCosmetic | null { + const kind = resolveKind(row.kindKey); + + if (kind === undefined) { + context.report(row.kindKey, "unknown_kind", cosmeticIdKey(row.cosmeticId)); + + return null; + } + + // A slug the server already ships and this build has no module for is not a malformed payload, + // and is told apart on the dashboard so the search starts at the client. + if (kind.implementsSlug && !kind.implementsSlug(row.slug)) { + context.report(kind.key, "unimplemented_slug", cosmeticIdKey(row.cosmeticId)); + + return null; + } + + const payload = parseRow(kind, row); + + if (payload === null) { + context.report(kind.key, "payload_rejected", cosmeticIdKey(row.cosmeticId)); + + return null; + } + + return { + itemId: row.cosmeticId, + slug: row.slug, + kind, + layer: kind.layer, + payload, + assets: assetsOf(row.assets), + options: [], + }; + } + + function resolveRow(row: CatalogueCosmetic): ResolvedCosmetic | null { + const memoised = resolvedRows.get(row); + + if (memoised !== undefined) { + return memoised; + } + + const resolved = readRow(row); + + resolvedRows.set(row, resolved); + + return resolved; + } + + function resolveOption(facet: CosmeticFacet, optionId: string | null): ResolvedOption | null { + if (optionId === null) { + return null; + } + + const row = item(optionId); + + if (row === null) { + context.report(facet.optionKindKey, "missing_row", cosmeticIdKey(optionId)); + + return null; + } + + if (row.kindKey !== facet.optionKindKey) { + context.report(facet.optionKindKey, "option_rejected", cosmeticIdKey(optionId)); + + return null; + } + + if (!context.enabledKinds.has(row.kindKey)) { + return null; + } + + const resolved = resolveRow(row); + + if (resolved === null) { + return null; + } + + return { + facetId: facet.id, + kindKey: row.kindKey, + itemId: row.cosmeticId, + slug: row.slug, + payload: resolved.payload, + assets: resolved.assets, + }; + } + + function composeNickname(worn: WornNickname): ResolvedCosmetic | null { + const kind = resolveKind(NICKNAME_STYLE_KIND); + + if (kind === undefined || !context.enabledKinds.has(kind.key)) { + return null; + } + + const tuning = tuningOf(worn); + + if (tuning === null) { + context.report(kind.key, "payload_rejected", "tuning"); + + return null; + } + + const axes = nicknameAxesOf(worn); + const options: ResolvedOption[] = []; + + for (const facet of kind.facets ?? []) { + const option = resolveOption(facet, axes[facet.id]); + + if (option !== null) { + options.push(option); + } + } + + if (options.length === 0 && isNicknameStyleTuningEmpty(tuning)) { + return null; + } + + return { + itemId: null, + slug: null, + kind, + layer: kind.layer, + payload: tuning, + assets: {}, + options, + }; + } + + function resolveNickname(worn: WornNickname): ResolvedCosmetic | null { + const memoised = resolvedNicknames.get(worn); + + if (memoised !== undefined) { + return memoised; + } + + const resolved = composeNickname(worn); + + resolvedNicknames.set(worn, resolved); + + return resolved; + } + + function resolveWornItem(itemId: string, surface: CosmeticSurface): ResolvedCosmetic | null { + const row = item(itemId); + + if (row === null) { + context.report("unknown", "missing_row", cosmeticIdKey(itemId)); + + return null; + } + + const kind = resolveKind(row.kindKey); + + if (kind === undefined) { + context.report(row.kindKey, "unknown_kind", cosmeticIdKey(itemId)); + + return null; + } + + if (!kind.surfaces.includes(surface) || !context.enabledKinds.has(kind.key)) { + return null; + } + + return resolveRow(row); + } + + function resolveEntry(worn: unknown, surface: CosmeticSurface): ResolvedCosmetic | null { + const wornItem = asWornItem(worn); + + if (wornItem !== null) { + return resolveWornItem(wornItem.itemId, surface); + } + + const nickname = asWornNickname(worn); + + if (nickname === null) { + context.report("unknown", "unknown_entry", "entry"); + + return null; + } + + const resolved = resolveNickname(nickname); + + if (resolved === null || !resolved.kind.surfaces.includes(surface)) { + return null; + } + + return resolved; + } + + function resolveWorn(worn: readonly IWornCosmetic[], surface: CosmeticSurface): ResolvedCosmetic[] { + const resolved: ResolvedCosmetic[] = []; + + for (const entry of worn) { + const cosmetic = resolveEntry(entry, surface); + + if (cosmetic !== null) { + resolved.push(cosmetic); + } + } + + return resolved.sort((left, right) => left.layer - right.layer); + } + + /** A worn item's kind comes from its catalogue row; null when the row is unknown here. */ + function kindKeyOf(worn: IWornCosmetic): string | null { + if (asWornNickname(worn) !== null) { + return NICKNAME_STYLE_KIND; + } + + const wornItem = asWornItem(worn); + + if (wornItem === null) { + return null; + } + + return item(wornItem.itemId)?.kindKey ?? null; + } + + return { + item, + resolveRow, + resolveWorn, + kindKeyOf, + }; +} diff --git a/src/cosmetics/reviveWorn.ts b/src/cosmetics/reviveWorn.ts new file mode 100644 index 00000000..bbbfc56a --- /dev/null +++ b/src/cosmetics/reviveWorn.ts @@ -0,0 +1,58 @@ +import { + WornItem, + WornNickname, + type IWornCosmetic, +} from "@argon/glue"; + +import { + asWornItem, + asWornNickname, +} from "@/cosmetics/wornEntry"; + +function reviveWornCosmetic(worn: unknown): IWornCosmetic | null { + const item = asWornItem(worn); + + if (item !== null) { + return new WornItem(item.itemId); + } + + const nickname = asWornNickname(worn); + + if (nickname === null) { + return null; + } + + return new WornNickname( + nickname.font ?? null, + nickname.effect ?? null, + Array.isArray(nickname.colors) ? [...nickname.colors] : null, + nickname.angle ?? null, + nickname.shape ?? null, + nickname.animate ?? null, + nickname.weight ?? null, + nickname.letterSpacing ?? null, + ); +} + +/** + * Worn cosmetics with their classes back, for a profile read out of IndexedDB. + * + * Null stays null ("not told"); anything this build cannot recognise is dropped. + */ +export function reviveWornCosmetics(worn: unknown): IWornCosmetic[] | null { + if (!Array.isArray(worn)) { + return null; + } + + const revived: IWornCosmetic[] = []; + + for (const entry of worn) { + const cosmetic = reviveWornCosmetic(entry); + + if (cosmetic !== null) { + revived.push(cosmetic); + } + } + + return revived; +} diff --git a/src/cosmetics/types.ts b/src/cosmetics/types.ts new file mode 100644 index 00000000..4ce92940 --- /dev/null +++ b/src/cosmetics/types.ts @@ -0,0 +1,80 @@ +import type { Component } from "vue"; +import type { ICosmeticPayload } from "@argon/glue"; + +import type { + CosmeticEdges, + CosmeticSides, +} from "@/cosmetics/edges"; +import type { CosmeticAssets } from "@/cosmetics/wire"; + +/** Where a worn cosmetic may be drawn. A kind names every surface it belongs on. */ +export type CosmeticSurface = + | "profileCard" + | "ownProfile" + | "avatar" + | "nicknameInMessages" + | "memberListRow"; + +/** The closed set of renderers. A kind picks one rather than shipping a component. */ +export type RenderPrimitive = "imageLayer" | "frameAssembly" | "textStyle"; + +/** One axis a wearer composes: its options are the catalogue rows of `optionKindKey`. */ +export interface CosmeticFacet { + readonly id: "font" | "effect"; + readonly labelKey: string; + readonly optionKindKey: string; +} + +export interface CosmeticKindModule { + /** The server's kind key, exactly as it arrives on the wire. */ + readonly key: string; + + /** Empty for a compositional kind, whose rows are only ever options on another kind's axis. */ + readonly surfaces: readonly CosmeticSurface[]; + + readonly primitive: RenderPrimitive; + + /** Compositing order within a surface, low to high. Mirrors the server's declaration. */ + readonly layer: number; + + readonly labelKey: string; + + /** A kind with no catalogue rows of its own, configured through its axes and its wearer's tuning. */ + readonly bare?: boolean; + + readonly facets?: readonly CosmeticFacet[]; + + /** Never throws: a row authored against a newer build degrades to "not drawn". */ + parsePayload(payload: ICosmeticPayload): TPayload | null; + + /** Whether this build can draw a row with this slug. Absent means every slug. */ + implementsSlug?(slug: string): boolean; + + insetsOf?(item: ResolvedCosmetic): CosmeticEdges; + outsetsOf?(item: ResolvedCosmetic): CosmeticEdges; + hidesEdges?(item: ResolvedCosmetic): CosmeticSides; + + /** How one of this kind's rows draws itself in an axis picker. */ + readonly chip?: Component; +} + +export interface ResolvedOption { + readonly facetId: CosmeticFacet["id"]; + readonly kindKey: string; + readonly itemId: string; + readonly slug: string; + readonly payload: TPayload; + readonly assets: CosmeticAssets; +} + +/** One worn cosmetic, ready to draw: kind resolved, payload parsed, files by slot, axes composed. */ +export interface ResolvedCosmetic { + /** The catalogue row, or null for a look composed by its wearer (a nickname). */ + readonly itemId: string | null; + readonly slug: string | null; + readonly kind: CosmeticKindModule; + readonly layer: number; + readonly payload: TPayload; + readonly assets: CosmeticAssets; + readonly options: readonly ResolvedOption[]; +} diff --git a/src/cosmetics/wire.ts b/src/cosmetics/wire.ts new file mode 100644 index 00000000..75a60587 --- /dev/null +++ b/src/cosmetics/wire.ts @@ -0,0 +1,318 @@ +import { + AssetSlot, + FrameAnchor as WireFrameAnchor, + FrameRepeat as WireFrameRepeat, + NicknameGradientShape as WireGradientShape, + WornItem, + WornNickname, + type CosmeticAsset, + type FrameMotion as WireFrameMotion, + type FrameProp, + type FrameRing, + type FrameSides as WireFrameSides, + type FrameSprite as WireFrameSprite, + type FrameSurround, + type ICosmeticPayload, + type IFramePart, + type PayloadAvatarDecoration, + type PayloadFont, + type PayloadProfileFrame, +} from "@argon/glue"; + +import { + parseAvatarDecorationPayload, + type AvatarDecorationPayload, +} from "@/cosmetics/kinds/avatar-decoration"; +import { + parseNicknameStyleTuning, + type NicknameGradientShape, + type NicknameStyleTuning, +} from "@/cosmetics/kinds/nickname-style"; +import { + parseFontOptionPayload, + type FontOptionPayload, +} from "@/cosmetics/kinds/option-font"; +import { + parseTextEffectOptionPayload, + type TextEffectOptionPayload, +} from "@/cosmetics/kinds/option-text-effect"; +import { + parseProfileFramePayload, + type FrameAnchor, + type FrameRepeat, + type FrameSides, + type FrameSlot, + type ProfileFramePayload, +} from "@/cosmetics/kinds/profile-frame"; +import type { CosmeticFacet } from "@/cosmetics/types"; +import { unionKeyOf } from "@/cosmetics/wornEntry"; + +export type CosmeticAssets = Readonly>>; + +/** The axes a worn nickname composes, keyed by the facet id its kind declares. */ +export type NicknameAxes = Readonly>; + +const SLOT_NAMES: ReadonlyMap = new Map([ + [AssetSlot.Primary, "primary"], + [AssetSlot.Secondary, "secondary"], + [AssetSlot.Tertiary, "tertiary"], + [AssetSlot.Quaternary, "quaternary"], +]); + +const ANCHOR_NAMES: ReadonlyMap = new Map([ + [WireFrameAnchor.TopLeft, "topLeft"], + [WireFrameAnchor.Top, "top"], + [WireFrameAnchor.TopRight, "topRight"], + [WireFrameAnchor.Left, "left"], + [WireFrameAnchor.Center, "center"], + [WireFrameAnchor.Right, "right"], + [WireFrameAnchor.BottomLeft, "bottomLeft"], + [WireFrameAnchor.Bottom, "bottom"], + [WireFrameAnchor.BottomRight, "bottomRight"], +]); + +const REPEAT_NAMES: ReadonlyMap = new Map([ + [WireFrameRepeat.Stretch, "stretch"], + [WireFrameRepeat.Repeat, "repeat"], + [WireFrameRepeat.Round, "round"], + [WireFrameRepeat.Space, "space"], +]); + +const SHAPE_NAMES: ReadonlyMap = new Map([ + [WireGradientShape.Linear, "linear"], + [WireGradientShape.Radial, "radial"], + [WireGradientShape.Conic, "conic"], +]); + +const SHAPE_VALUES: ReadonlyMap = new Map([ + ["linear", WireGradientShape.Linear], + ["radial", WireGradientShape.Radial], + ["conic", WireGradientShape.Conic], +]); + +const NO_SIDES: FrameSides = [0, 0, 0, 0]; + +export function slotName(slot: AssetSlot): FrameSlot | null { + return SLOT_NAMES.get(slot) ?? null; +} + +export function anchorName(anchor: WireFrameAnchor): FrameAnchor | null { + return ANCHOR_NAMES.get(anchor) ?? null; +} + +export function repeatName(repeat: WireFrameRepeat): FrameRepeat | null { + return REPEAT_NAMES.get(repeat) ?? null; +} + +export function shapeName(shape: WireGradientShape | null): NicknameGradientShape | null { + if (shape === null) { + return null; + } + + return SHAPE_NAMES.get(shape) ?? null; +} + +export function shapeValue(shape: NicknameGradientShape | null): WireGradientShape | null { + if (shape === null) { + return null; + } + + return SHAPE_VALUES.get(shape) ?? null; +} + +export function sidesOf(sides: WireFrameSides | null | undefined): FrameSides | null { + if (typeof sides !== "object" || sides === null) { + return null; + } + + return [sides.top, sides.right, sides.bottom, sides.left]; +} + +export function assetsOf(assets: readonly CosmeticAsset[] | null | undefined): CosmeticAssets { + const bySlot: Partial> = {}; + + for (const asset of assets ?? []) { + const slot = slotName(asset.slot); + + if (slot !== null && typeof asset.fileId === "string" && asset.fileId.length > 0) { + bySlot[slot] = asset.fileId; + } + } + + return bySlot; +} + +function motionOf(motion: WireFrameMotion | null | undefined): Record | null { + if (typeof motion !== "object" || motion === null) { + return null; + } + + return { + kind: motion.kind, + amount: motion.amount, + periodMs: motion.periodMs, + phaseMs: motion.phaseMs, + }; +} + +function spriteOf(sprite: WireFrameSprite | null | undefined): Record | null { + if (typeof sprite !== "object" || sprite === null) { + return null; + } + + return { + frames: sprite.frames, + columns: sprite.columns, + fps: sprite.fps, + still: sprite.still, + }; +} + +function commonOf(part: IFramePart): Record { + return { + over: part.over, + opacityPct: part.opacityPct, + inset: sidesOf(part.inset) ?? NO_SIDES, + motion: motionOf(part.motion), + }; +} + +function surroundOf(part: FrameSurround): Record { + return { + ...commonOf(part), + type: "surround", + slot: slotName(part.slot), + slice: sidesOf(part.slice), + width: sidesOf(part.width), + outset: sidesOf(part.outset) ?? NO_SIDES, + repeat: repeatName(part.repeat), + fill: part.fill, + }; +} + +function propOf(part: FrameProp): Record { + return { + ...commonOf(part), + type: "prop", + slot: slotName(part.slot), + anchor: anchorName(part.anchor), + w: part.w, + h: part.h, + dx: part.dx, + dy: part.dy, + sprite: spriteOf(part.sprite), + }; +} + +function ringOf(part: FrameRing): Record { + return { + ...commonOf(part), + type: "ring", + thickness: part.thickness, + colors: Array.isArray(part.colors) ? [...part.colors] : [], + angle: part.angle, + glowPct: part.glowPct, + }; +} + +/** One part as the upstream parser reads it, or null for a part type this build does not know. */ +export function framePartOf(part: IFramePart): Record | null { + switch (unionKeyOf(part)) { + case "FrameSurround": + return surroundOf(part as FrameSurround); + + case "FrameProp": + return propOf(part as FrameProp); + + case "FrameRing": + return ringOf(part as FrameRing); + + default: + return null; + } +} + +export function avatarDecorationOf(payload: ICosmeticPayload | null | undefined): AvatarDecorationPayload | null { + if (unionKeyOf(payload) !== "PayloadAvatarDecoration") { + return null; + } + + const decoration = payload as PayloadAvatarDecoration; + + return parseAvatarDecorationPayload({ + insetPct: decoration.insetPct, + beneath: decoration.beneath, + }); +} + +export function profileFrameOf(payload: ICosmeticPayload | null | undefined): ProfileFramePayload | null { + if (unionKeyOf(payload) !== "PayloadProfileFrame") { + return null; + } + + const frame = payload as PayloadProfileFrame; + + if (!Array.isArray(frame.parts)) { + return null; + } + + const parts: Array | null> = []; + + for (const part of frame.parts) { + parts.push(framePartOf(part)); + } + + return parseProfileFramePayload({ parts }); +} + +export function fontOptionOf(payload: ICosmeticPayload | null | undefined): FontOptionPayload | null { + if (unionKeyOf(payload) !== "PayloadFont") { + return null; + } + + return parseFontOptionPayload({ cssFamily: (payload as PayloadFont).cssFamily }); +} + +export function textEffectOptionOf(payload: ICosmeticPayload | null | undefined): TextEffectOptionPayload | null { + if (unionKeyOf(payload) !== "PayloadTextEffect") { + return null; + } + + return parseTextEffectOptionPayload({}); +} + +/** The wearer's own part of a nickname look, bounded, or null when the server sent more than a client may draw. */ +export function tuningOf(worn: WornNickname): NicknameStyleTuning | null { + return parseNicknameStyleTuning({ + colors: worn.colors, + angle: worn.angle, + shape: shapeName(worn.shape), + animate: worn.animate, + weight: worn.weight, + letterSpacingCentiEm: worn.letterSpacing, + }); +} + +export function nicknameAxesOf(worn: WornNickname): NicknameAxes { + return { + font: worn.font ?? null, + effect: worn.effect ?? null, + }; +} + +export function toWornItem(itemId: string): WornItem { + return new WornItem(itemId); +} + +export function toWornNickname(axes: NicknameAxes, tuning: NicknameStyleTuning): WornNickname { + return new WornNickname( + axes.font, + axes.effect, + tuning.colors === null ? null : [...tuning.colors], + tuning.angle, + shapeValue(tuning.shape), + tuning.animate, + tuning.weight, + tuning.letterSpacingCentiEm, + ); +} diff --git a/src/cosmetics/wornEntry.ts b/src/cosmetics/wornEntry.ts new file mode 100644 index 00000000..d66206db --- /dev/null +++ b/src/cosmetics/wornEntry.ts @@ -0,0 +1,28 @@ +import type { + WornItem, + WornNickname, +} from "@argon/glue"; + +// Read by key rather than through the isX() guards: a union that went through IndexedDB comes back +// as a plain object, with the key and fields intact and the prototype gone. +export function unionKeyOf(value: unknown): string | null { + if (typeof value !== "object" || value === null) { + return null; + } + + const unionKey = (value as { UnionKey?: unknown }).UnionKey; + + return typeof unionKey === "string" ? unionKey : null; +} + +export function asWornItem(worn: unknown): WornItem | null { + if (unionKeyOf(worn) !== "WornItem") { + return null; + } + + return typeof (worn as WornItem).itemId === "string" ? worn as WornItem : null; +} + +export function asWornNickname(worn: unknown): WornNickname | null { + return unionKeyOf(worn) === "WornNickname" ? worn as WornNickname : null; +} diff --git a/src/store/data/profileCacheStore.ts b/src/store/data/profileCacheStore.ts index 47826190..e8ee1d9d 100644 --- a/src/store/data/profileCacheStore.ts +++ b/src/store/data/profileCacheStore.ts @@ -1,12 +1,17 @@ +import { + reactive, + ref, + watch, +} from "vue"; import { defineStore } from "pinia"; -import { watch } from "vue"; import { logger } from "@argon/core"; import { useApi } from "@/store/system/apiStore"; +import { useMe } from "@/store/auth/meStore"; import { useBus } from "@/store/realtime/busStore"; import { useSystemStore } from "@/store/system/systemStore"; import { db, type CachedProfile, type ProfileScope } from "@/store/db/dexie"; import { onSessionReset, sessionEpoch } from "@/store/system/sessionLifecycle"; -import type { ArgonUserProfile, StatusEmoji } from "@argon/glue"; +import type { ArgonUserProfile, IWornCosmetic, StatusEmoji } from "@argon/glue"; import { UserProfileUpdated } from "@argon/glue"; import type { Guid } from "@argon-chat/ion.webcore"; @@ -46,6 +51,8 @@ export interface ProfileStatus { customStatus: string | null; customStatusIconId: string | null; customStatusEmoji: StatusEmoji | null; + /** What the member wears, drawn on the row itself. Null means the server did not say. */ + cosmetics: IWornCosmetic[] | null; } export interface ProfileRequestOptions { @@ -101,6 +108,21 @@ export const useProfileCacheStore = defineStore("profileCache", () => { */ let purge: Promise | null = null; + /** + * Bumped whenever what is held for someone changes under a caller that already read it, so a row + * holding an earlier answer knows to ask again. Answers stay plain promises; this is the signal. + */ + const userRevisions = reactive(new Map()); + const everyoneRevision = ref(0); + + function revisionOf(userId: string): number { + return everyoneRevision.value + (userRevisions.get(userId) ?? 0); + } + + function touchUser(userId: string) { + userRevisions.set(userId, (userRevisions.get(userId) ?? 0) + 1); + } + // Outside a space — the friends list, a DM, a mention in a direct chat — there is nothing to // scope the profile to, and those rows get their own cache namespace. const GLOBAL_SCOPE = "@global"; @@ -146,7 +168,8 @@ export const useProfileCacheStore = defineStore("profileCache", () => { primaryColor: null, accentColor: null, registeredAt: null, - cosmetics: null, + // Worn cosmetics are drawn on the row itself, and no per-person call exists to fetch them. + cosmetics: profile.cosmetics ?? null, customStatusEmoji: profile.customStatusEmoji, // Linked accounts are read once per card, never kept on a list row. connections: null, @@ -158,7 +181,12 @@ export const useProfileCacheStore = defineStore("profileCache", () => { * asks for it, and at least one caller appends to `badges` before rendering. */ function cloneProfile(profile: ArgonUserProfile): ArgonUserProfile { - return { ...profile, badges: [...profile.badges], archetypes: [...profile.archetypes] }; + return { + ...profile, + badges: [...profile.badges], + archetypes: [...profile.archetypes], + cosmetics: profile.cosmetics ? [...profile.cosmetics] : null, + }; } function remember(key: string, profile: ArgonUserProfile, fetchedAt: number, scope: ProfileScope) { @@ -330,6 +358,12 @@ export const useProfileCacheStore = defineStore("profileCache", () => { if (sameSession && !job.cancelled) keep(job, profile); settle(job, profile); + + // A row that re-asked on a revision bump while this was on the wire joined it and got the + // answer the bump was about. Now it is out of `jobs`, the bump is repeated so the row asks anew. + if (sameSession && !job.cancelled && !job.cacheable) { + touchUser(job.userId); + } } } catch (err) { // The call failed, so every member it was carrying failed with it. @@ -476,9 +510,36 @@ export const useProfileCacheStore = defineStore("profileCache", () => { customStatusIconId: profile.customStatusIconId, // Rows cached before the field existed have none. customStatusEmoji: profile.customStatusEmoji ?? null, + cosmetics: profile.cosmetics ?? null, }; } + /** + * A realtime profile update carries the broadcast projection, and laid over a full row it must not + * wipe what only a full lookup knows. Its one sender (UserGrain, via UserProfileEntity.Map) never + * fills archetypes, so roles are never taken from it; connections and cosmetics are null when the + * event did not say. + */ + function overBroadcast(held: ArgonUserProfile, broadcast: ArgonUserProfile): ArgonUserProfile { + return { + ...broadcast, + archetypes: held.archetypes ?? [], + connections: broadcast.connections ?? held.connections ?? null, + cosmetics: broadcast.cosmetics ?? held.cosmetics ?? null, + }; + } + + /** The account's own full profile takes the new look only; the rest of the projection is not news. */ + function wearOwnLook(userId: string, profile: ArgonUserProfile) { + const me = useMe(); + + if (!me.meProfile || me.me?.userId !== userId || !profile.cosmetics) { + return; + } + + me.meProfile = { ...me.meProfile, cosmetics: profile.cosmetics }; + } + /** * Refreshes a profile already in the cache, keeping the scope it was cached at. * @@ -498,12 +559,37 @@ export const useProfileCacheStore = defineStore("profileCache", () => { const scope = existing.scope ?? "full"; const fetchedAt = Date.now(); - const kept = scope === "full" ? profile : statusProjection(profile); + const kept = scope === "full" + ? overBroadcast(existing.profile, profile) + : { ...statusProjection(profile), cosmetics: profile.cosmetics ?? existing.profile.cosmetics ?? null }; remember(key, kept, fetchedAt, scope); await db.profileCache.put({ key, spaceId, userId, profile: kept, fetchedAt, scope }); } + /** + * Rewrites what one person wears on every row held for them, keeping the rest of each row. + * + * For the caller's own equip: the answer is the account's profile, which carries no space roles, + * so it cannot stand in for a space-scoped row wholesale. A lookup already on its way with the old + * look does not get to write it back. + */ + async function updateCosmetics(userId: string, cosmetics: IWornCosmetic[] | null) { + overtakeInFlight(job => job.userId === userId); + + for (const entry of memory.values()) { + if (entry.profile.userId === userId) { + entry.profile = { ...entry.profile, cosmetics }; + } + } + + await db.profileCache.where("userId").equals(userId).modify(row => { + row.profile = { ...row.profile, cosmetics }; + }); + + touchUser(userId); + } + /** * Stops the jobs an invalidation has overtaken from putting back what it is removing. * @@ -538,6 +624,7 @@ export const useProfileCacheStore = defineStore("profileCache", () => { overtakeInFlight(() => true); memory.clear(); await purging(() => db.profileCache.clear()); + everyoneRevision.value++; } async function invalidateUser(userId: string) { @@ -546,6 +633,7 @@ export const useProfileCacheStore = defineStore("profileCache", () => { if (entry.profile.userId === userId) memory.delete(key); } await purging(() => db.profileCache.where("userId").equals(userId).delete()); + touchUser(userId); } // Seamless account switch: drop everything in flight and everything held in memory. The cached @@ -558,10 +646,16 @@ export const useProfileCacheStore = defineStore("profileCache", () => { for (const job of [...jobs.values()]) cancel(job); queue.length = 0; memory.clear(); + // Lifted past every revision handed out so far before the per-user counts go, so a revision a + // caller remembers from the previous account never comes round again. + everyoneRevision.value += 1 + Math.max(0, ...userRevisions.values()); + userRevisions.clear(); }); // Subscribe to realtime event bus.onServerEvent("UserProfileUpdated", (e) => { + wearOwnLook(e.userId, e.profile); + void (async () => { await updateProfile(e.spaceId, e.userId, e.profile); // The space-less copy of a profile is a different thing — no archetypes — so a space-scoped @@ -571,6 +665,7 @@ export const useProfileCacheStore = defineStore("profileCache", () => { overtakeInFlight(job => job.key === globalKey); memory.delete(globalKey); await db.profileCache.delete(globalKey); + touchUser(e.userId); })(); }); @@ -585,6 +680,8 @@ export const useProfileCacheStore = defineStore("profileCache", () => { getProfile, getStatus, updateProfile, + updateCosmetics, + revisionOf, invalidateAll, invalidateUser, }; diff --git a/src/store/db/dexie.ts b/src/store/db/dexie.ts index 9b8fc637..0bc66d75 100644 --- a/src/store/db/dexie.ts +++ b/src/store/db/dexie.ts @@ -3,6 +3,8 @@ import { Guid, IonDateTime } from "@argon-chat/ion.webcore"; import Dexie, { type Table, type Transaction } from "dexie"; import { delay, logger } from "@argon/core"; +import { reviveWornCosmetics } from "@/cosmetics/reviveWorn"; + /** ArgonMessage with a numeric _msgId for IndexedDB indexing (bigint can't be an IDB key) */ export type StoredMessage = ArgonMessage & { _msgId: number }; @@ -193,6 +195,9 @@ export class PoolDatabase extends Dexie { this.version(10).stores({ appTexts: "ref", }); + // v11: profile rows written before worn cosmetics were kept hold `cosmetics: null` ("not told") + // for up to the cache's three hours. Only that table is emptied; the rest of the cache stands. + this.version(11).stores({}).upgrade((tx) => tx.table("profileCache").clear()); // Registered after the last `stores()` call on purpose: `Version.stores()` runs // `removeTablesApi` before rebuilding the table objects, so a hook attached against an earlier @@ -206,7 +211,11 @@ export class PoolDatabase extends Dexie { row.joinedAt = liveDateTime(row.joinedAt); })); this.profileCache.hook("reading", onRow((row) => { - if (row.profile) row.profile.registeredAt = liveDateTime(row.profile.registeredAt); + if (row.profile) { + row.profile.registeredAt = liveDateTime(row.profile.registeredAt); + // Worn cosmetics are union classes, flattened the same way; their isX() guards go with the prototype. + row.profile.cosmetics = reviveWornCosmetics(row.profile.cosmetics); + } })); } } diff --git a/src/store/features/cosmeticsStore.ts b/src/store/features/cosmeticsStore.ts new file mode 100644 index 00000000..0ef382d6 --- /dev/null +++ b/src/store/features/cosmeticsStore.ts @@ -0,0 +1,507 @@ +import { + computed, + shallowRef, + watch, +} from "vue"; +import { + CosmeticError, + type ArgonUserProfile, + type CatalogueCosmetic, + type CosmeticCatalogue, + type IEquipResult, + type IWornCosmetic, +} from "@argon/glue"; +import { defineStore } from "pinia"; +import { logger } from "@argon/core"; + +import { + cosmeticIdKey, + createCosmeticResolver, + type RenderFailure, +} from "@/cosmetics/resolver"; +import type { + CosmeticKindModule, + CosmeticSurface, + ResolvedCosmetic, +} from "@/cosmetics/types"; +import { + errorKind, + metrics, +} from "@/lib/telemetry/metrics"; +import { cosmeticKinds } from "@/cosmetics/registry"; +import { useMe } from "@/store/auth/meStore"; +import { useProfileCacheStore } from "@/store/data/profileCacheStore"; +import { useApi } from "@/store/system/apiStore"; +import { onSessionReset } from "@/store/system/sessionLifecycle"; + +type EquipAction = "equip" | "unequip"; + +type WornProfile = Pick | null | undefined; + +/** When each held cosmetic stops being wearable; null for one that never does. */ +type Grants = ReadonlyMap; + +/** Waits before each retry of a boot load that failed; the budget restarts on the next explicit ask. */ +const RETRY_DELAYS_MS: readonly number[] = [2_000, 8_000, 30_000]; + +function expiryOf(expiresAt: { toDate(): Date } | null | undefined): Date | null { + if (!expiresAt || typeof expiresAt.toDate !== "function") { + return null; + } + + return expiresAt.toDate(); +} + +function hasLapsed(expiresAt: Date | null): boolean { + if (expiresAt === null) { + return false; + } + + return expiresAt.getTime() <= Date.now(); +} + +// The same item can be listed twice — bought and covered by a subscription — and the longer claim wins. +function longerClaim(known: Date | null | undefined, incoming: Date | null): Date | null { + if (known === undefined) { + return incoming; + } + + if (known === null || incoming === null) { + return null; + } + + return known.getTime() >= incoming.getTime() ? known : incoming; +} + +function errorName(error: CosmeticError): string { + return CosmeticError[error]?.toLowerCase() ?? "unknown"; +} + +export const useCosmeticsStore = defineStore("cosmetics", () => { + const api = useApi(); + const me = useMe(); + + const catalogue = shallowRef(null); + const owned = shallowRef(new Map()); + + /** Bumped on session reset, so an answer for the previous account is dropped on arrival. */ + let generation = 0; + + let ownedLoaded = false; + + /** Bumped by every owned request, so a forced refresh outranks an older answer still on its way. */ + let ownedSequence = 0; + + let catalogueInflight: Promise | null = null; + let ownedInflight: Promise | null = null; + let loading: Promise | null = null; + + let retryTimer: ReturnType | null = null; + let retryAttempt = 0; + + const reportedFailures = new Set(); + + const enabledKinds = computed>(() => new Set(catalogue.value?.enabledKinds ?? [])); + + const byId = computed>(() => { + const index = new Map(); + + for (const item of catalogue.value?.items ?? []) { + index.set(cosmeticIdKey(item.cosmeticId), item); + } + + return index; + }); + + const resolver = computed(() => createCosmeticResolver({ + items: byId.value, + enabledKinds: enabledKinds.value, + report: reportFailure, + })); + + const wearableKinds = computed(() => + cosmeticKinds.filter(kind => kind.surfaces.length > 0 && enabledKinds.value.has(kind.key))); + + onSessionReset(() => { + generation++; + catalogue.value = null; + owned.value = new Map(); + ownedLoaded = false; + catalogueInflight = null; + ownedInflight = null; + loading = null; + cancelRetry(); + retryAttempt = 0; + reportedFailures.clear(); + }); + + // A subscription starting or lapsing changes what GetMyCosmetics lists. + watch(() => me.isPremium, (premium, wasPremium) => { + if (premium === wasPremium || !me.me) { + return; + } + + void loadOwned({ force: true }).then(grants => { + if (grants === null) { + void loadMissing(); + } + }); + }); + + /** Counted once per thing per session: resolution runs on every render of every row. */ + function reportFailure(kindKey: string, reason: RenderFailure, subject: string): void { + const key = `${reason}:${kindKey}:${subject}`; + + if (reportedFailures.has(key)) { + return; + } + + reportedFailures.add(key); + metrics.count("cosmetic.render.failed", { kind: kindKey, reason }); + } + + function itemById(cosmeticId: string | null | undefined): CatalogueCosmetic | null { + return resolver.value.item(cosmeticId); + } + + /** Absent from the catalogue's list means off, as the server sees it; nothing is on before it arrives. */ + function isKindEnabled(kindKey: string): boolean { + return enabledKinds.value.has(kindKey); + } + + /** A grant is checked against its expiry at the moment of asking: the list is only as fresh as its last load. */ + function mayWear(item: CatalogueCosmetic): boolean { + if (item.free) { + return true; + } + + const key = cosmeticIdKey(item.cosmeticId); + + if (!owned.value.has(key)) { + return false; + } + + return !hasLapsed(owned.value.get(key) ?? null); + } + + function loadCatalogue(): Promise { + if (catalogueInflight !== null) { + return catalogueInflight; + } + + const running = readCatalogue(generation).finally(() => { + if (catalogueInflight === running) { + catalogueInflight = null; + } + }); + + catalogueInflight = running; + + return running; + } + + async function readCatalogue(askedIn: number): Promise { + try { + const answer = await api.cosmeticsInteraction.GetCatalogue(); + + if (askedIn !== generation) { + return null; + } + + catalogue.value = answer; + + return answer; + } catch (error) { + logger.warn("Could not load the cosmetics catalogue", error); + + return null; + } + } + + /** What this account may wear. `force` asks again even with a request already on its way. */ + function loadOwned(options: { force?: boolean } = {}): Promise { + if (ownedInflight !== null && options.force !== true) { + return ownedInflight; + } + + ownedSequence++; + + const running = readOwned(generation, ownedSequence).finally(() => { + if (ownedInflight === running) { + ownedInflight = null; + } + }); + + ownedInflight = running; + + return running; + } + + async function readOwned(askedIn: number, sequence: number): Promise { + try { + const answer = await api.cosmeticsInteraction.GetMyCosmetics(); + + if (askedIn !== generation || sequence !== ownedSequence) { + return null; + } + + const grants = new Map(); + + for (const grant of answer.owned) { + const expiresAt = expiryOf(grant.expiresAt); + + if (hasLapsed(expiresAt)) { + continue; + } + + const key = cosmeticIdKey(grant.cosmeticId); + + grants.set(key, longerClaim(grants.get(key), expiresAt)); + } + + owned.value = grants; + ownedLoaded = true; + + return grants; + } catch (error) { + logger.warn("Could not load the cosmetics this account owns", error); + + // What is held may be out of date — a forced refresh asks because something changed — so the + // next ensureLoaded or retry asks again instead of trusting it. + if (askedIn === generation && sequence === ownedSequence) { + ownedLoaded = false; + } + + return null; + } + } + + function cancelRetry(): void { + if (retryTimer !== null) { + clearTimeout(retryTimer); + retryTimer = null; + } + } + + function scheduleRetry(): void { + if (retryTimer !== null || retryAttempt >= RETRY_DELAYS_MS.length) { + return; + } + + const delay = RETRY_DELAYS_MS[retryAttempt]; + + retryAttempt++; + retryTimer = setTimeout(() => { + retryTimer = null; + void loadMissing(); + }, delay); + } + + /** Asks again for whichever half has not arrived; a failure is retried with backoff. */ + function loadMissing(): Promise { + if (loading !== null) { + return loading; + } + + const askedIn = generation; + const pending: Promise[] = []; + + if (catalogue.value === null) { + pending.push(loadCatalogue()); + } + + if (!ownedLoaded) { + pending.push(loadOwned()); + } + + const running = Promise.all(pending).then(() => { + if (askedIn !== generation) { + return; + } + + if (catalogue.value !== null && ownedLoaded) { + retryAttempt = 0; + + return; + } + + scheduleRetry(); + }).finally(() => { + if (loading === running) { + loading = null; + } + }); + + loading = running; + + return running; + } + + /** + * Catalogue and owned, once per session. Every surface needs the catalogue to draw a worn item, so + * this runs right after sign-in and again after a realtime reconnect; each ask restarts the retry budget. + */ + function ensureLoaded(): Promise { + cancelRetry(); + retryAttempt = 0; + + return loadMissing(); + } + + /** A catalogue row drawn the way a worn one is, so a picker previews the real thing. */ + function resolveCatalogueItem(item: CatalogueCosmetic): ResolvedCosmetic | null { + return resolver.value.resolveRow(item); + } + + /** Rows a picker may offer for a kind: enabled, readable and drawable by this build; wearable first. */ + function optionsFor(kindKey: string): CatalogueCosmetic[] { + if (!isKindEnabled(kindKey)) { + return []; + } + + const offered: CatalogueCosmetic[] = []; + + for (const item of catalogue.value?.items ?? []) { + if (item.kindKey === kindKey && resolveCatalogueItem(item) !== null) { + offered.push(item); + } + } + + return offered.sort((left, right) => Number(mayWear(right)) - Number(mayWear(left))); + } + + /** + * What a profile wears on one surface, in compositing order. Anything this build cannot draw — an + * unknown kind, a switched-off kind, a row it never saw, a payload it cannot read — is skipped. + */ + function resolve(profile: WornProfile, surface: CosmeticSurface): ResolvedCosmetic[] { + const worn = profile?.cosmetics; + + if (!worn || worn.length === 0 || catalogue.value === null) { + return []; + } + + return resolver.value.resolveWorn(worn, surface); + } + + function wornOfKind(profile: WornProfile, kindKey: string): IWornCosmetic | null { + for (const entry of profile?.cosmetics ?? []) { + if (resolver.value.kindKeyOf(entry) === kindKey) { + return entry; + } + } + + return null; + } + + async function wearProfile(profile: ArgonUserProfile): Promise { + // The echo is the broadcast projection: archetypes empty, connections untold. Only the look is + // news, and with no own profile loaded there is nothing it may stand in for. + if (me.meProfile !== null) { + me.meProfile = { ...me.meProfile, cosmetics: profile.cosmetics }; + } + + const userId = profile.userId || me.me?.userId; + + if (!userId) { + return; + } + + try { + await useProfileCacheStore().updateCosmetics(userId, profile.cosmetics ?? null); + } catch (error) { + logger.warn("Could not write the new look into the profile cache", error); + } + } + + /** + * One equip or unequip, settled: the new profile is written where every surface reads it. + * Resolves to the server's refusal, or null on success; a transport failure is rethrown. + */ + async function settle(action: EquipAction, kindKey: string, call: () => Promise): Promise { + const askedIn = generation; + let result: IEquipResult; + + try { + result = await call(); + } catch (error) { + metrics.count("cosmetic.equip", { action, kind: kindKey, result: "failed", error: errorKind(error) }); + throw error; + } + + if (result.isFailedEquip()) { + metrics.count("cosmetic.equip", { action, kind: kindKey, result: "failed", error: errorName(result.error) }); + logger.warn(`Cosmetic ${action} was refused`, kindKey, result.error); + + return result.error; + } + + if (!result.isSuccessEquip()) { + metrics.count("cosmetic.equip", { action, kind: kindKey, result: "failed", error: "unknown_result" }); + throw new Error(`Cosmetic ${action} answered with an unknown result`); + } + + metrics.count("cosmetic.equip", { action, kind: kindKey, result: "ok" }); + + if (askedIn === generation) { + await wearProfile(result.profile); + } + + return null; + } + + /** Build `worn` with `toWornItem` / `toWornNickname` from wire.ts: plain objects do not serialise. */ + function equip(worn: IWornCosmetic): Promise { + const kindKey = resolver.value.kindKeyOf(worn) ?? "unknown"; + + return settle("equip", kindKey, () => api.cosmeticsInteraction.Equip(worn)); + } + + async function unequip(kindKey: string): Promise { + const askedIn = generation; + const error = await settle("unequip", kindKey, () => api.cosmeticsInteraction.Unequip(kindKey)); + + if (error === CosmeticError.NOT_FOUND && askedIn === generation) { + await forgetWornKind(kindKey); + } + + return error; + } + + /** The server holds nothing of this kind: drop what this tab still believes is worn. */ + async function forgetWornKind(kindKey: string): Promise { + const worn = me.meProfile?.cosmetics; + + if (!me.meProfile || !worn) { + return; + } + + const kept = worn.filter(entry => resolver.value.kindKeyOf(entry) !== kindKey); + + if (kept.length === worn.length) { + return; + } + + await wearProfile({ ...me.meProfile, cosmetics: kept }); + } + + return { + catalogue, + owned, + enabledKinds, + byId, + wearableKinds, + itemById, + isKindEnabled, + mayWear, + loadCatalogue, + loadOwned, + ensureLoaded, + optionsFor, + resolveCatalogueItem, + resolve, + wornOfKind, + equip, + unequip, + }; +}); diff --git a/src/store/features/ensureCosmeticsLoaded.ts b/src/store/features/ensureCosmeticsLoaded.ts new file mode 100644 index 00000000..323206aa --- /dev/null +++ b/src/store/features/ensureCosmeticsLoaded.ts @@ -0,0 +1,15 @@ +import { logger } from "@argon/core"; + +/** + * Starts loading the cosmetics catalogue and what this account owns, without waiting on it. + * + * Imported lazily: boot and reconnect run before anything draws a cosmetic, and a chunk that fails to + * load must not take either of them down with it. + */ +export function ensureCosmeticsLoaded(): void { + void import("@/store/features/cosmeticsStore") + .then(({ useCosmeticsStore }) => useCosmeticsStore().ensureLoaded()) + .catch(error => { + logger.warn("Could not load cosmetics", error); + }); +} diff --git a/src/store/system/apiStore.ts b/src/store/system/apiStore.ts index d3bfbb0c..0ffb8b18 100644 --- a/src/store/system/apiStore.ts +++ b/src/store/system/apiStore.ts @@ -310,6 +310,7 @@ export const useApi = defineStore("api", () => { const channelComposerInteraction = computed(() => rpcClient.value.ChannelComposerInteraction); const spaceExpressionInteraction = computed(() => rpcClient.value.SpaceExpressionInteraction); const connectionsInteraction = computed(() => rpcClient.value.ConnectionsInteraction); + const cosmeticsInteraction = computed(() => rpcClient.value.CosmeticsInteraction); const getRawClient = () => rpcClient; @@ -348,5 +349,6 @@ export const useApi = defineStore("api", () => { channelComposerInteraction, spaceExpressionInteraction, connectionsInteraction, + cosmeticsInteraction, }; }); diff --git a/src/store/system/appState.ts b/src/store/system/appState.ts index 8c93ff39..0afea724 100644 --- a/src/store/system/appState.ts +++ b/src/store/system/appState.ts @@ -19,6 +19,7 @@ import { useInstance } from "@/store/system/instanceStore"; import { useAccounts } from "@/store/auth/accountsStore"; import { ensureDbOpen } from "@/store/db/dexie"; import { metrics, bucket, errorKind } from "@/lib/telemetry/metrics"; +import { ensureCosmeticsLoaded } from "@/store/features/ensureCosmeticsLoaded"; // Initialize worklets with audio getter to break circular dependency initWorklets(() => audio); @@ -76,6 +77,10 @@ export const useAppState = defineStore("app", () => { await useNotificationStore().initFromGlobalBadges(); await useMe().completeInit(); + + // Every surface needs the catalogue to draw a worn item, but none blocks on it. + ensureCosmeticsLoaded(); + await useIdleStore().init(); await useActivity().init(); diff --git a/src/store/system/systemStore.ts b/src/store/system/systemStore.ts index 308a66f8..f7ae5f0a 100644 --- a/src/store/system/systemStore.ts +++ b/src/store/system/systemStore.ts @@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue"; import { useTone } from "@/store/media/toneStore"; import { Subject } from "rxjs"; import { useBus } from "@/store/realtime/busStore"; +import { ensureCosmeticsLoaded } from "@/store/features/ensureCosmeticsLoaded"; const LONG_RECONNECT_TIMEOUT = 5000; const RESYNC_JITTER_MS = 15000; @@ -151,9 +152,13 @@ export const useSystemStore = defineStore("system", () => { // The stream came back on the same server-side session: nothing to reload, however long it took. bus.resumed.subscribe(() => { isLongReconnecting.value = false; + // A cosmetics load that failed while the connection was down gets another go once it is back. + ensureCosmeticsLoaded(); }); bus.reconnected.subscribe(() => { + ensureCosmeticsLoaded(); + void (async () => { const wasLong = isLongReconnecting.value; isLongReconnecting.value = false; diff --git a/test/cosmetics/wire.test.ts b/test/cosmetics/wire.test.ts new file mode 100644 index 00000000..6c57840d --- /dev/null +++ b/test/cosmetics/wire.test.ts @@ -0,0 +1,415 @@ +import { + describe, + expect, + it, +} from "vitest"; +import { + AssetSlot, + FrameAnchor, + FrameProp, + FrameRepeat, + FrameRing, + FrameSurround, + NicknameGradientShape, + PayloadAvatarDecoration, + PayloadFont, + PayloadProfileFrame, + PayloadTextEffect, + WornItem, + WornNickname, + type FrameSides, + type IFramePart, +} from "@argon/glue"; + +import { + anchorName, + assetsOf, + avatarDecorationOf, + fontOptionOf, + framePartOf, + nicknameAxesOf, + profileFrameOf, + repeatName, + shapeName, + shapeValue, + sidesOf, + slotName, + textEffectOptionOf, + toWornItem, + toWornNickname, + tuningOf, +} from "@/cosmetics/wire"; +import { emptyNicknameStyleTuning } from "@/cosmetics/kinds/nickname-style"; +import { reviveWornCosmetics } from "@/cosmetics/reviveWorn"; + +function sides(top: number, right: number, bottom: number, left: number): FrameSides { + return { top, right, bottom, left }; +} + +function surround(overrides: Partial = {}): FrameSurround { + const part = new FrameSurround( + true, + 80, + null, + null, + AssetSlot.Secondary, + sides(10, 20, 30, 40), + sides(4, 0, 4, 0), + null, + FrameRepeat.Round, + true, + ); + + return Object.assign(part, overrides); +} + +function prop(overrides: Partial = {}): FrameProp { + const part = new FrameProp( + false, + 100, + sides(1, 2, 3, 4), + { kind: "sway", amount: 6, periodMs: 3000, phaseMs: 250 }, + AssetSlot.Tertiary, + FrameAnchor.BottomRight, + 48, + 32, + -5, + 7, + { frames: 8, columns: 4, fps: 12, still: 3 }, + ); + + return Object.assign(part, overrides); +} + +function ring(overrides: Partial = {}): FrameRing { + const part = new FrameRing(true, 100, null, null, 3, [0xff112233 | 0, 0xff445566 | 0], 45, 50); + + return Object.assign(part, overrides); +} + +function nickname(overrides: Partial = {}): WornNickname { + const worn = new WornNickname(null, null, null, null, null, null, null, null); + + return Object.assign(worn, overrides); +} + +describe("enum names", () => { + it("names every asset slot the way the upstream frame payload spells it", () => { + expect(slotName(AssetSlot.Primary)).toBe("primary"); + expect(slotName(AssetSlot.Secondary)).toBe("secondary"); + expect(slotName(AssetSlot.Tertiary)).toBe("tertiary"); + expect(slotName(AssetSlot.Quaternary)).toBe("quaternary"); + expect(slotName(9 as AssetSlot)).toBeNull(); + }); + + it("names every anchor", () => { + expect(anchorName(FrameAnchor.TopLeft)).toBe("topLeft"); + expect(anchorName(FrameAnchor.Top)).toBe("top"); + expect(anchorName(FrameAnchor.TopRight)).toBe("topRight"); + expect(anchorName(FrameAnchor.Left)).toBe("left"); + expect(anchorName(FrameAnchor.Center)).toBe("center"); + expect(anchorName(FrameAnchor.Right)).toBe("right"); + expect(anchorName(FrameAnchor.BottomLeft)).toBe("bottomLeft"); + expect(anchorName(FrameAnchor.Bottom)).toBe("bottom"); + expect(anchorName(FrameAnchor.BottomRight)).toBe("bottomRight"); + expect(anchorName(42 as FrameAnchor)).toBeNull(); + }); + + it("names every repeat", () => { + expect(repeatName(FrameRepeat.Stretch)).toBe("stretch"); + expect(repeatName(FrameRepeat.Repeat)).toBe("repeat"); + expect(repeatName(FrameRepeat.Round)).toBe("round"); + expect(repeatName(FrameRepeat.Space)).toBe("space"); + expect(repeatName(7 as FrameRepeat)).toBeNull(); + }); + + it("maps gradient shapes both ways", () => { + expect(shapeName(NicknameGradientShape.Linear)).toBe("linear"); + expect(shapeName(NicknameGradientShape.Radial)).toBe("radial"); + expect(shapeName(NicknameGradientShape.Conic)).toBe("conic"); + expect(shapeName(null)).toBeNull(); + expect(shapeName(3 as NicknameGradientShape)).toBeNull(); + + expect(shapeValue("linear")).toBe(NicknameGradientShape.Linear); + expect(shapeValue("radial")).toBe(NicknameGradientShape.Radial); + expect(shapeValue("conic")).toBe(NicknameGradientShape.Conic); + expect(shapeValue(null)).toBeNull(); + }); +}); + +describe("sides", () => { + it("turns the named object into the top-right-bottom-left tuple", () => { + expect(sidesOf(sides(1, 2, 3, 4))).toEqual([1, 2, 3, 4]); + }); + + it("has nothing for a missing side list", () => { + expect(sidesOf(null)).toBeNull(); + expect(sidesOf(undefined)).toBeNull(); + }); +}); + +describe("assets", () => { + it("keys files by the lowercase slot a frame part names", () => { + expect(assetsOf([ + { slot: AssetSlot.Primary, fileId: "file-a" }, + { slot: AssetSlot.Quaternary, fileId: "file-d" }, + ])).toEqual({ primary: "file-a", quaternary: "file-d" }); + }); + + it("drops a slot this build does not know and an empty file id", () => { + expect(assetsOf([ + { slot: 12 as AssetSlot, fileId: "file-x" }, + { slot: AssetSlot.Secondary, fileId: "" }, + ])).toEqual({}); + }); + + it("has nothing for no assets", () => { + expect(assetsOf(null)).toEqual({}); + expect(assetsOf([])).toEqual({}); + }); +}); + +describe("avatar decoration", () => { + it("reads its payload through the upstream bounds", () => { + expect(avatarDecorationOf(new PayloadAvatarDecoration(12, true))).toEqual({ insetPct: 12, beneath: true }); + expect(avatarDecorationOf(new PayloadAvatarDecoration(41, false))).toBeNull(); + }); + + it("is nothing for another kind's payload", () => { + expect(avatarDecorationOf(new PayloadFont("Lato"))).toBeNull(); + expect(avatarDecorationOf(null)).toBeNull(); + }); +}); + +describe("font option", () => { + it("reads the family through the upstream shape check", () => { + expect(fontOptionOf(new PayloadFont("Press Start 2P"))).toEqual({ cssFamily: "Press Start 2P" }); + expect(fontOptionOf(new PayloadFont("Lato; color: red"))).toBeNull(); + }); + + it("is nothing for another kind's payload", () => { + expect(fontOptionOf(new PayloadTextEffect())).toBeNull(); + }); +}); + +describe("text effect option", () => { + it("is an empty payload", () => { + expect(textEffectOptionOf(new PayloadTextEffect())).toEqual({}); + }); + + it("is nothing for another kind's payload", () => { + expect(textEffectOptionOf(new PayloadFont("Lato"))).toBeNull(); + }); +}); + +describe("frame parts", () => { + it("maps a surround, zeroing a missing inset and outset", () => { + expect(framePartOf(surround())).toEqual({ + type: "surround", + over: true, + opacityPct: 80, + inset: [0, 0, 0, 0], + motion: null, + slot: "secondary", + slice: [10, 20, 30, 40], + width: [4, 0, 4, 0], + outset: [0, 0, 0, 0], + repeat: "round", + fill: true, + }); + }); + + it("maps a prop with its motion and sprite", () => { + expect(framePartOf(prop())).toEqual({ + type: "prop", + over: false, + opacityPct: 100, + inset: [1, 2, 3, 4], + motion: { kind: "sway", amount: 6, periodMs: 3000, phaseMs: 250 }, + slot: "tertiary", + anchor: "bottomRight", + w: 48, + h: 32, + dx: -5, + dy: 7, + sprite: { frames: 8, columns: 4, fps: 12, still: 3 }, + }); + }); + + it("maps a ring", () => { + expect(framePartOf(ring())).toMatchObject({ + type: "ring", + thickness: 3, + colors: [0xff112233 | 0, 0xff445566 | 0], + angle: 45, + glowPct: 50, + }); + }); + + it("has nothing for a part type this build does not know", () => { + const unknown = { UnionKey: "FrameSpiral", UnionIndex: 3 } as unknown as IFramePart; + + expect(framePartOf(unknown)).toBeNull(); + }); +}); + +describe("profile frame", () => { + it("parses every part into the upstream model", () => { + const parsed = profileFrameOf(new PayloadProfileFrame([surround(), prop(), ring()])); + + expect(parsed?.parts.map(part => part.type)).toEqual(["surround", "prop", "ring"]); + expect(parsed?.parts[0]).toMatchObject({ slot: "secondary", outset: [0, 0, 0, 0], repeat: "round" }); + expect(parsed?.parts[1]).toMatchObject({ anchor: "bottomRight", sprite: { frames: 8, columns: 4, fps: 12, still: 3 } }); + }); + + it("drops a part with an unknown slot or anchor and keeps the rest", () => { + const parsed = profileFrameOf(new PayloadProfileFrame([ + surround({ slot: 9 as AssetSlot }), + prop({ anchor: 99 as FrameAnchor }), + ring(), + ])); + + expect(parsed?.parts.map(part => part.type)).toEqual(["ring"]); + }); + + it("falls back to stretch for an unknown repeat", () => { + const parsed = profileFrameOf(new PayloadProfileFrame([surround({ repeat: 8 as FrameRepeat })])); + + expect(parsed?.parts[0]).toMatchObject({ repeat: "stretch" }); + }); + + it("clamps out-of-range numbers through the upstream bounds", () => { + const parsed = profileFrameOf(new PayloadProfileFrame([ + surround({ width: sides(500, 0, 0, 0), outset: sides(1000, 0, 0, 0) }), + ])); + + expect(parsed?.parts[0]).toMatchObject({ width: [64, 0, 0, 0], outset: [96, 0, 0, 0] }); + }); + + it("is nothing when no part survives", () => { + expect(profileFrameOf(new PayloadProfileFrame([surround({ width: sides(0, 0, 0, 0) })]))).toBeNull(); + expect(profileFrameOf(new PayloadProfileFrame([]))).toBeNull(); + }); + + it("is nothing for another kind's payload", () => { + expect(profileFrameOf(new PayloadAvatarDecoration(0, false))).toBeNull(); + }); +}); + +describe("worn nickname", () => { + it("reads the wearer's tuning, renaming the shape and the spacing", () => { + const worn = nickname({ + colors: [0xffff0000 | 0, 0xff00ff00 | 0], + angle: 120, + shape: NicknameGradientShape.Conic, + animate: true, + weight: 700, + letterSpacing: 12, + }); + + expect(tuningOf(worn)).toEqual({ + colors: [0xffff0000 | 0, 0xff00ff00 | 0], + angle: 120, + shape: "conic", + animate: true, + weight: 700, + letterSpacingCentiEm: 12, + }); + }); + + it("drops out-of-range values rather than pinning them", () => { + const tuning = tuningOf(nickname({ + angle: 400, + shape: 5 as NicknameGradientShape, + weight: 950, + letterSpacing: -20, + })); + + expect(tuning).toEqual(emptyNicknameStyleTuning()); + }); + + it("refuses a seventh colour", () => { + expect(tuningOf(nickname({ colors: [1, 2, 3, 4, 5, 6, 7] }))).toBeNull(); + }); + + it("reads its axes", () => { + expect(nicknameAxesOf(nickname({ font: "font-id", effect: null }))).toEqual({ font: "font-id", effect: null }); + }); + + it("writes back a real WornNickname", () => { + const worn = toWornNickname( + { font: "font-id", effect: "effect-id" }, + { + colors: [7, 8], + angle: 30, + shape: "radial", + animate: false, + weight: 500, + letterSpacingCentiEm: -4, + }, + ); + + expect(worn).toBeInstanceOf(WornNickname); + expect(worn.isWornNickname()).toBe(true); + expect(worn.font).toBe("font-id"); + expect(worn.effect).toBe("effect-id"); + expect(worn.colors).toEqual([7, 8]); + expect(worn.angle).toBe(30); + expect(worn.shape).toBe(NicknameGradientShape.Radial); + expect(worn.animate).toBe(false); + expect(worn.weight).toBe(500); + expect(worn.letterSpacing).toBe(-4); + }); + + it("round-trips through the wire shape", () => { + const tuning = { + colors: [1, 2, 3], + angle: 0, + shape: "linear" as const, + animate: true, + weight: 100, + letterSpacingCentiEm: 50, + }; + + expect(tuningOf(toWornNickname({ font: null, effect: null }, tuning))).toEqual(tuning); + }); + + it("writes an all-null look for an empty tuning, which the server reads as taking it off", () => { + const worn = toWornNickname({ font: null, effect: null }, emptyNicknameStyleTuning()); + + expect([worn.font, worn.effect, worn.colors, worn.angle, worn.shape, worn.animate, worn.weight, worn.letterSpacing]) + .toEqual([null, null, null, null, null, null, null, null]); + }); +}); + +describe("worn item", () => { + it("is a real WornItem", () => { + const worn = toWornItem("item-id"); + + expect(worn).toBeInstanceOf(WornItem); + expect(worn.itemId).toBe("item-id"); + }); +}); + +describe("revival from IndexedDB", () => { + it("gives a structured clone its class back", () => { + const flattened = structuredClone([ + new WornItem("item-id"), + nickname({ font: "font-id", colors: [5], shape: NicknameGradientShape.Radial }), + ]); + + const revived = reviveWornCosmetics(flattened); + + expect(revived).toHaveLength(2); + expect(revived?.[0].isWornItem()).toBe(true); + expect((revived?.[0] as WornItem).itemId).toBe("item-id"); + expect(revived?.[1].isWornNickname()).toBe(true); + expect((revived?.[1] as WornNickname).shape).toBe(NicknameGradientShape.Radial); + }); + + it("keeps null as not told and drops what it does not recognise", () => { + expect(reviveWornCosmetics(null)).toBeNull(); + expect(reviveWornCosmetics(undefined)).toBeNull(); + expect(reviveWornCosmetics([{ kindKey: "profile.frame", payloadJson: "{}" }, { UnionKey: "WornItem" }])).toEqual([]); + }); +}); diff --git a/test/store/cosmeticsStore.test.ts b/test/store/cosmeticsStore.test.ts new file mode 100644 index 00000000..e11d41a5 --- /dev/null +++ b/test/store/cosmeticsStore.test.ts @@ -0,0 +1,755 @@ +/** + * Turning what the server says somebody wears into something drawable, and changing it. + * + * Most rules here are "draws nothing" rules: a cosmetic this build cannot draw has to disappear + * quietly rather than take a profile card down, and is counted through `cosmetic.render.failed`. + */ + +import { + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { + createPinia, + setActivePinia, +} from "pinia"; +import { reactive } from "vue"; + +const h = vi.hoisted(() => ({ + counted: [] as Array<{ name: string; attrs?: Record }>, + getCatalogue: vi.fn(), + getMyCosmetics: vi.fn(), + equip: vi.fn(), + unequip: vi.fn(), + updateCosmetics: vi.fn(async () => {}), + me: { me: { userId: "me" }, meProfile: null as unknown, isPremium: false } as Record, +})); + +vi.mock("@argon/core", () => ({ + logger: { log() {}, debug() {}, info() {}, warn() {}, error() {} }, +})); +vi.mock("@/lib/telemetry/metrics", () => ({ + metrics: { + count(name: string, attrs?: Record) { + h.counted.push({ name, attrs }); + }, + }, + errorKind: () => "network", +})); +vi.mock("@/store/system/apiStore", () => ({ + useApi: () => ({ + cosmeticsInteraction: { + GetCatalogue: h.getCatalogue, + GetMyCosmetics: h.getMyCosmetics, + Equip: h.equip, + Unequip: h.unequip, + }, + }), +})); +vi.mock("@/store/auth/meStore", () => ({ useMe: () => h.me })); +vi.mock("@/store/data/profileCacheStore", () => ({ + useProfileCacheStore: () => ({ updateCosmetics: h.updateCosmetics }), +})); + +import { + AssetSlot, + CosmeticError, + FailedEquip, + NicknameGradientShape, + PayloadAvatarDecoration, + PayloadFont, + PayloadProfileFrame, + PayloadTextEffect, + SuccessEquip, + WornItem, + WornNickname, + FrameRing, + type ArgonUserProfile, + type CatalogueCosmetic, + type CosmeticCatalogue, + type ICosmeticPayload, + type IWornCosmetic, +} from "@argon/glue"; + +import { useCosmeticsStore } from "@/store/features/cosmeticsStore"; +import { optionOn } from "@/cosmetics/optionOn"; +import { runSessionReset } from "@/store/system/sessionLifecycle"; + +const ALL_KINDS = ["avatar.decoration", "profile.frame", "nickname.style", "option.font", "option.text-effect"]; + +const DECORATION_ID = "AAAAAAAA-0000-0000-0000-000000000001"; +const FRAME_ID = "aaaaaaaa-0000-0000-0000-000000000002"; +const FONT_ID = "aaaaaaaa-0000-0000-0000-000000000003"; +const GLOW_ID = "aaaaaaaa-0000-0000-0000-000000000004"; +const PRISM_ID = "aaaaaaaa-0000-0000-0000-000000000005"; +const FUTURE_ID = "aaaaaaaa-0000-0000-0000-000000000006"; +const BROKEN_FRAME_ID = "aaaaaaaa-0000-0000-0000-000000000007"; + +function row( + cosmeticId: string, + kindKey: string, + slug: string, + payload: ICosmeticPayload, + overrides: Partial = {}, +): CatalogueCosmetic { + return { + cosmeticId, + kindKey, + slug, + nameKey: `cosmetic_${slug}`, + descriptionKey: null, + rarity: null, + version: 1, + payload, + assets: [], + availableUntil: null, + free: false, + ultima: false, + text: [], + ...overrides, + }; +} + +const ring = new FrameRing(true, 100, null, null, 2, [0xff00ff00 | 0], 90, 0); + +function catalogueWith(enabledKinds: string[] = ALL_KINDS): CosmeticCatalogue { + return { + enabledKinds, + items: [ + row(DECORATION_ID, "avatar.decoration", "halo", new PayloadAvatarDecoration(10, false), { + assets: [ + { slot: AssetSlot.Primary, fileId: "halo-file" }, + { slot: AssetSlot.Secondary, fileId: "halo-extra" }, + ], + }), + row(FRAME_ID, "profile.frame", "vines", new PayloadProfileFrame([ring]), { free: true }), + row(BROKEN_FRAME_ID, "profile.frame", "empty", new PayloadProfileFrame([])), + row(FONT_ID, "option.font", "lato", new PayloadFont("Lato"), { + assets: [{ slot: AssetSlot.Primary, fileId: "lato-file" }], + }), + row(GLOW_ID, "option.text-effect", "glow", new PayloadTextEffect()), + row(PRISM_ID, "option.text-effect", "prismatic", new PayloadTextEffect()), + row(FUTURE_ID, "profile.hologram", "holo", new PayloadTextEffect()), + ], + }; +} + +function nickname(overrides: Partial = {}): WornNickname { + return Object.assign(new WornNickname(null, null, [0xffff0000 | 0], null, null, null, null, null), overrides); +} + +function wearing(...cosmetics: IWornCosmetic[]): ArgonUserProfile { + return { userId: "u", cosmetics } as unknown as ArgonUserProfile; +} + +function renderFailures(): Array | undefined> { + return h.counted.filter(entry => entry.name === "cosmetic.render.failed").map(entry => entry.attrs); +} + +async function loadedStore(catalogue: CosmeticCatalogue = catalogueWith()) { + h.getCatalogue.mockResolvedValue(catalogue); + + const store = useCosmeticsStore(); + + await store.ensureLoaded(); + + return store; +} + +beforeEach(async () => { + await runSessionReset(); + + setActivePinia(createPinia()); + + h.counted.length = 0; + h.me = { me: { userId: "me" }, meProfile: null, isPremium: false }; + h.getCatalogue.mockReset(); + h.getMyCosmetics.mockReset(); + h.getMyCosmetics.mockResolvedValue({ owned: [] }); + h.equip.mockReset(); + h.unequip.mockReset(); + h.updateCosmetics.mockClear(); +}); + +describe("loading", () => { + test("catalogue and owned are read once however many ask at the same time", async () => { + h.getCatalogue.mockResolvedValue(catalogueWith()); + + const store = useCosmeticsStore(); + + await Promise.all([store.ensureLoaded(), store.ensureLoaded(), store.loadCatalogue()]); + await store.ensureLoaded(); + + expect(h.getCatalogue).toHaveBeenCalledTimes(1); + expect(h.getMyCosmetics).toHaveBeenCalledTimes(1); + expect(store.catalogue?.items).toHaveLength(7); + }); + + test("a failed load is retried by the next call", async () => { + h.getCatalogue.mockRejectedValueOnce(new Error("offline")); + + const store = useCosmeticsStore(); + + await store.ensureLoaded(); + expect(store.catalogue).toBeNull(); + + h.getCatalogue.mockResolvedValue(catalogueWith()); + await store.ensureLoaded(); + + expect(store.catalogue).not.toBeNull(); + expect(h.getCatalogue).toHaveBeenCalledTimes(2); + expect(h.getMyCosmetics).toHaveBeenCalledTimes(1); + }); + + test("a failed half is retried on its own with backoff, and the retries stop", async () => { + vi.useFakeTimers(); + + try { + h.getCatalogue.mockRejectedValue(new Error("offline")); + + const store = useCosmeticsStore(); + + await store.ensureLoaded(); + expect(h.getCatalogue).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1_999); + expect(h.getCatalogue).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(h.getCatalogue).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(8_000); + expect(h.getCatalogue).toHaveBeenCalledTimes(3); + + h.getCatalogue.mockResolvedValue(catalogueWith()); + await vi.advanceTimersByTimeAsync(30_000); + + expect(h.getCatalogue).toHaveBeenCalledTimes(4); + expect(store.catalogue).not.toBeNull(); + expect(h.getMyCosmetics).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(120_000); + expect(h.getCatalogue).toHaveBeenCalledTimes(4); + } finally { + vi.useRealTimers(); + } + }); + + test("three failed retries give up until something asks again", async () => { + vi.useFakeTimers(); + + try { + h.getMyCosmetics.mockRejectedValue(new Error("offline")); + h.getCatalogue.mockResolvedValue(catalogueWith()); + + const store = useCosmeticsStore(); + + await store.ensureLoaded(); + await vi.advanceTimersByTimeAsync(200_000); + + expect(h.getMyCosmetics).toHaveBeenCalledTimes(4); + expect(h.getCatalogue).toHaveBeenCalledTimes(1); + + h.getMyCosmetics.mockResolvedValue({ owned: [] }); + await store.ensureLoaded(); + + expect(h.getMyCosmetics).toHaveBeenCalledTimes(5); + expect(h.getCatalogue).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + test("a session reset cancels a pending retry", async () => { + vi.useFakeTimers(); + + try { + h.getCatalogue.mockRejectedValue(new Error("offline")); + + const store = useCosmeticsStore(); + + await store.ensureLoaded(); + await runSessionReset(); + await vi.advanceTimersByTimeAsync(60_000); + + expect(h.getCatalogue).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + test("a session reset forgets everything, and an answer for the old session is dropped", async () => { + let answer!: (catalogue: CosmeticCatalogue) => void; + + h.getCatalogue.mockReturnValue(new Promise(resolve => { + answer = resolve; + })); + + const store = useCosmeticsStore(); + const pending = store.loadCatalogue(); + + await runSessionReset(); + answer(catalogueWith()); + + expect(await pending).toBeNull(); + expect(store.catalogue).toBeNull(); + }); + + test("ids are compared without regard to case", async () => { + const store = await loadedStore(); + + expect(store.itemById(DECORATION_ID.toLowerCase())?.slug).toBe("halo"); + expect(store.itemById(FRAME_ID.toUpperCase())?.slug).toBe("vines"); + expect(store.itemById(null)).toBeNull(); + }); +}); + +describe("ownership", () => { + test("a free row may be worn by anybody, an owned one by its owner, an expired grant by nobody", async () => { + h.getMyCosmetics.mockResolvedValue({ + owned: [ + { cosmeticId: DECORATION_ID.toLowerCase(), expiresAt: null, viaSubscription: false }, + { cosmeticId: FONT_ID, expiresAt: { toDate: () => new Date(Date.now() - 1000) }, viaSubscription: true }, + ], + }); + + const store = await loadedStore(); + + expect(store.mayWear(store.itemById(DECORATION_ID)!)).toBe(true); + expect(store.mayWear(store.itemById(FRAME_ID)!)).toBe(true); + expect(store.mayWear(store.itemById(FONT_ID)!)).toBe(false); + expect(store.mayWear(store.itemById(GLOW_ID)!)).toBe(false); + }); +}); + +describe("owned refresh", () => { + test("a forced refresh asks again and replaces what is held, even with a request on its way", async () => { + const store = await loadedStore(); + let answer!: (owned: unknown) => void; + + h.getMyCosmetics.mockReturnValueOnce(new Promise(resolve => { + answer = resolve; + })); + + const stale = store.loadOwned(); + + h.getMyCosmetics.mockResolvedValueOnce({ + owned: [{ cosmeticId: DECORATION_ID, expiresAt: null, viaSubscription: false }], + }); + + await store.loadOwned({ force: true }); + answer({ owned: [] }); + + expect(await stale).toBeNull(); + expect(store.mayWear(store.itemById(DECORATION_ID)!)).toBe(true); + }); + + test("a grant is re-checked against its expiry when asked, not only when loaded", async () => { + vi.useFakeTimers(); + + try { + vi.setSystemTime(new Date("2026-10-08T12:00:00Z")); + h.getMyCosmetics.mockResolvedValue({ + owned: [{ cosmeticId: DECORATION_ID, expiresAt: { toDate: () => new Date("2026-10-08T12:00:05Z") }, viaSubscription: true }], + }); + + const store = await loadedStore(); + + expect(store.mayWear(store.itemById(DECORATION_ID)!)).toBe(true); + + vi.setSystemTime(new Date("2026-10-08T12:00:06Z")); + + expect(store.mayWear(store.itemById(DECORATION_ID)!)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + test("a grant listed twice keeps the longer claim", async () => { + h.getMyCosmetics.mockResolvedValue({ + owned: [ + { cosmeticId: DECORATION_ID, expiresAt: { toDate: () => new Date(Date.now() + 1000) }, viaSubscription: true }, + { cosmeticId: DECORATION_ID.toLowerCase(), expiresAt: null, viaSubscription: false }, + ], + }); + + const store = await loadedStore(); + + expect(store.owned.get(DECORATION_ID.toLowerCase())).toBeNull(); + }); + + test("a subscription starting refreshes what is owned", async () => { + h.me = reactive({ me: { userId: "me" }, meProfile: null, isPremium: false }); + + const store = await loadedStore(); + + expect(h.getMyCosmetics).toHaveBeenCalledTimes(1); + + h.me.isPremium = true; + await vi.waitFor(() => expect(h.getMyCosmetics).toHaveBeenCalledTimes(2)); + + expect(store.owned.size).toBe(0); + }); + + test("a refresh that fails on a subscription change is asked for again, not trusted as loaded", async () => { + h.me = reactive({ me: { userId: "me" }, meProfile: null, isPremium: false }); + + const store = await loadedStore(); + + h.getMyCosmetics.mockRejectedValueOnce(new Error("offline")); + h.getMyCosmetics.mockResolvedValue({ owned: [{ cosmeticId: FRAME_ID, expiresAt: null, viaSubscription: true }] }); + + h.me.isPremium = true; + await vi.waitFor(() => expect(h.getMyCosmetics).toHaveBeenCalledTimes(3)); + + expect(store.owned.has(FRAME_ID.toLowerCase())).toBe(true); + }); +}); + +describe("what a picker offers", () => { + test("only rows of an enabled kind, readable, and with a client module for an effect", async () => { + const store = await loadedStore(); + + expect(store.optionsFor("option.text-effect").map(item => item.slug)).toEqual(["glow"]); + expect(store.optionsFor("profile.frame").map(item => item.slug)).toEqual(["vines"]); + }); + + test("nothing for a kind the server switched off", async () => { + const store = await loadedStore(catalogueWith(["avatar.decoration"])); + + expect(store.optionsFor("profile.frame")).toEqual([]); + expect(store.isKindEnabled("profile.frame")).toBe(false); + expect(store.wearableKinds.map(kind => kind.key)).toEqual(["avatar.decoration"]); + }); + + test("what may be worn comes first", async () => { + h.getMyCosmetics.mockResolvedValue({ owned: [] }); + + const catalogue = catalogueWith(); + + catalogue.items.push(row("bbbbbbbb-0000-0000-0000-000000000001", "profile.frame", "free-ring", new PayloadProfileFrame([ring]), { free: true })); + catalogue.items.unshift(row("bbbbbbbb-0000-0000-0000-000000000002", "profile.frame", "locked-ring", new PayloadProfileFrame([ring]))); + + const store = await loadedStore(catalogue); + + expect(store.optionsFor("profile.frame").map(item => item.slug)).toEqual(["vines", "free-ring", "locked-ring"]); + }); +}); + +describe("resolution", () => { + test("nothing resolves before the catalogue arrives", () => { + const store = useCosmeticsStore(); + + expect(store.resolve(wearing(new WornItem(DECORATION_ID)), "avatar")).toEqual([]); + }); + + test("null cosmetics and no profile resolve to nothing", async () => { + const store = await loadedStore(); + + expect(store.resolve({ cosmetics: null }, "avatar")).toEqual([]); + expect(store.resolve(null, "avatar")).toEqual([]); + }); + + test("a worn item resolves on a surface its kind names, with its files by slot", async () => { + const store = await loadedStore(); + + const [decoration] = store.resolve(wearing(new WornItem(DECORATION_ID.toLowerCase())), "avatar"); + + expect(decoration.kind.key).toBe("avatar.decoration"); + expect(decoration.itemId).toBe(DECORATION_ID); + expect(decoration.payload).toEqual({ insetPct: 10, beneath: false }); + expect(decoration.assets).toEqual({ primary: "halo-file", secondary: "halo-extra" }); + }); + + test("and not on a surface it does not", async () => { + const store = await loadedStore(); + + expect(store.resolve(wearing(new WornItem(DECORATION_ID)), "profileCard")).toEqual([]); + expect(renderFailures()).toEqual([]); + }); + + test("everything worn comes back in compositing order", async () => { + const store = await loadedStore(); + + const resolved = store.resolve(wearing(new WornItem(FRAME_ID), nickname()), "profileCard"); + + expect(resolved.map(item => item.kind.key)).toEqual(["nickname.style", "profile.frame"]); + }); + + test("a row whose kind this build does not ship is skipped and counted", async () => { + const store = await loadedStore(); + + expect(store.resolve(wearing(new WornItem(FUTURE_ID)), "profileCard")).toEqual([]); + expect(renderFailures()).toEqual([{ kind: "profile.hologram", reason: "unknown_kind" }]); + }); + + test("a kind the server switched off is skipped without counting a failure", async () => { + const store = await loadedStore(catalogueWith(["avatar.decoration"])); + + expect(store.resolve(wearing(new WornItem(FRAME_ID), nickname()), "profileCard")).toEqual([]); + expect(renderFailures()).toEqual([]); + }); + + test("an item missing from the catalogue is skipped and counted", async () => { + const store = await loadedStore(); + + expect(store.resolve(wearing(new WornItem("cccccccc-0000-0000-0000-000000000000")), "avatar")).toEqual([]); + expect(renderFailures()).toEqual([{ kind: "unknown", reason: "missing_row" }]); + }); + + test("a payload this build cannot read is skipped and counted once, however often it renders", async () => { + const store = await loadedStore(); + const profile = wearing(new WornItem(BROKEN_FRAME_ID)); + + for (let render = 0; render < 5; render++) { + expect(store.resolve(profile, "profileCard")).toEqual([]); + } + + expect(renderFailures()).toEqual([{ kind: "profile.frame", reason: "payload_rejected" }]); + }); + + test("an entry that is neither union case is skipped", async () => { + const store = await loadedStore(); + const stranger = { UnionKey: "WornBadge", UnionIndex: 2 } as unknown as IWornCosmetic; + + expect(store.resolve(wearing(stranger), "profileCard")).toEqual([]); + expect(renderFailures()).toEqual([{ kind: "unknown", reason: "unknown_entry" }]); + }); + + test("a profile flattened by IndexedDB resolves the same as a live one", async () => { + const store = await loadedStore(); + const flattened = structuredClone(wearing(new WornItem(FRAME_ID), nickname({ font: FONT_ID }))); + + expect(store.resolve(flattened, "profileCard").map(item => item.kind.key)).toEqual(["nickname.style", "profile.frame"]); + }); +}); + +describe("a worn nickname", () => { + test("is a look composed from its axes and its wearer's tuning", async () => { + const store = await loadedStore(); + + const [style] = store.resolve( + wearing(nickname({ + font: FONT_ID.toUpperCase(), + effect: GLOW_ID, + colors: [0xff112233 | 0, 0xff445566 | 0], + angle: 45, + shape: NicknameGradientShape.Radial, + weight: 800, + letterSpacing: 5, + })), + "nicknameInMessages", + ); + + expect(style.kind.key).toBe("nickname.style"); + expect(style.itemId).toBeNull(); + expect(style.payload).toEqual({ + colors: [0xff112233 | 0, 0xff445566 | 0], + angle: 45, + shape: "radial", + animate: null, + weight: 800, + letterSpacingCentiEm: 5, + }); + expect(optionOn(style, "font")).toMatchObject({ + kindKey: "option.font", + slug: "lato", + payload: { cssFamily: "Lato" }, + assets: { primary: "lato-file" }, + }); + expect(optionOn(style, "effect")?.slug).toBe("glow"); + }); + + test("an effect with no client module is dropped and the rest of the look stands", async () => { + const store = await loadedStore(); + + const [style] = store.resolve(wearing(nickname({ font: FONT_ID, effect: PRISM_ID })), "memberListRow"); + + expect(optionOn(style, "effect")).toBeUndefined(); + expect(optionOn(style, "font")?.slug).toBe("lato"); + expect(renderFailures()).toEqual([{ kind: "option.text-effect", reason: "unimplemented_slug" }]); + }); + + test("an axis pointing at another kind's row is dropped", async () => { + const store = await loadedStore(); + + const [style] = store.resolve(wearing(nickname({ font: GLOW_ID })), "profileCard"); + + expect(style.options).toEqual([]); + expect(renderFailures()).toEqual([{ kind: "option.font", reason: "option_rejected" }]); + }); + + test("an option kind the server switched off is dropped quietly", async () => { + const store = await loadedStore(catalogueWith(["nickname.style", "option.text-effect"])); + + const [style] = store.resolve(wearing(nickname({ font: FONT_ID, effect: GLOW_ID })), "profileCard"); + + expect(style.options.map(option => option.facetId)).toEqual(["effect"]); + expect(renderFailures()).toEqual([]); + }); + + test("a look with nothing left to draw is not drawn", async () => { + const store = await loadedStore(catalogueWith(["nickname.style"])); + + expect(store.resolve(wearing(nickname({ colors: null, font: FONT_ID })), "profileCard")).toEqual([]); + }); + + test("more colours than a client may draw is skipped and counted", async () => { + const store = await loadedStore(); + + expect(store.resolve(wearing(nickname({ colors: [1, 2, 3, 4, 5, 6, 7] })), "profileCard")).toEqual([]); + expect(renderFailures()).toEqual([{ kind: "nickname.style", reason: "payload_rejected" }]); + }); + + test("is not drawn on a face", async () => { + const store = await loadedStore(); + + expect(store.resolve(wearing(nickname()), "avatar")).toEqual([]); + }); +}); + +describe("the entry worn for a kind", () => { + test("is found by its catalogue row's kind, or as the nickname", async () => { + const store = await loadedStore(); + const look = nickname(); + const frame = new WornItem(FRAME_ID); + const profile = wearing(frame, look); + + expect(store.wornOfKind(profile, "profile.frame")).toBe(frame); + expect(store.wornOfKind(profile, "nickname.style")).toBe(look); + expect(store.wornOfKind(profile, "avatar.decoration")).toBeNull(); + }); +}); + +describe("equip", () => { + test("sends the real union class and writes the answer to the cache, never installing the projection as the own profile", async () => { + const store = await loadedStore(); + const profile = { userId: "me", cosmetics: [new WornItem(FRAME_ID)] } as unknown as ArgonUserProfile; + + h.equip.mockResolvedValue(new SuccessEquip(profile)); + + const error = await store.equip(new WornItem(FRAME_ID)); + + expect(error).toBeNull(); + expect(h.equip.mock.calls[0][0]).toBeInstanceOf(WornItem); + expect(h.me.meProfile).toBeNull(); + expect(h.updateCosmetics).toHaveBeenCalledWith("me", profile.cosmetics); + expect(h.counted).toContainEqual({ + name: "cosmetic.equip", + attrs: { action: "equip", kind: "profile.frame", result: "ok" }, + }); + }); + + test("a loaded own profile keeps everything but the look: the echo is the broadcast projection", async () => { + const store = await loadedStore(); + const connections = [{ provider: "github" }]; + + h.me.meProfile = { userId: "me", bio: "hello", connections, archetypes: [{ id: "role" }], cosmetics: [] }; + h.equip.mockResolvedValue(new SuccessEquip({ + userId: "me", + bio: "hello", + connections: null, + archetypes: [], + cosmetics: [new WornItem(FRAME_ID)], + } as unknown as ArgonUserProfile)); + + await store.equip(new WornItem(FRAME_ID)); + + const kept = h.me.meProfile as unknown as ArgonUserProfile; + + expect(kept.connections).toBe(connections); + expect(kept.archetypes).toHaveLength(1); + expect(kept.cosmetics).toHaveLength(1); + }); + + test("a nickname look goes out as a WornNickname", async () => { + const store = await loadedStore(); + + h.equip.mockResolvedValue(new SuccessEquip({ userId: "me", cosmetics: [] } as unknown as ArgonUserProfile)); + + await store.equip(nickname({ font: FONT_ID })); + + expect(h.equip.mock.calls[0][0]).toBeInstanceOf(WornNickname); + expect(h.counted.at(-1)?.attrs).toMatchObject({ kind: "nickname.style", result: "ok" }); + }); + + test("a refusal is returned and changes nothing", async () => { + const store = await loadedStore(); + + h.equip.mockResolvedValue(new FailedEquip(CosmeticError.NOT_OWNED)); + + const error = await store.equip(new WornItem(DECORATION_ID)); + + expect(error).toBe(CosmeticError.NOT_OWNED); + expect(h.me.meProfile).toBeNull(); + expect(h.updateCosmetics).not.toHaveBeenCalled(); + expect(h.counted).toContainEqual({ + name: "cosmetic.equip", + attrs: { action: "equip", kind: "avatar.decoration", result: "failed", error: "not_owned" }, + }); + }); + + test("a transport failure is counted and rethrown", async () => { + const store = await loadedStore(); + + h.equip.mockRejectedValue(new Error("socket closed")); + + await expect(store.equip(new WornItem(DECORATION_ID))).rejects.toThrow("socket closed"); + expect(h.counted).toContainEqual({ + name: "cosmetic.equip", + attrs: { action: "equip", kind: "avatar.decoration", result: "failed", error: "network" }, + }); + }); + + test("unequip answered NOT_FOUND drops the stale entry this tab still holds", async () => { + const store = await loadedStore(); + + h.me.meProfile = { userId: "me", cosmetics: [new WornItem(FRAME_ID), new WornItem(DECORATION_ID)] }; + h.unequip.mockResolvedValue(new FailedEquip(CosmeticError.NOT_FOUND)); + + expect(await store.unequip("profile.frame")).toBe(CosmeticError.NOT_FOUND); + + const kept = (h.me.meProfile as unknown as ArgonUserProfile).cosmetics ?? []; + + expect(kept).toHaveLength(1); + expect(store.wornOfKind({ cosmetics: kept }, "profile.frame")).toBeNull(); + expect(h.updateCosmetics).toHaveBeenCalledWith("me", kept); + }); + + test("unequip asks by kind and writes the answer", async () => { + const store = await loadedStore(); + const profile = { userId: "me", cosmetics: [] } as unknown as ArgonUserProfile; + + h.unequip.mockResolvedValue(new SuccessEquip(profile)); + + expect(await store.unequip("profile.frame")).toBeNull(); + expect(h.unequip).toHaveBeenCalledWith("profile.frame"); + expect(h.me.meProfile).toBeNull(); + expect(h.updateCosmetics).toHaveBeenCalledWith("me", []); + expect(h.counted.at(-1)).toEqual({ + name: "cosmetic.equip", + attrs: { action: "unequip", kind: "profile.frame", result: "ok" }, + }); + }); + + test("an answer that lands after a session reset is not written into the next account", async () => { + const store = await loadedStore(); + let answer!: (result: SuccessEquip) => void; + + h.equip.mockReturnValue(new Promise(resolve => { + answer = resolve; + })); + + const pending = store.equip(new WornItem(FRAME_ID)); + + await runSessionReset(); + answer(new SuccessEquip({ userId: "me", cosmetics: [] } as unknown as ArgonUserProfile)); + + expect(await pending).toBeNull(); + expect(h.me.meProfile).toBeNull(); + expect(h.updateCosmetics).not.toHaveBeenCalled(); + }); +}); diff --git a/test/store/profileCacheUpgrade.test.ts b/test/store/profileCacheUpgrade.test.ts new file mode 100644 index 00000000..a247d743 --- /dev/null +++ b/test/store/profileCacheUpgrade.test.ts @@ -0,0 +1,58 @@ +/** + * The upgrade that drops profile rows cached before worn cosmetics were kept: they hold + * `cosmetics: null` ("not told") and would draw nobody's look until they aged out. + */ + +import { + describe, + expect, + test, + vi, +} from "vitest"; + +await vi.hoisted(async () => { + const { indexedDB, IDBKeyRange } = await import("fake-indexeddb"); + + Object.assign(globalThis, { indexedDB, IDBKeyRange }); +}); + +vi.mock("@argon/core", () => ({ + logger: { log() {}, debug() {}, info() {}, warn() {}, error() {} }, + delay: (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds)), +})); + +import Dexie from "dexie"; + +import { PoolDatabase } from "@/store/db/dexie"; + +describe("the v11 upgrade", () => { + test("empties the profile cache and keeps every other table", async () => { + const name = "probe-upgrade-v10"; + const previous = new Dexie(name); + + previous.version(10).stores({ + profileCache: "key, userId, spaceId", + channels: "channelId, spaceId, type", + }); + + await previous.table("profileCache").put({ + key: "s1:u1", + spaceId: "s1", + userId: "u1", + profile: { userId: "u1", cosmetics: null }, + fetchedAt: Date.now(), + scope: "status", + }); + await previous.table("channels").put({ channelId: "c1", spaceId: "s1", type: 0 }); + previous.close(); + + const upgraded = new PoolDatabase(name); + + await upgraded.open(); + + expect(await upgraded.profileCache.count()).toBe(0); + expect(await upgraded.channels.count()).toBe(1); + + upgraded.close(); + }); +}); diff --git a/test/store/profilePrefetch.test.ts b/test/store/profilePrefetch.test.ts index e6432176..862787e7 100644 --- a/test/store/profilePrefetch.test.ts +++ b/test/store/profilePrefetch.test.ts @@ -42,7 +42,19 @@ vi.mock("@/store/system/sessionLifecycle", async () => { onSessionReset: (fn: () => void) => resetters.push(fn), }; }); -vi.mock("@/store/realtime/busStore", () => ({ useBus: () => ({ onServerEvent: () => {} }) })); +const { serverEvents, ownAccount } = vi.hoisted(() => ({ + serverEvents: new Map void>(), + ownAccount: { me: { userId: "me" }, meProfile: null as any }, +})); + +vi.mock("@/store/realtime/busStore", () => ({ + useBus: () => ({ + onServerEvent: (key: string, callback: (event: any) => void) => { + serverEvents.set(key, callback); + }, + }), +})); +vi.mock("@/store/auth/meStore", () => ({ useMe: () => ownAccount })); vi.mock("@/store/system/systemStore", () => ({ useSystemStore: () => ({ isLongReconnecting: false }), })); @@ -135,6 +147,8 @@ beforeEach(() => { resetters.length = 0; sessionEpoch.value = 0; profileCache.clear(); + serverEvents.clear(); + ownAccount.meProfile = null; setActivePinia(createPinia()); }); @@ -156,7 +170,7 @@ describe("member list prefetch", () => { expect(wire[0].userIds).toEqual(["u1"]); answerAll(wire[0], "away"); - expect(await rows).toEqual(Array(4).fill({ customStatus: "away", customStatusIconId: null, customStatusEmoji: null })); + expect(await rows).toEqual(Array(4).fill({ customStatus: "away", customStatusIconId: null, customStatusEmoji: null, cosmetics: null })); }); test("a screenful of members goes out as one call, not one call each", async () => { @@ -177,6 +191,7 @@ describe("member list prefetch", () => { customStatus: "away", customStatusIconId: null, customStatusEmoji: null, + cosmetics: null, }); expect(wire).toHaveLength(1); }); @@ -242,6 +257,7 @@ describe("member list prefetch", () => { customStatus: "back soon", customStatusIconId: null, customStatusEmoji: null, + cosmetics: null, }); expect(wire).toHaveLength(1); }); @@ -271,7 +287,7 @@ describe("member list prefetch", () => { // The answer is paired up by userId, so a missing entry costs its own job and nothing else. wire[0].answer([serverProfile("u1", "away")]); - expect(await present).toEqual({ customStatus: "away", customStatusIconId: null, customStatusEmoji: null }); + expect(await present).toEqual({ customStatus: "away", customStatusIconId: null, customStatusEmoji: null, cosmetics: null }); await expect(absent).rejects.toThrow(/u2/); }); }); @@ -312,7 +328,7 @@ describe("an answer that arrives after the ground moved", () => { expect(wire).toHaveLength(2); answerAll(wire[1], "current"); - expect(await joining).toEqual({ customStatus: "current", customStatusIconId: null, customStatusEmoji: null }); + expect(await joining).toEqual({ customStatus: "current", customStatusIconId: null, customStatusEmoji: null, cosmetics: null }); }); test("an update event for the previous account is not written into the new one's cache", async () => { @@ -355,6 +371,7 @@ describe("an answer that arrives after the ground moved", () => { customStatus: "from before the reconnect", customStatusIconId: null, customStatusEmoji: null, + cosmetics: null, }); // But nothing was written down, so the next reader goes and asks. @@ -407,7 +424,7 @@ describe("an answer that arrives after the ground moved", () => { expect(wire[0].userIds).toEqual(["u1"]); answerAll(wire[0], "current"); - expect(await reading).toEqual({ customStatus: "current", customStatusIconId: null, customStatusEmoji: null }); + expect(await reading).toEqual({ customStatus: "current", customStatusIconId: null, customStatusEmoji: null, cosmetics: null }); }); test("a member still waiting its turn when the cache is emptied is cached as usual", async () => { @@ -497,3 +514,194 @@ describe("what a prefetch leaves behind", () => { expect(again.badges).toEqual(["early"]); }); }); + +describe("what a member wears", () => { + const worn = [{ UnionKey: "WornItem", UnionIndex: 0, itemId: "frame-1" }]; + + test("a list row keeps it, because the row draws it and nothing else would fetch it", async () => { + const store = useProfileCacheStore(); + + const row = store.getStatus(SPACE, "u1"); + await settle(); + wire[0].answer([{ ...serverProfile("u1", "away"), cosmetics: worn }]); + + expect((await row).cosmetics).toEqual(worn); + await settle(); + + const cached = profileCache.peek(`${SPACE}:u1`); + expect(cached.scope).toBe("status"); + expect(cached.profile.cosmetics).toEqual(worn); + }); + + test("an equip rewrites it on every row held for that person and leaves the rest of each row alone", async () => { + const store = useProfileCacheStore(); + + const row = store.getStatus(SPACE, "u1"); + await settle(); + wire[0].answer([{ ...serverProfile("u1", "away"), cosmetics: [] }]); + await row; + await settle(); + + await store.updateCosmetics("u1", worn as never); + + expect(profileCache.peek(`${SPACE}:u1`).profile.cosmetics).toEqual(worn); + expect(profileCache.peek(`${SPACE}:u1`).profile.customStatus).toBe("away"); + expect((await store.getStatus(SPACE, "u1")).cosmetics).toEqual(worn); + expect(wire).toHaveLength(1); + }); + + test("a row that re-asks while the old look is still on the wire is told to ask once more", async () => { + const store = useProfileCacheStore(); + + const first = store.getStatus(SPACE, "u1"); + await settle(); + expect(wire).toHaveLength(1); + + await store.updateCosmetics("u1", worn as never); + const bumped = store.revisionOf("u1"); + + // The row re-asks on the bump and joins the call that was already carrying the old look. + const second = store.getStatus(SPACE, "u1"); + expect(wire).toHaveLength(1); + + wire[0].answer([{ ...serverProfile("u1"), cosmetics: [] }]); + await Promise.all([first, second]); + await settle(); + + expect(store.revisionOf("u1")).toBeGreaterThan(bumped); + + const third = store.getStatus(SPACE, "u1"); + await settle(); + expect(wire).toHaveLength(2); + + wire[1].answer([{ ...serverProfile("u1"), cosmetics: worn }]); + expect((await third).cosmetics).toEqual(worn); + }); + + test("a revision handed out before an account switch never comes round again", () => { + const store = useProfileCacheStore(); + + void store.updateCosmetics("u1", worn as never); + void store.updateCosmetics("u1", worn as never); + void store.updateCosmetics("u1", worn as never); + const before = store.revisionOf("u1"); + + switchAccount(); + + const seen = new Set(); + + for (let touch = 0; touch < 5; touch++) { + seen.add(store.revisionOf("u1")); + void store.updateCosmetics("u1", worn as never); + } + + expect(Math.min(...seen)).toBeGreaterThan(before); + }); + + test("callers get their own copy of the list", async () => { + const store = useProfileCacheStore(); + + const first = store.getProfile(SPACE, "u1"); + await settle(); + wire[0].answer([{ ...serverProfile("u1"), cosmetics: worn }]); + const held = await first; + await settle(); + + held.cosmetics?.push({ UnionKey: "WornItem", UnionIndex: 0, itemId: "other" } as never); + + expect((await store.getProfile(SPACE, "u1")).cosmetics).toEqual(worn); + }); +}); + +describe("a realtime profile update", () => { + const worn = [{ UnionKey: "WornItem", UnionIndex: 0, itemId: "frame-1" }]; + const connections = [{ provider: "github" }]; + + /** What UserProfileUpdated carries: the broadcast projection, archetypes empty and connections untold. */ + const broadcast = (userId: string) => ({ + ...serverProfile(userId, "updated"), + archetypes: [], + connections: null, + cosmetics: worn, + }) as unknown as ArgonUserProfile; + + test("keeps the archetypes and connections a full row already holds", async () => { + const store = useProfileCacheStore(); + + const card = store.getProfile(SPACE, "u1"); + await settle(); + wire[0].answer([{ ...serverProfile("u1"), connections }]); + await card; + await settle(); + + await store.updateProfile(SPACE, "u1", broadcast("u1")); + + const held = profileCache.peek(`${SPACE}:u1`).profile; + + expect(held.archetypes).toEqual([{ archetypeId: "role-1" }]); + expect(held.connections).toEqual(connections); + expect(held.customStatus).toBe("updated"); + expect(held.cosmetics).toEqual(worn); + expect((await store.getProfile(SPACE, "u1")).archetypes).toEqual([{ archetypeId: "role-1" }]); + }); + + test("never takes roles from it: its one sender does not fill them", async () => { + const store = useProfileCacheStore(); + + const card = store.getProfile(SPACE, "u1"); + await settle(); + answerAll(wire[0]); + await card; + await settle(); + + await store.updateProfile(SPACE, "u1", { ...broadcast("u1"), archetypes: [{ archetypeId: "role-2" }] } as never); + + expect(profileCache.peek(`${SPACE}:u1`).profile.archetypes).toEqual([{ archetypeId: "role-1" }]); + }); + + test("an update that does not say what is worn keeps the look a row already holds", async () => { + const store = useProfileCacheStore(); + + const card = store.getProfile(SPACE, "u1"); + const row = store.getStatus(SPACE, "u2"); + await settle(); + wire[0].answer([ + { ...serverProfile("u1"), cosmetics: worn }, + { ...serverProfile("u2"), cosmetics: worn }, + ]); + await Promise.all([card, row]); + await settle(); + + await store.updateProfile(SPACE, "u1", { ...broadcast("u1"), cosmetics: null } as never); + await store.updateProfile(SPACE, "u2", { ...broadcast("u2"), cosmetics: null } as never); + + expect(profileCache.peek(`${SPACE}:u1`).profile.cosmetics).toEqual(worn); + expect(profileCache.peek(`${SPACE}:u2`).profile.cosmetics).toEqual(worn); + expect(profileCache.peek(`${SPACE}:u2`).profile.customStatus).toBe("updated"); + }); + + test("puts only the new look on the account's own loaded profile", async () => { + useProfileCacheStore(); + ownAccount.meProfile = { userId: "me", bio: "mine", archetypes: [{ archetypeId: "role-1" }], connections, cosmetics: [] }; + + serverEvents.get("UserProfileUpdated")!({ spaceId: SPACE, userId: "me", profile: broadcast("me") }); + + expect(ownAccount.meProfile.cosmetics).toEqual(worn); + expect(ownAccount.meProfile.bio).toBe("mine"); + expect(ownAccount.meProfile.connections).toBe(connections); + expect(ownAccount.meProfile.archetypes).toEqual([{ archetypeId: "role-1" }]); + }); + + test("leaves the own profile alone for somebody else, or for an update that says nothing of the look", async () => { + useProfileCacheStore(); + + const mine = { userId: "me", cosmetics: [] }; + + ownAccount.meProfile = mine; + + serverEvents.get("UserProfileUpdated")!({ spaceId: SPACE, userId: "u1", profile: broadcast("u1") }); + serverEvents.get("UserProfileUpdated")!({ spaceId: SPACE, userId: "me", profile: { ...broadcast("me"), cosmetics: null } }); + + expect(ownAccount.meProfile).toBe(mine); + }); +});