diff --git a/cat-stage-vegas-0.png b/cat-stage-vegas-0.png
new file mode 100644
index 0000000..fe5a083
Binary files /dev/null and b/cat-stage-vegas-0.png differ
diff --git a/cat-stage-vegas-10.png b/cat-stage-vegas-10.png
new file mode 100644
index 0000000..4db984b
Binary files /dev/null and b/cat-stage-vegas-10.png differ
diff --git a/cat-stage-vegas-100.png b/cat-stage-vegas-100.png
new file mode 100644
index 0000000..ec3b4c2
Binary files /dev/null and b/cat-stage-vegas-100.png differ
diff --git a/cat-stage-vegas-130.png b/cat-stage-vegas-130.png
new file mode 100644
index 0000000..2f82c47
Binary files /dev/null and b/cat-stage-vegas-130.png differ
diff --git a/cat-stage-vegas-25.png b/cat-stage-vegas-25.png
new file mode 100644
index 0000000..da2857b
Binary files /dev/null and b/cat-stage-vegas-25.png differ
diff --git a/cat-stage-vegas-50.png b/cat-stage-vegas-50.png
new file mode 100644
index 0000000..919d312
Binary files /dev/null and b/cat-stage-vegas-50.png differ
diff --git a/cat-stage-vegas-75.png b/cat-stage-vegas-75.png
new file mode 100644
index 0000000..251a7a1
Binary files /dev/null and b/cat-stage-vegas-75.png differ
diff --git a/count.html b/count.html
index 5a36df9..e085d27 100644
--- a/count.html
+++ b/count.html
@@ -616,6 +616,22 @@
linear-gradient(135deg, rgba(101, 67, 33, 0.18) 0%, rgba(212, 179, 107, 0.10) 100%),
linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
}
+
+ /* ── Viva Las Vegas theme: neon casino glow + gold progress bar ── */
+ .count-container.theme-cat-vegas {
+ background:
+ radial-gradient(ellipse at 20% 0%, rgba(255, 92, 138, 0.22) 0%, rgba(255, 92, 138, 0) 55%),
+ radial-gradient(ellipse at 80% 100%, rgba(92, 242, 255, 0.14) 0%, rgba(92, 242, 255, 0) 55%),
+ linear-gradient(135deg, #1a0f2e 0%, #24123e 50%, #0f1a40 100%);
+ }
+
+ .count-container.theme-cat-vegas .progress-bar-fill {
+ background: linear-gradient(90deg, #c48a12, #ffd34d, #fff1b0);
+ }
+
+ .count-container.theme-cat-vegas .cat-img {
+ filter: drop-shadow(0 0 14px rgba(255, 211, 77, 0.35));
+ }
@@ -728,6 +744,26 @@ Select Event
+
+
+
@@ -781,6 +817,7 @@ Select Event
+
diff --git a/count.js b/count.js
index 2ab0bec..3f7875b 100644
--- a/count.js
+++ b/count.js
@@ -99,6 +99,19 @@ const CAT_MESSAGES = [
'Fatten up the cat with your responses! \u{1F43E}'
];
+const VEGAS_CAT_MESSAGES = [
+ 'Viva Las Vegas! Every response is a pull of the slots! \u{1F3B0}',
+ 'The cat is all in on your feedback! \u{1F0CF}',
+ 'Hit me! The cat wants another response! \u{1F0CF}',
+ 'Feeling lucky? Share your thoughts! \u{1F340}',
+ 'From rags to riches \u2014 one response at a time! \u{1F4B5}',
+ 'Your feedback is the jackpot! \u{1F4B0}',
+ 'What happens in training... gets feedback! \u{1F3B2}',
+ 'Don\'t fold now \u2014 the cat needs a raise! \u{1F4B8}',
+ 'Thank you, thank you very much! \u{1F3A4}',
+ 'The house always wins when you share feedback! \u{1F3B0}'
+];
+
// ── Theme content registry ────────────────────────────────────────────────────
// Each theme's stages/messages/food/milestone-overrides live here as data, so
// adding a new theme = adding an entry, not sprinkling conditionals.
@@ -106,20 +119,29 @@ const CAT_MESSAGES = [
// encouragingMessages: rotating bottom-of-counter messages
// foodEmojis: emojis dropped on each count tick by dropFood()
// milestoneMessages: override map for showMilestone(); falls back to MILESTONE_MESSAGES
+// viewId/counterId/catContainerId: this theme's DOM elements
+// sounds: optional map from a generic sound name to a theme-specific one
+// confettiStyle: optional; 'casino' swaps confetti for poker chips + banknotes
const THEME_CONTENT = {
'classic': {
+ viewId: 'themeClassic',
+ counterId: 'counterNumber',
+ catContainerId: null,
stages: null,
encouragingMessages: CLASSIC_MESSAGES,
foodEmojis: null, // Classic theme doesn't drop food
milestoneMessages: null // falls back to global MILESTONE_MESSAGES
},
'cat': {
+ viewId: 'themeCat',
+ counterId: 'catCounterNumber',
+ catContainerId: 'catContainer',
stages: null, // set below once CAT_STAGES is defined
encouragingMessages: CAT_MESSAGES,
foodEmojis: null, // set below once FOOD_EMOJIS is defined
milestoneMessages: null
}
- // 'cat-de' entry added in Task 5
+ // 'cat-de' and 'cat-vegas' entries are added further down, next to their constants
};
function getCurrentThemeContent() {
@@ -321,12 +343,73 @@ function initSounds() {
explosionNotes.push({ freq: 120, start: 0, duration: 0.2, type: 'sine', volume: 0.15 });
SOUNDS.fireworkBoom = URL.createObjectURL(generateWav(explosionNotes, 0.4));
+ // ── Viva Las Vegas: slot machine sounds ──
+ // Reels spinning: rapid clicks that slow down, then a thunk as each reel stops
+ const reelSpin = (startAt, length, reelStops) => {
+ const notes = [];
+ let t = startAt, gap = 0.035;
+ while (t < startAt + length) {
+ notes.push({ freq: 1400 + Math.random() * 300, start: t, duration: 0.012, type: 'square', volume: 0.07 });
+ notes.push({ freq: 220, start: t, duration: 0.02, type: 'triangle', volume: 0.06 });
+ t += gap;
+ gap *= 1.04;
+ }
+ reelStops.forEach(stop => {
+ notes.push({ freq: 140, start: startAt + stop, duration: 0.09, type: 'square', volume: 0.16 });
+ notes.push({ freq: 90, start: startAt + stop, duration: 0.12, type: 'sine', volume: 0.2 });
+ });
+ return notes;
+ };
+ // Coins clinking into the tray
+ const coinShower = (startAt, length, count) => Array.from({ length: count }, () => {
+ const freq = 2400 + Math.random() * 2200;
+ const start = startAt + Math.random() * length;
+ return [
+ { freq, start, duration: 0.05, type: 'sine', volume: 0.08 },
+ { freq: freq * 1.5, start: start + 0.005, duration: 0.03, type: 'sine', volume: 0.04 }
+ ];
+ }).flat();
+
+ // Every response: one quick reel stop, a ding and a couple of coins
+ SOUNDS.slotCoin = URL.createObjectURL(generateWav([
+ ...reelSpin(0, 0.18, [0.18]),
+ { freq: 1568, start: 0.24, duration: 0.18, type: 'triangle', volume: 0.2 },
+ { freq: 2093, start: 0.24, duration: 0.22, type: 'sine', volume: 0.08 },
+ ...coinShower(0.3, 0.25, 4)
+ ], 0.65));
+
+ // Party: full three-reel spin, then a ding-ding win bell and a short payout
+ SOUNDS.slotWin = URL.createObjectURL(generateWav([
+ ...reelSpin(0, 0.75, [0.45, 0.6, 0.75]),
+ { freq: 1319, start: 0.82, duration: 0.14, type: 'triangle', volume: 0.22 },
+ { freq: 1568, start: 0.96, duration: 0.14, type: 'triangle', volume: 0.22 },
+ { freq: 2093, start: 1.1, duration: 0.25, type: 'triangle', volume: 0.22 },
+ ...coinShower(1.0, 0.5, 14)
+ ], 1.6));
+
+ // Chaos + milestones: JACKPOT — spin, alarm bell ringing, coins pouring out
+ const jackpot = reelSpin(0, 0.6, [0.35, 0.48, 0.6]);
+ for (let i = 0; i < 16; i++) {
+ jackpot.push({ freq: i % 2 ? 1568 : 1319, start: 0.68 + i * 0.07, duration: 0.07, type: 'triangle', volume: 0.2 });
+ jackpot.push({ freq: i % 2 ? 3136 : 2637, start: 0.68 + i * 0.07, duration: 0.05, type: 'sine', volume: 0.05 });
+ }
+ jackpot.push(
+ { freq: 523, start: 1.85, duration: 0.6, type: 'triangle', volume: 0.16 },
+ { freq: 659, start: 1.85, duration: 0.6, type: 'triangle', volume: 0.14 },
+ { freq: 784, start: 1.85, duration: 0.6, type: 'triangle', volume: 0.14 },
+ { freq: 1047, start: 1.95, duration: 0.5, type: 'sine', volume: 0.12 },
+ ...coinShower(0.8, 1.6, 45)
+ );
+ SOUNDS.slotJackpot = URL.createObjectURL(generateWav(jackpot, 2.5));
+
console.log('Sound effects initialized');
}
function playSound(name) {
if (!soundEnabled) return;
- const url = SOUNDS[name];
+ // Themes can swap a generic sound for their own (e.g. Vegas plays slot machines)
+ const themeSounds = getCurrentThemeContent().sounds;
+ const url = SOUNDS[(themeSounds && themeSounds[name]) || name];
if (!url) return;
try {
const audio = new Audio(url);
@@ -410,7 +493,25 @@ function initConfettiCanvas() {
});
}
+const CASINO_CHIP_COLORS = ['#c4172e', '#1f5fbf', '#1f9a3d', '#1a1a1a', '#5b2a86', '#ffd34d'];
+
function createConfettiParticle(x, y) {
+ if (getCurrentThemeContent().confettiStyle === 'casino') {
+ const isChip = Math.random() < 0.55;
+ return {
+ x, y,
+ vx: (Math.random() - 0.5) * 14,
+ vy: Math.random() * -16 - 4,
+ color: isChip ? CASINO_CHIP_COLORS[Math.floor(Math.random() * CASINO_CHIP_COLORS.length)] : '#6cbf5f',
+ size: isChip ? Math.random() * 10 + 16 : Math.random() * 12 + 26,
+ rotation: Math.random() * 360,
+ rotationSpeed: (Math.random() - 0.5) * 10,
+ gravity: 0.35,
+ opacity: 1,
+ fadeRate: 0.005 + Math.random() * 0.004,
+ shape: isChip ? 'chip' : 'bill'
+ };
+ }
return {
x, y,
vx: (Math.random() - 0.5) * 14,
@@ -483,6 +584,10 @@ function animateConfetti() {
if (p.shape === 'rect') {
confettiCtx.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2);
+ } else if (p.shape === 'chip') {
+ drawPokerChip(p.size / 2, p.color);
+ } else if (p.shape === 'bill') {
+ drawBanknote(p.size);
} else {
confettiCtx.beginPath();
confettiCtx.arc(0, 0, p.size / 2, 0, Math.PI * 2);
@@ -526,6 +631,46 @@ function animateConfetti() {
confettiAnimationId = requestAnimationFrame(animateConfetti);
}
+// Poker chip centered on the current (translated + rotated) origin
+function drawPokerChip(r, color) {
+ confettiCtx.beginPath();
+ confettiCtx.arc(0, 0, r, 0, Math.PI * 2);
+ confettiCtx.fill();
+ confettiCtx.fillStyle = '#ffffff';
+ for (let i = 0; i < 6; i++) {
+ confettiCtx.save();
+ confettiCtx.rotate(i * Math.PI / 3);
+ confettiCtx.fillRect(-r * 0.14, -r, r * 0.28, r * 0.3);
+ confettiCtx.restore();
+ }
+ confettiCtx.strokeStyle = '#ffffff';
+ confettiCtx.lineWidth = Math.max(1, r * 0.1);
+ confettiCtx.setLineDash([r * 0.25, r * 0.18]);
+ confettiCtx.beginPath();
+ confettiCtx.arc(0, 0, r * 0.62, 0, Math.PI * 2);
+ confettiCtx.stroke();
+ confettiCtx.setLineDash([]);
+ confettiCtx.fillStyle = color;
+ confettiCtx.beginPath();
+ confettiCtx.arc(0, 0, r * 0.45, 0, Math.PI * 2);
+ confettiCtx.fill();
+}
+
+// Green banknote with a $ in the middle, centered on the current origin
+function drawBanknote(w) {
+ const h = w * 0.48;
+ confettiCtx.fillStyle = '#6cbf5f';
+ confettiCtx.fillRect(-w / 2, -h / 2, w, h);
+ confettiCtx.strokeStyle = '#2f6b2f';
+ confettiCtx.lineWidth = 1.5;
+ confettiCtx.strokeRect(-w / 2 + 3, -h / 2 + 3, w - 6, h - 6);
+ confettiCtx.fillStyle = '#2f6b2f';
+ confettiCtx.font = `bold ${Math.round(h * 0.75)}px sans-serif`;
+ confettiCtx.textAlign = 'center';
+ confettiCtx.textBaseline = 'middle';
+ confettiCtx.fillText('$', 0, 1);
+}
+
// ══════════════════════════════════════════════════════════════════════════════
// FIREWORKS SYSTEM
// ══════════════════════════════════════════════════════════════════════════════
@@ -709,6 +854,16 @@ const GERMAN_CHARACTER_POOL = {
bonus: ['\uD83C\uDFAA', '\u26F0\uFE0F', '\uD83C\uDDE9\uD83C\uDDEA', '\uD83E\uDD73', '\uD83C\uDFB5', '\u2728', '\uD83C\uDF86', '\uD83C\uDFC6', '\uD83C\uDF89']
};
+// Viva Las Vegas celebration pool — money and casino flair instead of animals.
+// cats → cash raining/floating (bills, money bags, flying money, coins, money-face)
+// ducks → casino games (slot machine, dice, joker card)
+// bonus → high-roller life (diamond, sports car, mansion, crown, champagne, Elvis mic)
+const VEGAS_CHARACTER_POOL = {
+ cats: ['\u{1F4B5}', '\u{1F4B0}', '\u{1F4B8}', '\u{1FA99}', '\u{1F911}'],
+ ducks: ['\u{1F3B0}', '\u{1F3B2}', '\u{1F0CF}'],
+ bonus: ['\u{1F48E}', '\u{1F3CE}\uFE0F', '\u{1F3F0}', '\u{1F451}', '\u{1F37E}', '\u{1F3A4}', '\u{1F57A}', '\u2728', '\u{1F386}']
+};
+
// Wire the default character pool into Classic + Feed the Cat. The German
// cat theme overrides it via the THEME_CONTENT['cat-de'] entry below.
THEME_CONTENT.classic.characterPool = CHARACTER_POOL;
@@ -1048,8 +1203,7 @@ function sizeProgressRing() {
} else {
// Cat themes: scale counter font for whichever cat counter is active
const availableW = leftSection.clientWidth - 40;
- const counterId = currentTheme === 'cat-de' ? 'catCounterDENumber' : 'catCounterNumber';
- const catCounter = document.getElementById(counterId);
+ const catCounter = document.getElementById(getCurrentThemeContent().counterId);
if (catCounter) catCounter.style.fontSize = Math.max(32, Math.min(availableW * 0.14, 64)) + 'px';
}
sizeCat();
@@ -1081,7 +1235,7 @@ function updateProgressRing(count) {
const range = nextMilestone - prevMilestone;
const progress = atApex ? 1 : (range > 0 ? Math.min(1, (count - prevMilestone) / range) : 0);
const apexText = themeContent.apexLabel || '🏆 Maximum reached!';
- for (const suffix of ['', 'DE']) {
+ for (const suffix of ['', 'DE', 'Vegas']) {
const fill = document.getElementById(`progressBarFill${suffix}`);
const label = document.getElementById(`progressBarLabel${suffix}`);
if (fill) fill.style.width = (progress * 100) + '%';
@@ -1096,16 +1250,16 @@ function updateProgressRing(count) {
function setTheme(theme) {
currentTheme = theme;
session.settings.theme = currentTheme;
- const classicView = document.getElementById('themeClassic');
- const catView = document.getElementById('themeCat');
- const catDEView = document.getElementById('themeCatDE');
- const container = document.querySelector('.count-container');
-
- if (classicView) classicView.style.display = (theme === 'classic') ? '' : 'none';
- if (catView) catView.style.display = (theme === 'cat') ? '' : 'none';
- if (catDEView) catDEView.style.display = (theme === 'cat-de') ? '' : 'none';
+ const container = document.querySelector('.count-container');
- if (container) container.classList.toggle('theme-cat-de', theme === 'cat-de');
+ for (const [name, content] of Object.entries(THEME_CONTENT)) {
+ const view = document.getElementById(content.viewId);
+ if (view) view.style.display = (theme === name) ? '' : 'none';
+ // Classic + Feed the Cat use the base styling; other themes get a class hook
+ if (container && name !== 'classic' && name !== 'cat') {
+ container.classList.toggle(`theme-${name}`, theme === name);
+ }
+ }
// Resize for new layout
sizeProgressRing();
@@ -1117,7 +1271,7 @@ function setTheme(theme) {
updateEncouragingMessage();
}
-const VALID_THEMES = ['classic', 'cat', 'cat-de'];
+const VALID_THEMES = ['classic', 'cat', 'cat-de', 'cat-vegas'];
function initializeThemeSelector() {
const select = document.getElementById('themeSelect');
@@ -1146,7 +1300,7 @@ const CAT_STAGES = [0, 10, 25, 50, 75, 100];
THEME_CONTENT.cat.stages = CAT_STAGES;
function initCat() {
- const imgs = document.querySelectorAll('#themeCat .cat-img, #themeCatDE .cat-img');
+ const imgs = document.querySelectorAll('.theme-view .cat-img');
if (!imgs.length) return;
sizeCat();
@@ -1179,7 +1333,7 @@ function updateCatState(count) {
const content = getCurrentThemeContent();
const stages = content.stages;
if (!stages) return; // Classic theme — no cat to update
- const imgs = document.querySelectorAll(`#${currentTheme === 'cat-de' ? 'catContainerDE' : 'catContainer'} .cat-img`);
+ const imgs = document.querySelectorAll(`#${content.catContainerId} .cat-img`);
if (!imgs.length) return;
let activeStage = stages[0];
@@ -1201,8 +1355,8 @@ function sizeCat() {
}
function celebrateCatMilestone() {
- const containerId = currentTheme === 'cat-de' ? 'catContainerDE' : 'catContainer';
- const container = document.getElementById(containerId);
+ const containerId = getCurrentThemeContent().catContainerId;
+ const container = containerId && document.getElementById(containerId);
if (!container) return;
container.style.transform = 'scale(1.2) translateY(-10px)';
@@ -1245,6 +1399,9 @@ const GERMAN_MILESTONE_MESSAGES = {
const GERMAN_FOOD_EMOJIS = ['🥨', '🌭', '🍺', '🥖', '🧀', '🍻'];
THEME_CONTENT['cat-de'] = {
+ viewId: 'themeCatDE',
+ counterId: 'catCounterDENumber',
+ catContainerId: 'catContainerDE',
stages: GERMAN_CAT_STAGES,
encouragingMessages: GERMAN_CAT_MESSAGES,
foodEmojis: GERMAN_FOOD_EMOJIS,
@@ -1257,9 +1414,49 @@ THEME_CONTENT['cat-de'] = {
apexLabel: '🏆 Mega-Katzen-König!'
};
+// ── Viva Las Vegas theme constants ───────────────────────────────────────────
+// Rags to riches: broke alley cat (0) → penny slots (10) → blackjack (25) →
+// roulette (50) → 777 jackpot (75) → high roller (100) → Elvis, the King (130)
+const VEGAS_CAT_STAGES = [0, 10, 25, 50, 75, 100, 130];
+
+const VEGAS_MILESTONE_MESSAGES = {
+ 10: 'Ten! Penny slots are paying out! 🎰',
+ 25: 'Twenty-five! Blackjack — winner winner! 🃏',
+ 50: 'Fifty! Let it ride on red! 🎡',
+ 75: 'Seventy-five! JACKPOT! 7-7-7! 💰',
+ 100: 'ONE HUNDRED! High roller status unlocked! 🎩🍾',
+ 150: '150! The King has entered the building! 🎤',
+ 200: '200! Making it rain on the Strip! 💸',
+ 300: '300! Penthouse suite, baby! 🏰',
+ 500: '500! Viva Las Vegas — the house is yours! 👑💎'
+};
+
+const VEGAS_FOOD_EMOJIS = ['💵', '💰', '🪙', '💎', '🎲', '🃏'];
+
+THEME_CONTENT['cat-vegas'] = {
+ viewId: 'themeCatVegas',
+ counterId: 'catCounterVegasNumber',
+ catContainerId: 'catContainerVegas',
+ stages: VEGAS_CAT_STAGES,
+ encouragingMessages: VEGAS_CAT_MESSAGES,
+ foodEmojis: VEGAS_FOOD_EMOJIS,
+ milestoneMessages: VEGAS_MILESTONE_MESSAGES,
+ characterPool: VEGAS_CHARACTER_POOL,
+ confettiStyle: 'casino',
+ sounds: {
+ chime: 'slotCoin',
+ fanfare: 'slotWin',
+ chaos: 'slotJackpot',
+ milestone: 'slotJackpot'
+ },
+ apex: 130,
+ apexLabel: '🎤 The King of Vegas!'
+};
+
function dropFood() {
const counter = document.getElementById('counterNumber');
- const cat = document.getElementById(currentTheme === 'cat-de' ? 'catContainerDE' : 'catContainer');
+ const catContainerId = getCurrentThemeContent().catContainerId;
+ const cat = catContainerId && document.getElementById(catContainerId);
if (!counter || !cat) return;
const counterRect = counter.getBoundingClientRect();
@@ -1323,8 +1520,8 @@ function createDigitReel() {
function updateDigitDisplay(newCount) {
const digits = String(newCount).split('');
- // Update all three counter elements (classic + cat + cat-de)
- ['counterNumber', 'catCounterNumber', 'catCounterDENumber'].forEach(id => {
+ // Update every theme's counter element so switching themes is instant
+ Object.values(THEME_CONTENT).map(t => t.counterId).forEach(id => {
const el = document.getElementById(id);
if (el) updateDigitContainer(el, digits);
});
@@ -1715,10 +1912,7 @@ async function updateCount() {
updateDigitDisplay(count);
// Pulse animation on active counter
- const activeCounter =
- currentTheme === 'classic' ? document.getElementById('counterNumber')
- : currentTheme === 'cat-de' ? document.getElementById('catCounterDENumber')
- : document.getElementById('catCounterNumber');
+ const activeCounter = document.getElementById(getCurrentThemeContent().counterId);
if (activeCounter) {
activeCounter.classList.remove('pulse');
void activeCounter.offsetWidth;
diff --git a/scripts/vegas-cat-prompts.json b/scripts/vegas-cat-prompts.json
new file mode 100644
index 0000000..e3fb98d
--- /dev/null
+++ b/scripts/vegas-cat-prompts.json
@@ -0,0 +1,40 @@
+{
+ "generator": "Bing Image Creator (MAI-Image-2.5-Flash), aspect ratio 1:1, downloaded as 1024x1024 WebP and converted to PNG. Each prompt is self-contained and under Bing's 480-character limit.",
+ "stages": [
+ {
+ "stage": 0,
+ "filename": "cat-stage-vegas-0.png",
+ "prompt": "Cute chibi orange tabby kitten, big anime eyes, white chest, painterly digital art, dark navy background with soft Las Vegas neon glow, centered full body, square. Skinny broke kitten in a rainy back alley, scruffy patched fur, empty pockets turned inside out, holding a soggy cardboard sign \"WILL PURR FOR CHIPS\", one tear, sad hopeful eyes, a single penny on the wet ground, dim pink PAWN neon sign behind."
+ },
+ {
+ "stage": 10,
+ "filename": "cat-stage-vegas-10.png",
+ "prompt": "Cute chibi orange tabby kitten, big anime eyes, white chest, painterly digital art, dark navy background with soft Las Vegas neon glow, centered full body, square. Kitten in a green casino dealer visor beside a small red penny slot machine, proudly holding a paper cup with a few gold coins, small hopeful smile, coins on the casino carpet, \"PENNY SLOTS\" neon sign."
+ },
+ {
+ "stage": 25,
+ "filename": "cat-stage-vegas-25.png",
+ "prompt": "Cute chibi orange tabby kitten, big anime eyes, white chest, painterly digital art, dark navy background with soft Las Vegas neon glow, centered full body, square. Plumper kitten in a red casino vest, black bow tie and green visor at a blackjack table, holding up an Ace of spades and King of hearts, big excited grin, small stacks of red, blue and black poker chips."
+ },
+ {
+ "stage": 50,
+ "filename": "cat-stage-vegas-50.png",
+ "prompt": "Cute chibi orange tabby kitten, big anime eyes, white chest, painterly digital art, dark navy background with soft Las Vegas neon glow, centered full body, square. Chubby kitten in a sharp purple suit, gold bow tie, sunglasses pushed up on its head, eyes shut in joy, fanning a handful of dollar bills, roulette wheel behind, tall towers of poker chips and cash bundles."
+ },
+ {
+ "stage": 75,
+ "filename": "cat-stage-vegas-75.png",
+ "prompt": "Cute chibi orange tabby kitten, big anime eyes, white chest, painterly digital art, dark navy background with soft Las Vegas neon glow, centered full body, square. Very chubby kitten in a purple suit with a thick gold chain and dollar medallion, green dollar-sign eyes, waving a fan of hundred-dollar bills, red slot machine showing 7-7-7 spilling a waterfall of gold coins, \"JACKPOT\" neon."
+ },
+ {
+ "stage": 100,
+ "filename": "cat-stage-vegas-100.png",
+ "prompt": "Cute chibi orange tabby kitten, big anime eyes, white chest, painterly digital art, dark navy background with soft Las Vegas neon glow, centered full body, square. Chonky high-roller kitten in a white tuxedo with a red rose, black top hat and cool sunglasses, raising a glass of champagne, shiny red convertible on the Las Vegas Strip behind, money bag and stacks of cash and chips."
+ },
+ {
+ "stage": 130,
+ "filename": "cat-stage-vegas-130.png",
+ "prompt": "Cute chibi orange tabby kitten, big anime eyes, painterly digital art, dark navy background with Las Vegas neon, centered full body, square. Rich chonky kitten dressed as Vegas-era Elvis: white rhinestone jumpsuit, tall collar, gold belt buckle, white-and-gold cape, black pompadour, gold aviators, microphone, lip-curl grin, on a throne of cash. Mansion, gold and red sports cars, Welcome to Las Vegas sign, fireworks, chips and bills raining."
+ }
+ ]
+}
diff --git a/tests/german-cat-theme.spec.mjs b/tests/german-cat-theme.spec.mjs
index e067283..77debfc 100644
--- a/tests/german-cat-theme.spec.mjs
+++ b/tests/german-cat-theme.spec.mjs
@@ -41,10 +41,10 @@ async function waitForCountReady(page) {
}
test.describe('Dropdown integration', () => {
- test('Dropdown shows three theme options', async ({ page }) => {
+ test('Dropdown lists every theme option', async ({ page }) => {
await page.goto(COUNT_URL);
const values = await page.$$eval('#themeSelect option', opts => opts.map(o => o.value));
- expect(values).toEqual(['classic', 'cat', 'cat-de']);
+ expect(values).toEqual(['classic', 'cat', 'cat-de', 'cat-vegas']);
});
test("Selecting 'cat-de' switches the view and persists across reload", async ({ page }) => {
diff --git a/tests/vegas-cat-theme.spec.mjs b/tests/vegas-cat-theme.spec.mjs
new file mode 100644
index 0000000..178c9ce
--- /dev/null
+++ b/tests/vegas-cat-theme.spec.mjs
@@ -0,0 +1,189 @@
+/**
+ * Verification suite for the Viva Las Vegas cat theme: rags-to-riches stages,
+ * casino celebrations (cash + poker chips) and slot machine sounds.
+ */
+import { test, expect } from '@playwright/test';
+
+const COUNT_URL = '/count.html?code=CSA1B2C3';
+const VEGAS_FOOD_EMOJIS = ['💵', '💰', '🪙', '💎', '🎲', '🃏'];
+
+async function setMockCount(page, count) {
+ await page.evaluate((n) => {
+ const rows = Array.from({ length: n }, (_, i) => ({
+ eventCode: 'CSA1B2C3',
+ eventModuleId: 1,
+ id: i
+ }));
+ localStorage.setItem('bootcampFeedback', JSON.stringify(rows));
+ }, count);
+}
+
+async function pickTheme(page, theme) {
+ await page.selectOption('#themeSelect', theme);
+ await page.waitForTimeout(150);
+}
+
+async function waitForCountReady(page) {
+ await page.waitForSelector('#countDisplay', { state: 'visible', timeout: 5000 });
+ await page.waitForFunction(() => {
+ const el = document.getElementById('lastUpdated');
+ return el && el.textContent.trim() !== '' && el.textContent.trim() !== '-';
+ }, { timeout: 5000 });
+}
+
+test.describe('Viva Las Vegas — theme switching', () => {
+ test("Selecting 'cat-vegas' switches the view and persists across reload", async ({ page }) => {
+ await page.goto(COUNT_URL);
+ const label = await page.$eval('#themeSelect option[value="cat-vegas"]', o => o.textContent);
+ expect(label).toBe('Viva Las Vegas');
+ await pickTheme(page, 'cat-vegas');
+ await expect(page.locator('#themeCatVegas')).toBeVisible();
+ await expect(page.locator('#themeCat')).toBeHidden();
+ await expect(page.locator('#themeCatDE')).toBeHidden();
+ await expect(page.locator('#themeClassic')).toBeHidden();
+ expect(await page.evaluate(() => sessionStorage.getItem('counterTheme'))).toBe('cat-vegas');
+ await page.reload();
+ await expect(page.locator('#themeCatVegas')).toBeVisible();
+ });
+
+ test('theme-cat-vegas class is applied only while the Vegas theme is active', async ({ page }) => {
+ await page.goto(COUNT_URL);
+ const container = page.locator('.count-container');
+ await pickTheme(page, 'cat-vegas');
+ await expect(container).toHaveClass(/theme-cat-vegas/);
+ await expect(container).not.toHaveClass(/theme-cat-de/);
+ await pickTheme(page, 'cat-de');
+ await expect(container).not.toHaveClass(/theme-cat-vegas/);
+ await expect(container).toHaveClass(/theme-cat-de/);
+ await pickTheme(page, 'classic');
+ await expect(container).not.toHaveClass(/theme-cat-/);
+ });
+});
+
+test.describe('Viva Las Vegas — stages', () => {
+ test('All 7 rags-to-riches stages render at the correct counts', async ({ page }) => {
+ for (const [n, stage] of [[0, 0], [9, 0], [10, 10], [25, 25], [50, 50], [75, 75], [100, 100], [129, 100], [130, 130], [400, 130]]) {
+ await page.goto(COUNT_URL);
+ await setMockCount(page, n);
+ await page.reload();
+ await waitForCountReady(page);
+ await pickTheme(page, 'cat-vegas');
+ const activeStages = await page.$$eval('#themeCatVegas .cat-img.active', els => els.map(e => e.dataset.stage));
+ expect(activeStages, `count ${n}`).toEqual([String(stage)]);
+ }
+ });
+
+ test('Counter digits and progress bar update for the Vegas view', async ({ page }) => {
+ await page.goto(COUNT_URL);
+ await setMockCount(page, 42);
+ await page.reload();
+ await waitForCountReady(page);
+ await pickTheme(page, 'cat-vegas');
+ const digits = await page.$$eval('#catCounterVegasNumber .counter-digit', els => els.map(e => e.dataset.digit).join(''));
+ expect(digits).toBe('42');
+ await expect(page.locator('#progressBarLabelVegas')).toHaveText('42 / 50 to next milestone');
+
+ await setMockCount(page, 135);
+ await page.reload();
+ await waitForCountReady(page);
+ await pickTheme(page, 'cat-vegas');
+ await expect(page.locator('#progressBarLabelVegas')).toContainText('The King of Vegas');
+ });
+
+ test('All 7 stage images load', async ({ page }) => {
+ await page.goto(COUNT_URL);
+ await pickTheme(page, 'cat-vegas');
+ const results = await page.$$eval('#themeCatVegas .cat-img', imgs => Promise.all(imgs.map(img =>
+ img.complete ? img.naturalWidth : new Promise(r => { img.onload = () => r(img.naturalWidth); img.onerror = () => r(0); })
+ )));
+ expect(results).toHaveLength(7);
+ for (const w of results) expect(w).toBeGreaterThan(0);
+ });
+});
+
+test.describe('Viva Las Vegas — content', () => {
+ test('Encouraging messages are casino-flavored', async ({ page }) => {
+ await page.goto(COUNT_URL);
+ await pickTheme(page, 'cat-vegas');
+ const messages = await page.evaluate(() => window._activeEncouragingMessagesForTest?.());
+ expect(messages.join(' ')).toMatch(/Viva Las Vegas|jackpot|slots/i);
+ });
+
+ test('Milestone messages are Vegas overrides', async ({ page }) => {
+ await page.goto(COUNT_URL);
+ await pickTheme(page, 'cat-vegas');
+ expect(await page.evaluate(() => window._activeMilestoneMessageForTest?.(75))).toMatch(/JACKPOT/);
+ expect(await page.evaluate(() => window._activeMilestoneMessageForTest?.(100))).toMatch(/High roller/i);
+ });
+
+ test('Food drops are money and casino items only', async ({ page }) => {
+ await page.goto(COUNT_URL);
+ await pickTheme(page, 'cat-vegas');
+ const list = await page.evaluate(() => window._activeFoodEmojisForTest?.());
+ expect(list).toEqual(VEGAS_FOOD_EMOJIS);
+ });
+});
+
+test.describe('Viva Las Vegas — celebrations', () => {
+ test('Celebrations play slot machine sounds instead of the default chimes', async ({ page }) => {
+ await page.goto(COUNT_URL);
+ await pickTheme(page, 'cat-vegas');
+ const played = await page.evaluate(async () => {
+ const urls = [];
+ const OrigAudio = window.Audio;
+ window.Audio = function (src) {
+ urls.push(src);
+ return { play: () => Promise.resolve(), volume: 1 };
+ };
+ window.playMilestoneSound();
+ window.Audio = OrigAudio;
+ return urls;
+ });
+ expect(played).toHaveLength(1);
+ // Same call on the plain cat theme must produce a different (default) sound
+ await pickTheme(page, 'cat');
+ const defaultPlayed = await page.evaluate(async () => {
+ const urls = [];
+ const OrigAudio = window.Audio;
+ window.Audio = function (src) { urls.push(src); return { play: () => Promise.resolve(), volume: 1 }; };
+ window.playMilestoneSound();
+ window.Audio = OrigAudio;
+ return urls;
+ });
+ expect(defaultPlayed).toHaveLength(1);
+ expect(defaultPlayed[0]).not.toBe(played[0]);
+ });
+
+ test('Milestone confetti is poker chips and banknotes', async ({ page }) => {
+ await page.goto(COUNT_URL);
+ await pickTheme(page, 'cat-vegas');
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ await page.evaluate(() => window.checkMilestone(10, 9));
+ await page.waitForTimeout(600);
+ await expect(page.locator('#milestoneMessage')).toContainText('Penny slots');
+ expect(errors).toEqual([]);
+ });
+});
+
+test.describe('Viva Las Vegas — image assets', () => {
+ test('All 7 cat-stage-vegas-*.png files exist, are square, and ≤ 3500 KB', async ({ }) => {
+ const fs = await import('node:fs/promises');
+ const { fileURLToPath } = await import('node:url');
+ const root = fileURLToPath(new URL('..', import.meta.url));
+ for (const stage of [0, 10, 25, 50, 75, 100, 130]) {
+ const file = root + `cat-stage-vegas-${stage}.png`;
+ const stat = await fs.stat(file);
+ expect(stat.size, `${file} size`).toBeLessThanOrEqual(3500 * 1024);
+ const fd = await fs.open(file, 'r');
+ const buf = Buffer.alloc(8);
+ await fd.read(buf, 0, 8, 16);
+ await fd.close();
+ const width = buf.readUInt32BE(0);
+ const height = buf.readUInt32BE(4);
+ expect(width, `${file} width`).toBeGreaterThanOrEqual(700);
+ expect(width).toBeLessThanOrEqual(1200);
+ expect(height, `${file} height`).toEqual(width);
+ }
+ });
+});