openvibers/openvibe.live Commits

Josh Vegas (fx_vegas): a one-of-a-kind name effect, slot reels around the name under casino lights (#313)

OP OpenVibers committed 22 days ago · 1c7c38a12a · 5 files, +167 −0

Changes

ddiff --git a/public/css/cosmetics.css b/public/css/cosmetics.css
iindex bb2fc6a9..8b2d6419 100644
---- a/public/css/cosmetics.css
++++ b/public/css/cosmetics.css
@@@ -175,6 +175,68 @@
50% { background-position: 300% 50%; }
}
++/* Josh Vegas: one of a kind (2026-10-22). The name in casino gold under marquee lights, between two slot reels that
++ spin through cherries, bells, diamonds, slot machines and lemons and land on 7. Only its one holder wears it (fx_vegas is
++ never in the catalog). The reels are pseudo-elements, so they carry their own fill, pause and reduced-motion rules. */
++.name-fx-vegas {
++ background: linear-gradient(90deg, #fff1b8 0%, #ffcf3f 22%, #ff9d00 40%, #ff3d7f 52%, #ffcf3f 66%, #fff1b8 82%, #ffcf3f 100%);
++ background-size: 280% 100%;
++ -webkit-background-clip: text;
++ background-clip: text;
++ -webkit-text-fill-color: transparent;
++ font-weight: 800;
++ letter-spacing: .02em;
++ white-space: nowrap;
++ animation: nameFxVegasShine 2.6s linear infinite, nameFxVegasLights 1.4s steps(1, end) infinite;
++}
++.name-fx-vegas::before,
++.name-fx-vegas::after {
++ content: '7';
++ display: inline-block;
++ box-sizing: border-box;
++ width: 2em;
++ margin-right: .4em;
++ border: 1px solid #ffcf3f;
++ border-radius: 3px;
++ background: linear-gradient(180deg, #3d0512 0%, #8f0f2c 48%, #3d0512 100%);
++ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .45), 0 0 5px rgba(255, 207, 63, .6);
++ color: #ffe27a;
++ -webkit-text-fill-color: #ffe27a;
++ font-size: .72em;
++ font-weight: 900;
++ letter-spacing: 0;
++ line-height: 1.35;
++ text-align: center;
++ overflow: hidden;
++ vertical-align: .1em;
++ animation: nameFxVegasReel 3.2s steps(1, end) infinite;
++}
++.name-fx-vegas::after { margin: 0 0 0 .4em; animation-delay: .35s; }
++@keyframes nameFxVegasShine {
++ 0% { background-position: 0% 50%; }
++ 100% { background-position: 280% 50%; }
++}
++/* Marquee bulbs: the glow steps between gold and pink, like the lights around a casino sign. */
++@keyframes nameFxVegasLights {
++ 0% { filter: drop-shadow(0 0 3px rgba(255, 207, 63, .9)) drop-shadow(0 0 9px rgba(255, 157, 0, .55)); }
++ 50% { filter: drop-shadow(0 0 3px rgba(255, 61, 127, .9)) drop-shadow(0 0 9px rgba(255, 61, 127, .5)); }
++}
++/* Each reel spins through the symbols, then lands on 7 and holds it (a browser that cannot animate `content` shows 7). */
++@keyframes nameFxVegasReel {
++ 0% { content: '🍒'; }
++ 7% { content: '🔔'; }
++ 14% { content: '💎'; }
++ 21% { content: '🎰'; }
++ 28% { content: '🍋'; }
++ 35%, 100% { content: '7'; }
++}
++.chat-msg.has-fx:not(.fx-on) .name-fx-vegas::before,
++.chat-msg.has-fx:not(.fx-on) .name-fx-vegas::after { animation-play-state: paused !important; }
++@media (prefers-reduced-motion: reduce) {
++ .name-fx-vegas, .name-fx-vegas::before, .name-fx-vegas::after { animation: none !important; }
++ .name-fx-vegas { filter: drop-shadow(0 0 3px rgba(255, 207, 63, .9)); }
++}
++
/* ══════════════════════════════════════════════════════════════
PARTICLE EFFECTS — spawned around chat usernames
ddiff --git a/public/obs/chat.html b/public/obs/chat.html
iindex 245aecfb..97a97992 100644
---- a/public/obs/chat.html
++++ b/public/obs/chat.html
@@@ -198,6 +198,68 @@ body.no-shadow .msg { text-shadow: none; }
}
@keyframes nameFxGalaxy { 0%{background-position:0% 50%} 100%{background-position:400% 50%} }
++/* Josh Vegas: one of a kind (2026-10-22). The name in casino gold under marquee lights, between two slot reels that
++ spin through cherries, bells, diamonds, slot machines and lemons and land on 7. Only its one holder wears it (fx_vegas is
++ never in the catalog). The reels are pseudo-elements, so they carry their own fill, pause and reduced-motion rules. */
++.name-fx-vegas {
++ background: linear-gradient(90deg, #fff1b8 0%, #ffcf3f 22%, #ff9d00 40%, #ff3d7f 52%, #ffcf3f 66%, #fff1b8 82%, #ffcf3f 100%);
++ background-size: 280% 100%;
++ -webkit-background-clip: text;
++ background-clip: text;
++ -webkit-text-fill-color: transparent;
++ font-weight: 800;
++ letter-spacing: .02em;
++ white-space: nowrap;
++ animation: nameFxVegasShine 2.6s linear infinite, nameFxVegasLights 1.4s steps(1, end) infinite;
++}
++.name-fx-vegas::before,
++.name-fx-vegas::after {
++ content: '7';
++ display: inline-block;
++ box-sizing: border-box;
++ width: 2em;
++ margin-right: .4em;
++ border: 1px solid #ffcf3f;
++ border-radius: 3px;
++ background: linear-gradient(180deg, #3d0512 0%, #8f0f2c 48%, #3d0512 100%);
++ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .45), 0 0 5px rgba(255, 207, 63, .6);
++ color: #ffe27a;
++ -webkit-text-fill-color: #ffe27a;
++ font-size: .72em;
++ font-weight: 900;
++ letter-spacing: 0;
++ line-height: 1.35;
++ text-align: center;
++ overflow: hidden;
++ vertical-align: .1em;
++ animation: nameFxVegasReel 3.2s steps(1, end) infinite;
++}
++.name-fx-vegas::after { margin: 0 0 0 .4em; animation-delay: .35s; }
++@keyframes nameFxVegasShine {
++ 0% { background-position: 0% 50%; }
++ 100% { background-position: 280% 50%; }
++}
++/* Marquee bulbs: the glow steps between gold and pink, like the lights around a casino sign. */
++@keyframes nameFxVegasLights {
++ 0% { filter: drop-shadow(0 0 3px rgba(255, 207, 63, .9)) drop-shadow(0 0 9px rgba(255, 157, 0, .55)); }
++ 50% { filter: drop-shadow(0 0 3px rgba(255, 61, 127, .9)) drop-shadow(0 0 9px rgba(255, 61, 127, .5)); }
++}
++/* Each reel spins through the symbols, then lands on 7 and holds it (a browser that cannot animate `content` shows 7). */
++@keyframes nameFxVegasReel {
++ 0% { content: '🍒'; }
++ 7% { content: '🔔'; }
++ 14% { content: '💎'; }
++ 21% { content: '🎰'; }
++ 28% { content: '🍋'; }
++ 35%, 100% { content: '7'; }
++}
++.chat-msg.has-fx:not(.fx-on) .name-fx-vegas::before,
++.chat-msg.has-fx:not(.fx-on) .name-fx-vegas::after { animation-play-state: paused !important; }
++@media (prefers-reduced-motion: reduce) {
++ .name-fx-vegas, .name-fx-vegas::before, .name-fx-vegas::after { animation: none !important; }
++ .name-fx-vegas { filter: drop-shadow(0 0 3px rgba(255, 207, 63, .9)); }
++}
++
.name-fx-void {
color: #9900ff !important;
text-shadow: 0 0 8px #6600cc, 0 0 16px #33006688, 0 0 24px #11003344;
ddiff --git a/server/monetization/cosmetics-routes.js b/server/monetization/cosmetics-routes.js
iindex 27d699ad..2728a6e7 100644
---- a/server/monetization/cosmetics-routes.js
++++ b/server/monetization/cosmetics-routes.js
@@@ -20,6 +20,7 @@ router.get('/catalog', async (req, res) => {
const earn = await cosmetics.earnable();
const catalog = {};
for (const [id, c] of Object.entries(cosmetics.COSMETICS)) {
++ if (c.exclusive) continue; // one of a kind: only its holder sees it, in their own inventory
if (!catalog[c.category]) catalog[c.category] = [];
catalog[c.category].push({ itemId: id, ...c, ...(earn[id] ? { earn: earn[id] } : {}) });
}
ddiff --git a/server/monetization/cosmetics.js b/server/monetization/cosmetics.js
iindex 61cdd2de..c6c8cbd0 100644
---- a/server/monetization/cosmetics.js
++++ b/server/monetization/cosmetics.js
@@@ -34,6 +34,9 @@ const COSMETICS = {
fx_glitch: { name: 'Glitch Name', emoji: '📟', category: 'name_effect', tier: 3, cssClass: 'name-fx-glitch', desc: 'Digital glitch' },
fx_hologram: { name: 'Hologram Name', emoji: '🔮', category: 'name_effect', tier: 4, cssClass: 'name-fx-hologram', desc: 'Holographic shimmer' },
fx_divine: { name: 'Divine Name', emoji: '✝️', category: 'name_effect', tier: 5, cssClass: 'name-fx-divine', desc: 'Divine radiance' },
++ // One of a kind (2026-10-22, the owner's gift to his friend Josh, "Josh Vegas" since they were kids): granted to
++ // its 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 },
// ── Particle Effects ─────────────────────────────────
px_sparkle: { name: 'Sparkle', emoji: '✨', category: 'particle', tier: 1, cssClass: 'px-sparkle', chars: '✦✧⋆', desc: 'Sparkle particles' },
ddiff --git a/test/cosmetics-exclusive.test.js b/test/cosmetics-exclusive.test.js
nnew file mode 100644
iindex 00000000..fd7441e0
---- /dev/null
++++ b/test/cosmetics-exclusive.test.js
@@@ -0,0 +1,39 @@
++'use strict';
++// A one-of-a-kind cosmetic (fx_vegas, "Josh Vegas", 2026-10-22): it is in Live's catalog of renderers, so the person
++// who holds it shows it in chat (getCosmeticProfile maps a worn alias through the catalog), but GET
++// /api/cosmetics/catalog never lists it, and its
++// class is drawn by both of Live's stylesheets (the site's cosmetics.css and the OBS chat overlay).
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++
++console.warn = () => {};
++const cosmetics = require('../server/monetization/cosmetics');
++
++(async () => {
++ const vegas = cosmetics.COSMETICS.fx_vegas;
++ assert.ok(vegas && vegas.exclusive === true && vegas.category === 'name_effect', 'fx_vegas is a one-of-a-kind name effect');
++ assert.match(vegas.cssClass, /^name-fx-[a-z]{2,24}$/, 'its class passes the pattern Chat and Network accept');
++
++ const app = express();
++ app.use('/api/cosmetics', require('../server/monetization/cosmetics-routes'));
++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
++ try {
++ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/cosmetics/catalog`);
++ 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');
++ assert.ok(!listed.includes('fx_vegas'), 'a one-of-a-kind item is never listed');
++ } finally {
++ server.close();
++ }
++
++ for (const file of ['public/css/cosmetics.css', 'public/obs/chat.html']) {
++ const css = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
++ assert.ok(css.includes('.name-fx-vegas {') && css.includes('@keyframes nameFxVegasReel'), `${file} draws the effect`);
++ 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`);
++ }
++ console.log('cosmetics exclusive: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });