openvibers/openvibe.live Commits
2fat's set in the catalog; a worn item the catalog lacks renders from its Inventory definition (#319)
Changes
@@@ -37,6 +37,8 @@ const COSMETICS = {// One of a kind (2026-10-22, the owner's gift to his friend Josh, "Josh Vegas" since they were kids), with its set// (px_vegas, hat_vegas, voice_vegas): granted to their one holder, never listed in the catalog nor earnable.fx_vegas: { name: 'Josh Vegas', emoji: '🎰', category: 'name_effect', tier: 6, cssClass: 'name-fx-vegas', desc: 'Slot reels and casino lights', exclusive: true },++ // 2fat's set (2026-10-23, the owner's ask; px_twofat, hat_twofat, voice_twofat): the old classic bangers, back.++ fx_twofat: { name: '2fat Bass Drop', emoji: '🔊', category: 'name_effect', tier: 6, cssClass: 'name-fx-twofat', desc: 'A bass drop and a dancing equalizer', exclusive: true },// ── Particle Effects ─────────────────────────────────px_sparkle: { name: 'Sparkle', emoji: '✨', category: 'particle', tier: 1, cssClass: 'px-sparkle', chars: '✦✧⋆', desc: 'Sparkle particles' },@@@ -45,6 +47,7 @@ const COSMETICS = {px_stars: { name: 'Stars', emoji: '⭐', category: 'particle', tier: 2, cssClass: 'px-stars', chars: '★☆✩', desc: 'Orbiting stars' },px_void: { name: 'Void', emoji: '🕳️', category: 'particle', tier: 3, cssClass: 'px-void', chars: '◉◎⊙', desc: 'Dark matter' },px_vegas: { name: 'Josh Vegas Chips', emoji: '🎲', category: 'particle', tier: 6, cssClass: 'px-vegas', chars: '♠♥♦♣🎲🪙💰', desc: 'Cards, dice and coins', exclusive: true },++ px_twofat: { name: '2fat Bangers', emoji: '💿', category: 'particle', tier: 6, cssClass: 'px-twofat', chars: '🎵🎶💿📼🔊', desc: 'Notes, a CD and a tape', exclusive: true },// ── Hats ─────────────────────────────────────────────hat_basic_cap: { name: 'Basic Cap', emoji: '🧢', category: 'hat', tier: 1, hatChar: '🧢', desc: 'Simple cap' },@@@ -54,6 +57,7 @@ const COSMETICS = {hat_halo: { name: 'Halo', emoji: '😇', category: 'hat', tier: 5, hatChar: '😇', animated: 'float', desc: 'Angelic' },hat_void_crown: { name: 'Void Crown', emoji: '🕳️', category: 'hat', tier: 6, hatChar: '🕳️', animated: 'warp', desc: 'From the dungeon depths' },hat_vegas: { name: 'Josh Vegas Top Hat', emoji: '🎩', category: 'hat', tier: 6, hatChar: '🎩', animated: 'float', desc: 'The showman on the Strip', exclusive: true },++ hat_twofat: { name: '2fat Boombox', emoji: '📻', category: 'hat', tier: 6, hatChar: '📻', animated: 'pulse', desc: 'The old classic bangers, back on the air', exclusive: true },// ── Voices (TTS style — cosmetic, actual TTS is client-side SpeechSynthesis) ──voice_default: { name: 'Default Voice', emoji: '🔊', category: 'voice', tier: 0, desc: 'Standard TTS' },@@@ -149,6 +153,7 @@ const COSMETICS = {pl_matthew_gen: { name: 'Gen Matt', emoji: '🌟', category: 'voice', tier: 6, pitch: 1.0, rate: 1.0, desc: 'Amazon Polly Generative' },pl_ruth_gen: { name: 'Gen Ruth', emoji: '🌟', category: 'voice', tier: 6, pitch: 1.0, rate: 1.0, desc: 'Amazon Polly Generative' },voice_vegas: { name: 'Vegas Announcer', emoji: '🎙️', category: 'voice', tier: 6, pitch: 0.7, rate: 0.92, desc: 'Ladies and gentlemen…', exclusive: true },++ voice_twofat: { name: 'Bass Boss', emoji: '🎛️', category: 'voice', tier: 6, pitch: 0.6, rate: 1.0, desc: 'Deep enough to rattle the subwoofer', exclusive: true },};// Category → slot name mapping@@@ -177,33 +182,68 @@ async function getEquipped(userId) {}, {});}--/** What a chat line carries for one { slot → item id } set: { nameFX, particleFX, hatFX, badgeFX, voiceFX } (each optional). */--function profileOf(equipped) {-- const result = {};-- if (equipped.name_effect && COSMETICS[equipped.name_effect]) {-- const c = COSMETICS[equipped.name_effect];-- result.nameFX = { itemId: equipped.name_effect, cssClass: c.cssClass };-- }-- if (equipped.particle && COSMETICS[equipped.particle]) {-- const c = COSMETICS[equipped.particle];-- result.particleFX = { itemId: equipped.particle, cssClass: c.cssClass, chars: c.chars };-- }-- if (equipped.hat && COSMETICS[equipped.hat]) {-- const c = COSMETICS[equipped.hat];-- result.hatFX = { itemId: equipped.hat, hatChar: c.hatChar, cssClass: c.hatChar, animated: c.animated };++/**++ * A renderer for an item Live's catalog does not list but Inventory defines (a one-of-a-kind set made for one person,++ * say), read from the definition's attributes and checked as strictly as Inventory's own kinds: a name effect or++ * particle class Chat and the stylesheets accept, at most 16 particle characters, a hat of at most 8 with a known++ * motion, a voice's pitch and rate in range. So a new item shows in chat as soon as Inventory has it and the++ * stylesheets draw its class, without waiting for a Live release (2fat's set waited for one, 2026-10-23).++ */++const KIND_SLOT = { 'live.name_effect': 'name_effect', 'live.particle': 'particle', 'live.hat': 'hat', 'live.voice': 'voice' };++function fromDefinition(d) {++ const a = d && d.attributes;++ if (!a || typeof a !== 'object') return null;++ const num = (v, lo, hi) => (typeof v === 'number' && v >= lo && v <= hi ? v : null);++ switch (KIND_SLOT[d.kind]) {++ case 'name_effect':++ return /^name-fx-[a-z]{2,24}$/.test(String(a.css_class || '')) ? { category: 'name_effect', cssClass: a.css_class } : null;++ case 'particle':++ return /^px-[a-z]{2,24}$/.test(String(a.css_class || '')) && typeof a.chars === 'string' && a.chars && a.chars.length <= 16++ ? { category: 'particle', cssClass: a.css_class, chars: a.chars } : null;++ case 'hat':++ return typeof a.hat_char === 'string' && a.hat_char && a.hat_char.length <= 8 && (a.animated == null || ['float', 'pulse', 'warp'].includes(a.animated))++ ? { category: 'hat', hatChar: a.hat_char, animated: a.animated || null } : null;++ case 'voice': {++ const pitch = num(a.pitch, 0, 2);++ const rate = num(a.rate, 0.1, 3);++ return pitch != null && rate != null ? { category: 'voice', pitch, rate } : null;++ }++ default: return null;}++}++++/**++ * What a chat line carries for one { slot → item id } set: { nameFX, particleFX, hatFX, badgeFX, voiceFX } (each++ * optional). `defs` (Inventory's definitions by alias) renders an item Live's catalog does not list (fromDefinition).++ */++function profileOf(equipped, defs = null) {++ const item = (slot) => {++ const alias = equipped[slot];++ if (!alias) return null;++ if (COSMETICS[alias]) return COSMETICS[alias];++ const c = fromDefinition(defs && defs.get(alias));++ return c && c.category === slot ? c : null;++ };++ const result = {};++ const fx = item('name_effect');++ if (fx) result.nameFX = { itemId: equipped.name_effect, cssClass: fx.cssClass };++ const px = item('particle');++ if (px) result.particleFX = { itemId: equipped.particle, cssClass: px.cssClass, chars: px.chars };++ const hat = item('hat');++ if (hat) result.hatFX = { itemId: equipped.hat, hatChar: hat.hatChar, cssClass: hat.hatChar, animated: hat.animated };// A community badge from the Workshop (network.badge): its reviewed image on OpenVibe.Media, before the name.if (/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(equipped._badge || ''))) result.badgeFX = { mediaId: equipped._badge };-- if (equipped.voice && COSMETICS[equipped.voice]) {-- const c = COSMETICS[equipped.voice];-- result.voiceFX = { itemId: equipped.voice, pitch: c.pitch, rate: c.rate };-- }++ const voice = item('voice');++ if (voice) result.voiceFX = { itemId: equipped.voice, pitch: voice.pitch, rate: voice.rate };return result;}++/** Inventory's definitions by alias (cached by the client), or null when Inventory cannot be read. */++const definitions = async () => await quietly(async () => (await inventory.catalog()).byAlias, null);++// ── Get full cosmetic profile (for chat messages) ────────────async function getCosmeticProfile(userId) {-- return profileOf(await getEquipped(userId));++ return profileOf(await getEquipped(userId), await definitions());}/**@@@ -217,7 +257,8 @@ async function getCosmeticProfiles(userIds) {const subjects = new Map();for (const id of userIds) { const s = await inventory.subjectOf(id); if (s) subjects.set(id, s); }const sets = await inventory.equippedMany([...new Set(subjects.values())]);-- for (const [id, s] of subjects) out[id] = profileOf(sets.get(s) || {});++ const defs = await definitions();++ for (const [id, s] of subjects) out[id] = profileOf(sets.get(s) || {}, defs);}, null);return out;}@@@ -319,6 +360,7 @@ async function getFullInventory(userId) {module.exports = {COSMETICS,++ profileOf,getUnlocked,getEquipped,getCosmeticProfile,@@@ -35,7 +35,7 @@ const cosmetics = require('../server/monetization/cosmetics');const { catalog } = await res.json();const listed = Object.values(catalog).flat().map((c) => c.itemId);assert.ok(listed.includes('fx_galaxy'), 'the catalog still lists the others');-- for (const id of ['fx_vegas', 'px_vegas', 'hat_vegas', 'voice_vegas']) assert.ok(!listed.includes(id), `${id}, one of a kind, is never listed`);++ for (const id of ['fx_vegas', 'px_vegas', 'hat_vegas', 'voice_vegas', 'fx_twofat', 'px_twofat', 'hat_twofat', 'voice_twofat']) assert.ok(!listed.includes(id), `${id}, one of a kind, is never listed`);} finally {server.close();}@@@ -45,6 +45,17 @@ const cosmetics = require('../server/monetization/cosmetics');assert.ok(css.includes('.name-fx-vegas {') && css.includes('@keyframes nameFxVegasReel') && css.includes('@keyframes nameFxVegasJackpot'), `${file} draws the effect`);assert.ok(css.includes('.px-vegas .chat-particle'), `${file} colours the Vegas particles`);assert.ok(css.includes('prefers-reduced-motion: reduce') && /\.name-fx-vegas::before, \.name-fx-vegas::after \{ animation: none/.test(css), `${file} stops the reels for reduced motion`);++ // 2fat's set (2026-10-23): the bass drop, its equalizer and its particles, stopped for reduced motion too.++ assert.ok(css.includes('.name-fx-twofat {') && css.includes('@keyframes nameFxTwofatThump') && css.includes('@keyframes nameFxTwofatEq'), `${file} draws 2fat's effect`);++ assert.ok(css.includes('.px-twofat .chat-particle'), `${file} colours 2fat's particles`);++ assert.ok(/\.name-fx-twofat, \.name-fx-twofat::before, \.name-fx-twofat::after \{ animation: none/.test(css), `${file} stops 2fat's effect for reduced motion`);}++ // 2fat's set is one of a kind like Vegas, and fits Inventory's kinds.++ const T = cosmetics.COSMETICS;++ for (const [id, category] of Object.entries({ fx_twofat: 'name_effect', px_twofat: 'particle', hat_twofat: 'hat', voice_twofat: 'voice' })) {++ assert.ok(T[id] && T[id].exclusive === true && T[id].category === category, `${id} is a one-of-a-kind ${category}`);++ }++ assert.match(T.fx_twofat.cssClass, /^name-fx-[a-z]{2,24}$/);++ assert.ok(T.px_twofat.chars.length <= 16 && /^px-[a-z]+$/.test(T.px_twofat.cssClass));console.log('cosmetics exclusive: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++// A worn item Live's catalog does not list renders from its Inventory definition (server/monetization/cosmetics.js++// fromDefinition, 2026-10-23): a one-of-a-kind set made for one person shows in chat as soon as Inventory has it and++// the stylesheets draw its class, without a Live release. The definition is checked as strictly as Inventory's kinds,++// and an item worn in the wrong slot, or one Inventory does not define, still shows nothing.++const assert = require('assert');++++console.warn = () => {};++const { profileOf, COSMETICS } = require('../server/monetization/cosmetics');++++const def = (kind, attributes) => ({ kind, attributes });++const defs = new Map([++ ['fx_newbie', def('live.name_effect', { tier: 6, css_class: 'name-fx-newbie' })],++ ['px_newbie', def('live.particle', { tier: 6, css_class: 'px-newbie', chars: '★☆' })],++ ['hat_newbie', def('live.hat', { tier: 6, hat_char: '👑', animated: 'warp' })],++ ['voice_newbie', def('live.voice', { tier: 6, pitch: 1.4, rate: 1.1 })],++ // Each one fails one rule.++ ['fx_bad', def('live.name_effect', { css_class: 'name-fx-2fat" onmouseover="x' })],++ ['px_long', def('live.particle', { css_class: 'px-long', chars: '★'.repeat(17) })],++ ['hat_spin', def('live.hat', { hat_char: '👑', animated: 'spin' })],++ ['voice_loud', def('live.voice', { pitch: 5, rate: 1 })],++]);++++assert.ok(!COSMETICS.fx_newbie, 'the fixture items are not in the catalog');++++// Not in the catalog, defined by Inventory: rendered from the definition.++assert.deepStrictEqual(profileOf({ name_effect: 'fx_newbie', particle: 'px_newbie', hat: 'hat_newbie', voice: 'voice_newbie' }, defs), {++ nameFX: { itemId: 'fx_newbie', cssClass: 'name-fx-newbie' },++ particleFX: { itemId: 'px_newbie', cssClass: 'px-newbie', chars: '★☆' },++ hatFX: { itemId: 'hat_newbie', hatChar: '👑', cssClass: '👑', animated: 'warp' },++ voiceFX: { itemId: 'voice_newbie', pitch: 1.4, rate: 1.1 },++});++++// A definition that breaks a rule renders nothing for that slot.++assert.deepStrictEqual(profileOf({ name_effect: 'fx_bad', particle: 'px_long', hat: 'hat_spin', voice: 'voice_loud' }, defs), {});++++// Worn in the wrong slot, unknown to Inventory, or Inventory unreadable (null): nothing.++assert.deepStrictEqual(profileOf({ name_effect: 'px_newbie', hat: 'fx_newbie' }, defs), {});++assert.deepStrictEqual(profileOf({ name_effect: 'fx_nobody' }, defs), {});++assert.deepStrictEqual(profileOf({ name_effect: 'fx_newbie' }, null), {});++++// The catalog's own entries are unchanged, with or without definitions.++assert.deepStrictEqual(profileOf({ name_effect: 'fx_vegas' }, null), { nameFX: { itemId: 'fx_vegas', cssClass: 'name-fx-vegas' } });++assert.deepStrictEqual(profileOf({ name_effect: 'fx_twofat', hat: 'hat_twofat' }, defs).hatFX, { itemId: 'hat_twofat', hatChar: '📻', cssClass: '📻', animated: 'pulse' });++++console.log('cosmetics fallback: all checks passed');
@@ -37,6 +37,8 @@ const COSMETICS = {
@@ -37,6 +37,8 @@ const COSMETICS = {
// One of a kind (2026-10-22, the owner's gift to his friend Josh, "Josh Vegas" since they were kids), with its set
// One of a kind (2026-10-22, the owner's gift to his friend Josh, "Josh Vegas" since they were kids), with its set
// (px_vegas, hat_vegas, voice_vegas): granted to their one holder, never listed in the catalog nor earnable.
// (px_vegas, hat_vegas, voice_vegas): granted to their one holder, never listed in the catalog nor earnable.
fx_vegas: { name: 'Josh Vegas', emoji: '🎰', category: 'name_effect', tier: 6, cssClass: 'name-fx-vegas', desc: 'Slot reels and casino lights', exclusive: true },
fx_vegas: { name: 'Josh Vegas', emoji: '🎰', category: 'name_effect', tier: 6, cssClass: 'name-fx-vegas', desc: 'Slot reels and casino lights', exclusive: true },
+ // 2fat's set (2026-10-23, the owner's ask; px_twofat, hat_twofat, voice_twofat): the old classic bangers, back.
+ fx_twofat: { name: '2fat Bass Drop', emoji: '🔊', category: 'name_effect', tier: 6, cssClass: 'name-fx-twofat', desc: 'A bass drop and a dancing equalizer', exclusive: true },
// ── Particle Effects ─────────────────────────────────
// ── Particle Effects ─────────────────────────────────
px_sparkle: { name: 'Sparkle', emoji: '✨', category: 'particle', tier: 1, cssClass: 'px-sparkle', chars: '✦✧⋆', desc: 'Sparkle particles' },
px_sparkle: { name: 'Sparkle', emoji: '✨', category: 'particle', tier: 1, cssClass: 'px-sparkle', chars: '✦✧⋆', desc: 'Sparkle particles' },
@@ -45,6 +47,7 @@ const COSMETICS = {
@@ -45,6 +47,7 @@ const COSMETICS = {
px_stars: { name: 'Stars', emoji: '⭐', category: 'particle', tier: 2, cssClass: 'px-stars', chars: '★☆✩', desc: 'Orbiting stars' },
px_stars: { name: 'Stars', emoji: '⭐', category: 'particle', tier: 2, cssClass: 'px-stars', chars: '★☆✩', desc: 'Orbiting stars' },
px_void: { name: 'Void', emoji: '🕳️', category: 'particle', tier: 3, cssClass: 'px-void', chars: '◉◎⊙', desc: 'Dark matter' },
px_void: { name: 'Void', emoji: '🕳️', category: 'particle', tier: 3, cssClass: 'px-void', chars: '◉◎⊙', desc: 'Dark matter' },
px_vegas: { name: 'Josh Vegas Chips', emoji: '🎲', category: 'particle', tier: 6, cssClass: 'px-vegas', chars: '♠♥♦♣🎲🪙💰', desc: 'Cards, dice and coins', exclusive: true },
px_vegas: { name: 'Josh Vegas Chips', emoji: '🎲', category: 'particle', tier: 6, cssClass: 'px-vegas', chars: '♠♥♦♣🎲🪙💰', desc: 'Cards, dice and coins', exclusive: true },
+ px_twofat: { name: '2fat Bangers', emoji: '💿', category: 'particle', tier: 6, cssClass: 'px-twofat', chars: '🎵🎶💿📼🔊', desc: 'Notes, a CD and a tape', exclusive: true },
// ── Hats ─────────────────────────────────────────────
// ── Hats ─────────────────────────────────────────────
hat_basic_cap: { name: 'Basic Cap', emoji: '🧢', category: 'hat', tier: 1, hatChar: '🧢', desc: 'Simple cap' },
hat_basic_cap: { name: 'Basic Cap', emoji: '🧢', category: 'hat', tier: 1, hatChar: '🧢', desc: 'Simple cap' },
@@ -54,6 +57,7 @@ const COSMETICS = {
@@ -54,6 +57,7 @@ const COSMETICS = {
hat_halo: { name: 'Halo', emoji: '😇', category: 'hat', tier: 5, hatChar: '😇', animated: 'float', desc: 'Angelic' },
hat_halo: { name: 'Halo', emoji: '😇', category: 'hat', tier: 5, hatChar: '😇', animated: 'float', desc: 'Angelic' },
hat_void_crown: { name: 'Void Crown', emoji: '🕳️', category: 'hat', tier: 6, hatChar: '🕳️', animated: 'warp', desc: 'From the dungeon depths' },
hat_void_crown: { name: 'Void Crown', emoji: '🕳️', category: 'hat', tier: 6, hatChar: '🕳️', animated: 'warp', desc: 'From the dungeon depths' },
hat_vegas: { name: 'Josh Vegas Top Hat', emoji: '🎩', category: 'hat', tier: 6, hatChar: '🎩', animated: 'float', desc: 'The showman on the Strip', exclusive: true },
hat_vegas: { name: 'Josh Vegas Top Hat', emoji: '🎩', category: 'hat', tier: 6, hatChar: '🎩', animated: 'float', desc: 'The showman on the Strip', exclusive: true },
+ hat_twofat: { name: '2fat Boombox', emoji: '📻', category: 'hat', tier: 6, hatChar: '📻', animated: 'pulse', desc: 'The old classic bangers, back on the air', exclusive: true },
// ── Voices (TTS style — cosmetic, actual TTS is client-side SpeechSynthesis) ──
// ── Voices (TTS style — cosmetic, actual TTS is client-side SpeechSynthesis) ──
voice_default: { name: 'Default Voice', emoji: '🔊', category: 'voice', tier: 0, desc: 'Standard TTS' },
voice_default: { name: 'Default Voice', emoji: '🔊', category: 'voice', tier: 0, desc: 'Standard TTS' },
@@ -149,6 +153,7 @@ const COSMETICS = {
@@ -149,6 +153,7 @@ const COSMETICS = {
pl_matthew_gen: { name: 'Gen Matt', emoji: '🌟', category: 'voice', tier: 6, pitch: 1.0, rate: 1.0, desc: 'Amazon Polly Generative' },
pl_matthew_gen: { name: 'Gen Matt', emoji: '🌟', category: 'voice', tier: 6, pitch: 1.0, rate: 1.0, desc: 'Amazon Polly Generative' },
pl_ruth_gen: { name: 'Gen Ruth', emoji: '🌟', category: 'voice', tier: 6, pitch: 1.0, rate: 1.0, desc: 'Amazon Polly Generative' },
pl_ruth_gen: { name: 'Gen Ruth', emoji: '🌟', category: 'voice', tier: 6, pitch: 1.0, rate: 1.0, desc: 'Amazon Polly Generative' },
voice_vegas: { name: 'Vegas Announcer', emoji: '🎙️', category: 'voice', tier: 6, pitch: 0.7, rate: 0.92, desc: 'Ladies and gentlemen…', exclusive: true },
voice_vegas: { name: 'Vegas Announcer', emoji: '🎙️', category: 'voice', tier: 6, pitch: 0.7, rate: 0.92, desc: 'Ladies and gentlemen…', exclusive: true },
+ voice_twofat: { name: 'Bass Boss', emoji: '🎛️', category: 'voice', tier: 6, pitch: 0.6, rate: 1.0, desc: 'Deep enough to rattle the subwoofer', exclusive: true },
};
};
// Category → slot name mapping
// Category → slot name mapping
@@ -177,33 +182,68 @@ async function getEquipped(userId) {
@@ -177,33 +182,68 @@ async function getEquipped(userId) {
}, {});
}, {});
}
}
-/** What a chat line carries for one { slot → item id } set: { nameFX, particleFX, hatFX, badgeFX, voiceFX } (each optional). */
-function profileOf(equipped) {
- const result = {};
- if (equipped.name_effect && COSMETICS[equipped.name_effect]) {
- const c = COSMETICS[equipped.name_effect];
- result.nameFX = { itemId: equipped.name_effect, cssClass: c.cssClass };
- }
- if (equipped.particle && COSMETICS[equipped.particle]) {
- const c = COSMETICS[equipped.particle];
- result.particleFX = { itemId: equipped.particle, cssClass: c.cssClass, chars: c.chars };
- }
- if (equipped.hat && COSMETICS[equipped.hat]) {
- const c = COSMETICS[equipped.hat];
- result.hatFX = { itemId: equipped.hat, hatChar: c.hatChar, cssClass: c.hatChar, animated: c.animated };
+/**
+ * A renderer for an item Live's catalog does not list but Inventory defines (a one-of-a-kind set made for one person,
+ * say), read from the definition's attributes and checked as strictly as Inventory's own kinds: a name effect or
+ * particle class Chat and the stylesheets accept, at most 16 particle characters, a hat of at most 8 with a known
+ * motion, a voice's pitch and rate in range. So a new item shows in chat as soon as Inventory has it and the
+ * stylesheets draw its class, without waiting for a Live release (2fat's set waited for one, 2026-10-23).
+ */
+const KIND_SLOT = { 'live.name_effect': 'name_effect', 'live.particle': 'particle', 'live.hat': 'hat', 'live.voice': 'voice' };
+function fromDefinition(d) {
+ const a = d && d.attributes;
+ if (!a || typeof a !== 'object') return null;
+ const num = (v, lo, hi) => (typeof v === 'number' && v >= lo && v <= hi ? v : null);
+ switch (KIND_SLOT[d.kind]) {
+ case 'name_effect':
+ return /^name-fx-[a-z]{2,24}$/.test(String(a.css_class || '')) ? { category: 'name_effect', cssClass: a.css_class } : null;
+ case 'particle':
+ return /^px-[a-z]{2,24}$/.test(String(a.css_class || '')) && typeof a.chars === 'string' && a.chars && a.chars.length <= 16
+ ? { category: 'particle', cssClass: a.css_class, chars: a.chars } : null;
+ case 'hat':
+ return typeof a.hat_char === 'string' && a.hat_char && a.hat_char.length <= 8 && (a.animated == null || ['float', 'pulse', 'warp'].includes(a.animated))
+ ? { category: 'hat', hatChar: a.hat_char, animated: a.animated || null } : null;
+ case 'voice': {
+ const pitch = num(a.pitch, 0, 2);
+ const rate = num(a.rate, 0.1, 3);
+ return pitch != null && rate != null ? { category: 'voice', pitch, rate } : null;
+ }
+ default: return null;
}
}
+}
+
+/**
+ * What a chat line carries for one { slot → item id } set: { nameFX, particleFX, hatFX, badgeFX, voiceFX } (each
+ * optional). `defs` (Inventory's definitions by alias) renders an item Live's catalog does not list (fromDefinition).
+ */
+function profileOf(equipped, defs = null) {
+ const item = (slot) => {
+ const alias = equipped[slot];
+ if (!alias) return null;
+ if (COSMETICS[alias]) return COSMETICS[alias];
+ const c = fromDefinition(defs && defs.get(alias));
+ return c && c.category === slot ? c : null;
+ };
+ const result = {};
+ const fx = item('name_effect');
+ if (fx) result.nameFX = { itemId: equipped.name_effect, cssClass: fx.cssClass };
+ const px = item('particle');
+ if (px) result.particleFX = { itemId: equipped.particle, cssClass: px.cssClass, chars: px.chars };
+ const hat = item('hat');
+ if (hat) result.hatFX = { itemId: equipped.hat, hatChar: hat.hatChar, cssClass: hat.hatChar, animated: hat.animated };
// A community badge from the Workshop (network.badge): its reviewed image on OpenVibe.Media, before the name.
// A community badge from the Workshop (network.badge): its reviewed image on OpenVibe.Media, before the name.
if (/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(equipped._badge || ''))) result.badgeFX = { mediaId: equipped._badge };
if (/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(equipped._badge || ''))) result.badgeFX = { mediaId: equipped._badge };
- if (equipped.voice && COSMETICS[equipped.voice]) {
- const c = COSMETICS[equipped.voice];
- result.voiceFX = { itemId: equipped.voice, pitch: c.pitch, rate: c.rate };
- }
+ const voice = item('voice');
+ if (voice) result.voiceFX = { itemId: equipped.voice, pitch: voice.pitch, rate: voice.rate };
return result;
return result;
}
}
+/** Inventory's definitions by alias (cached by the client), or null when Inventory cannot be read. */
+const definitions = async () => await quietly(async () => (await inventory.catalog()).byAlias, null);
+
// ── Get full cosmetic profile (for chat messages) ────────────
// ── Get full cosmetic profile (for chat messages) ────────────
async function getCosmeticProfile(userId) {
async function getCosmeticProfile(userId) {
- return profileOf(await getEquipped(userId));
+ return profileOf(await getEquipped(userId), await definitions());
}
}
/**
/**
@@ -217,7 +257,8 @@ async function getCosmeticProfiles(userIds) {
@@ -217,7 +257,8 @@ async function getCosmeticProfiles(userIds) {
const subjects = new Map();
const subjects = new Map();
for (const id of userIds) { const s = await inventory.subjectOf(id); if (s) subjects.set(id, s); }
for (const id of userIds) { const s = await inventory.subjectOf(id); if (s) subjects.set(id, s); }
const sets = await inventory.equippedMany([...new Set(subjects.values())]);
const sets = await inventory.equippedMany([...new Set(subjects.values())]);
- for (const [id, s] of subjects) out[id] = profileOf(sets.get(s) || {});
+ const defs = await definitions();
+ for (const [id, s] of subjects) out[id] = profileOf(sets.get(s) || {}, defs);
}, null);
}, null);
return out;
return out;
}
}
@@ -319,6 +360,7 @@ async function getFullInventory(userId) {
@@ -319,6 +360,7 @@ async function getFullInventory(userId) {
module.exports = {
module.exports = {
COSMETICS,
COSMETICS,
+ profileOf,
getUnlocked,
getUnlocked,
getEquipped,
getEquipped,
getCosmeticProfile,
getCosmeticProfile,
@@ -35,7 +35,7 @@ const cosmetics = require('../server/monetization/cosmetics');
@@ -35,7 +35,7 @@ const cosmetics = require('../server/monetization/cosmetics');
const { catalog } = await res.json();
const { catalog } = await res.json();
const listed = Object.values(catalog).flat().map((c) => c.itemId);
const listed = Object.values(catalog).flat().map((c) => c.itemId);
assert.ok(listed.includes('fx_galaxy'), 'the catalog still lists the others');
assert.ok(listed.includes('fx_galaxy'), 'the catalog still lists the others');
- for (const id of ['fx_vegas', 'px_vegas', 'hat_vegas', 'voice_vegas']) assert.ok(!listed.includes(id), `${id}, one of a kind, is never listed`);
+ for (const id of ['fx_vegas', 'px_vegas', 'hat_vegas', 'voice_vegas', 'fx_twofat', 'px_twofat', 'hat_twofat', 'voice_twofat']) assert.ok(!listed.includes(id), `${id}, one of a kind, is never listed`);
} finally {
} finally {
server.close();
server.close();
}
}
@@ -45,6 +45,17 @@ const cosmetics = require('../server/monetization/cosmetics');
@@ -45,6 +45,17 @@ const cosmetics = require('../server/monetization/cosmetics');
assert.ok(css.includes('.name-fx-vegas {') && css.includes('@keyframes nameFxVegasReel') && css.includes('@keyframes nameFxVegasJackpot'), `${file} draws the effect`);
assert.ok(css.includes('.name-fx-vegas {') && css.includes('@keyframes nameFxVegasReel') && css.includes('@keyframes nameFxVegasJackpot'), `${file} draws the effect`);
assert.ok(css.includes('.px-vegas .chat-particle'), `${file} colours the Vegas particles`);
assert.ok(css.includes('.px-vegas .chat-particle'), `${file} colours the Vegas particles`);
assert.ok(css.includes('prefers-reduced-motion: reduce') && /\.name-fx-vegas::before, \.name-fx-vegas::after \{ animation: none/.test(css), `${file} stops the reels for reduced motion`);
assert.ok(css.includes('prefers-reduced-motion: reduce') && /\.name-fx-vegas::before, \.name-fx-vegas::after \{ animation: none/.test(css), `${file} stops the reels for reduced motion`);
+ // 2fat's set (2026-10-23): the bass drop, its equalizer and its particles, stopped for reduced motion too.
+ assert.ok(css.includes('.name-fx-twofat {') && css.includes('@keyframes nameFxTwofatThump') && css.includes('@keyframes nameFxTwofatEq'), `${file} draws 2fat's effect`);
+ assert.ok(css.includes('.px-twofat .chat-particle'), `${file} colours 2fat's particles`);
+ assert.ok(/\.name-fx-twofat, \.name-fx-twofat::before, \.name-fx-twofat::after \{ animation: none/.test(css), `${file} stops 2fat's effect for reduced motion`);
}
}
+ // 2fat's set is one of a kind like Vegas, and fits Inventory's kinds.
+ const T = cosmetics.COSMETICS;
+ for (const [id, category] of Object.entries({ fx_twofat: 'name_effect', px_twofat: 'particle', hat_twofat: 'hat', voice_twofat: 'voice' })) {
+ assert.ok(T[id] && T[id].exclusive === true && T[id].category === category, `${id} is a one-of-a-kind ${category}`);
+ }
+ assert.match(T.fx_twofat.cssClass, /^name-fx-[a-z]{2,24}$/);
+ assert.ok(T.px_twofat.chars.length <= 16 && /^px-[a-z]+$/.test(T.px_twofat.cssClass));
console.log('cosmetics exclusive: all checks passed');
console.log('cosmetics exclusive: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+// A worn item Live's catalog does not list renders from its Inventory definition (server/monetization/cosmetics.js
+// fromDefinition, 2026-10-23): a one-of-a-kind set made for one person shows in chat as soon as Inventory has it and
+// the stylesheets draw its class, without a Live release. The definition is checked as strictly as Inventory's kinds,
+// and an item worn in the wrong slot, or one Inventory does not define, still shows nothing.
+const assert = require('assert');
+
+console.warn = () => {};
+const { profileOf, COSMETICS } = require('../server/monetization/cosmetics');
+
+const def = (kind, attributes) => ({ kind, attributes });
+const defs = new Map([
+ ['fx_newbie', def('live.name_effect', { tier: 6, css_class: 'name-fx-newbie' })],
+ ['px_newbie', def('live.particle', { tier: 6, css_class: 'px-newbie', chars: '★☆' })],
+ ['hat_newbie', def('live.hat', { tier: 6, hat_char: '👑', animated: 'warp' })],
+ ['voice_newbie', def('live.voice', { tier: 6, pitch: 1.4, rate: 1.1 })],
+ // Each one fails one rule.
+ ['fx_bad', def('live.name_effect', { css_class: 'name-fx-2fat" onmouseover="x' })],
+ ['px_long', def('live.particle', { css_class: 'px-long', chars: '★'.repeat(17) })],
+ ['hat_spin', def('live.hat', { hat_char: '👑', animated: 'spin' })],
+ ['voice_loud', def('live.voice', { pitch: 5, rate: 1 })],
+]);
+
+assert.ok(!COSMETICS.fx_newbie, 'the fixture items are not in the catalog');
+
+// Not in the catalog, defined by Inventory: rendered from the definition.
+assert.deepStrictEqual(profileOf({ name_effect: 'fx_newbie', particle: 'px_newbie', hat: 'hat_newbie', voice: 'voice_newbie' }, defs), {
+ nameFX: { itemId: 'fx_newbie', cssClass: 'name-fx-newbie' },
+ particleFX: { itemId: 'px_newbie', cssClass: 'px-newbie', chars: '★☆' },
+ hatFX: { itemId: 'hat_newbie', hatChar: '👑', cssClass: '👑', animated: 'warp' },
+ voiceFX: { itemId: 'voice_newbie', pitch: 1.4, rate: 1.1 },
+});
+
+// A definition that breaks a rule renders nothing for that slot.
+assert.deepStrictEqual(profileOf({ name_effect: 'fx_bad', particle: 'px_long', hat: 'hat_spin', voice: 'voice_loud' }, defs), {});
+
+// Worn in the wrong slot, unknown to Inventory, or Inventory unreadable (null): nothing.
+assert.deepStrictEqual(profileOf({ name_effect: 'px_newbie', hat: 'fx_newbie' }, defs), {});
+assert.deepStrictEqual(profileOf({ name_effect: 'fx_nobody' }, defs), {});
+assert.deepStrictEqual(profileOf({ name_effect: 'fx_newbie' }, null), {});
+
+// The catalog's own entries are unchanged, with or without definitions.
+assert.deepStrictEqual(profileOf({ name_effect: 'fx_vegas' }, null), { nameFX: { itemId: 'fx_vegas', cssClass: 'name-fx-vegas' } });
+assert.deepStrictEqual(profileOf({ name_effect: 'fx_twofat', hat: 'hat_twofat' }, defs).hatFX, { itemId: 'hat_twofat', hatChar: '📻', cssClass: '📻', animated: 'pulse' });
+
+console.log('cosmetics fallback: all checks passed');