Compare commits

...
6 Commits
Author SHA1 Message Date
hermes 4e873773fe Session 18: Prestige Progress Bar (live visual progress toward next Stellar Essence) 2026-08-25 10:33:27 +00:00
hermes b9068e2029 Session 17: Sound Toggle (mute/unmute with persistence) Added 2026-08-25 02:17:50 +00:00
hermes cc1fa6bc4d Session 16: Global 'Buy All Buildings' bulk-buy feature Added
Adds a global Buy All button to the Buildings panel that purchases as many
of every building type the player can afford at once, bringing Plants in line
with the Upgrades (Buy All) and Prestige (Buy Max) panels.

- game.js: Game.buyAllBuildings() loops buyMaxBuilding() per building type
- ui.js: UI.updateBuyAllBuildings() shows/hides + enables/disables live each tick
- index.html: Buy All button in Buildings panel header (reuses .upgrades-header/.buy-all-btn)
- Added headless test buy-all-buildings-test.mjs (31/31 pass); all existing suites green
2026-08-24 18:00:27 +00:00
hermes 44f55f0d24 Merge branch 'main' of git.obahan.xyz:lullu/idle-game 2026-08-24 09:43:52 +00:00
hermes 42b6f30d5b Add Session 15 test: Buy Max Prestige Upgrades (22 checks + catches essence-spend bug) 2026-08-24 09:43:45 +00:00
hermes e734a6a4a7 Add Golden Starbloom headless test (Session 11) 2026-08-23 00:25:01 +00:00
10 changed files with 618 additions and 19 deletions
+45
View File
@@ -1060,6 +1060,51 @@ h2, h3 {
font-weight: bold;
}
/* Prestige Progress Bar */
.prestige-progress {
margin-bottom: 8px;
}
.prestige-progress-label {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-secondary);
font-size: 0.8rem;
margin-bottom: 5px;
}
.prestige-progress-label #prestige-progress-pct {
color: var(--accent-purple);
font-weight: bold;
}
.prestige-progress-bar {
width: 100%;
height: 12px;
background: var(--bg-card-2, var(--text-muted));
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.prestige-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--accent-purple), #b366ff);
border-radius: 6px;
transition: width 0.25s ease-out;
}
.prestige-progress-fill.filling {
animation: prestigeGlow 1.5s ease-in-out infinite;
}
@keyframes prestigeGlow {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.25); }
}
/* Prestige Upgrade Cards */
#prestige-upgrades-list {
display: flex;
+78
View File
@@ -442,3 +442,81 @@ Wrote a headless Node.js test (`/opt/data/idle-game-test/prestige-buy-max-test.m
- Potential new building tiers or prestige milestones / new prestige upgrades.
- Sound variations or background music.
- Consider a global "Buy Max" for buildings across all types at once (currently per-building).
---
### Session 16 - 2026-08-24 (Afternoon)
**Status:** ✅ Global "Buy All Buildings" Bulk-Buy Added
#### What was done:
- Followed up on Sessions 10, 14, and 15's recurring bulk-buy convention by adding a **global "Buy All buildings"** button that purchases as many of *every* building type the player can afford at once — previously you could only Buy Max per individual building card (Session 10), while upgrades got Buy All (Session 14) and prestige upgrades got Buy Max (Session 15). This brings the Plants panel in line with the rest of the game.
- Added `Game.buyAllBuildings()` to `js/game.js`. It loops through every entry in `BUILDINGS`, calling the existing read-only-friendly `buyMaxBuilding()` for each, until a full pass purchases nothing (loop kept for symmetry with `buyAllUpgrades` / `buyMaxPrestigeUpgrades` and to stay safe if future buildings gain requirement gates; in practice a single pass suffices since cost-scales independently per type). Returns the total number of buildings purchased. Reuses `buyMaxBuilding`, so it correctly respects per-building cost scaling, prestige costReduction upgrades, purchase notifications, audio, and building-purchase particle effects.
- Wired a "Buy All" button into the Buildings panel header in `index.html` (`#buy-all-buildings`, reusing the existing `.upgrades-header` flex layout + `.buy-all-btn` gold theme — no new CSS needed). Added a click listener in `Game.setupEventListeners()`.
- Added `UI.updateBuyAllBuildings()` (called from `UI.updateBuildings()` each tick) which shows/hides and enables/disables the button based on whether any unlocked building type is currently affordable — a read-only state check mirroring `UI.updateBuyAllButton()`. Locked buildings (unlockAt not reached, count 0) are skipped so the button only appears once at least one plant is available.
- **Bug found & fixed during testing:** the first test run failed because `buyMaxBuilding()` calls `Particles.createBuildingEffect()`, which the initial Particles stub didn't provide — this confirmed the integration point and was added to the stub (not a game bug). A second test asserted the leftover stardust would be < cheapest base cost, which is incorrect under geometric cost scaling (the leftover can exceed a base cost while still being unable to afford the next scaled unit); replaced with the correct end-state invariant that no further single purchase is possible across any building type.
#### Files modified:
- `js/game.js` - added `buyAllBuildings()` method; wired Buy All buildings button click listener in setupEventListeners()
- `js/ui.js` - added `updateBuyAllBuildings()`; called it from `updateBuildings()` each tick
- `index.html` - added Buy All button to the Buildings panel header (reuses `.upgrades-header` + `.buy-all-btn`)
- `dev-journal.md` - appended Session 16 entry
#### Verification:
Wrote a headless Node.js test (`/opt/data/idle-game-test/buy-all-buildings-test.mjs`) that loads the real `game.js` and `ui.js` in-process with DOM/audio/particle stubs and the *real* `BUILDINGS` data. **31/31 checks pass**, covering: no-op when nothing affordable, bulk purchase across every building type matching a hand-computed cost-scaling reference (exact per-type counts + exact stardust depletion), the end-state invariant that no further single purchase is possible, idempotent second call, notification-on-purchase vs silence on no-op, top-up behavior from a mixed starting state (already-own-one case), and the real `UI.updateBuyAllBuildings()` show/hide/enabled/disabled state against actual `BUILDINGS` data. All existing suites re-ran clean with no regressions: buy-max 6/6, golden-bloom 22/22, prestige-buy-max 22/22, slot-preview 22/22, buy-all-upgrades 24/24. All modified JS files pass `node --check`.
#### Next goals:
- Potential new building tiers or prestige milestones / new prestige upgrades.
- Sound variations or background music.
- Consider a "Buy Max" toggle (buy one-at-a-time vs bulk) or per-building Buy Max count display.
---
### Session 17 - 2026-08-25 (Morning)
**Status:** ✅ Sound Toggle (mute/unmute with persistence) Added
#### What was done:
- Followed up on Sessions 14-16's recurring "sound variations / background music" next-goal by adding a **footer Sound toggle button**. The game already had a full Web Audio sound-effect system (click arpeggio, purchase chime, achievement fanfare, prestige whoosh, golden-bloom shimmer in `js/audio.js`), but there was no way to turn it off — once the audio context started on first click, it could never be silenced. This is a real usability gap for, e.g., playing at night or in a shared space.
- Added `soundEnabled: true` to `Game.state` and made **every** sound effect flow through a single new method `Game.playSound(effect)` (effects: 'click', 'purchase', 'achievement', 'prestige', 'goldenBloom'). The method returns early when `soundEnabled` is false, so muting reliably silences all effects. Replaced every direct `Audio.play*()` call site in `game.js`, `prestige.js`, and `achievements.js` with `Game.playSound(...)`.
- Guarded the initial `Audio.init()` on `if (this.state.soundEnabled)` so a muted player's browser never creates an audio context at all.
- Added a footer **Sound** toggle button (`#sound-btn`) next to the existing Autosave toggle, reusing the `.toggle-btn` styling. It shows 🔊/🔇 icons (instead of ON/OFF text), toggles `state.soundEnabled`, persists the preference to `localStorage('cosmicGardenSound')`, shows a notification, and — when unmute-ing — re-initializes/resumes the audio context (a click gesture satisfies the browser's autoplay policy).
- Added `Game.syncSoundButton()` which reflects the current preference on the footer button's visual state; called it at the end of `init()` so a muted player sees a muted button on reload even though the HTML defaults to enabled.
- Restored the persisted sound preference in `loadState()` **outside** the `if (saved)` block so it applies whether or not a per-slot save exists, and preserved `soundEnabled` across `reset()` and the empty-slot branch of `switchSlot()` (mirroring the existing `autoSave` handling).
#### Files modified:
- `js/game.js` - added `soundEnabled` state; `playSound()` + `syncSoundButton()` methods; guarded `Audio.init()`; sound-toggle event listener; routed all 6 audio call sites through `Game.playSound`; restored pref in `loadState()`; preserved across `reset()`/`switchSlot()`
- `js/prestige.js` - routed prestige + prestige-upgrade sounds through `Game.playSound()`
- `js/achievements.js` - routed achievement sound through `Game.playSound()`
- `index.html` - added Sound toggle button to the footer
- `dev-journal.md` - appended Session 17 entry
#### Verification:
Wrote a headless Node.js test (`/opt/data/idle-game-test/sound-toggle-test.mjs`) that loads the real `game.js` in-process with DOM/audio/localStorage stubs. **29/29 checks pass**, covering: default-on state, correct effect routing when enabled, total silence for all five effects when muted, unknown-effect safety, toggle button flipping preference + persisting to localStorage + showing notifications + updating 🔊/🔇 icon and `.on` class, restoring the muted/enabled preference from `loadState()` both with and without a save on disk, `reset()` preserving the preference across both states, and `syncSoundButton()` visual state. All existing suites re-ran clean with no regressions: buy-max 6/6, buy-all-upgrades 24/24, golden-bloom 22/22, prestige-buy-max 22/22, slot-preview 22/22, buy-all-buildings 31/31. All modified JS files pass `node --check`.
#### Next goals:
- Consider adding a mute-state persistence across slots explicitly tested via the UI (currently preserved in state; could add a dedicated test that switches slots and checks the button).
- Potential new building tiers or prestige milestones / new prestige upgrades.
- Background music track or per-effect volume sliders.
---
### Session 18 - 2026-08-25 (Morning)
**Status:** ✅ Prestige Progress Bar Added
#### What was done:
- Followed up on Sessions 14-17's recurring "prestige milestones" next-goal by adding a **live prestige progress bar** to the Prestige tab. The tab already showed a static "Next prestige: N ✦" number (the essence you'd gain right now), but players had no visual sense of how close they were to earning their next Stellar Essence — a staple idle-game UX element that makes the prestige loop feel rewarding between resets.
- New math derived from the existing `PRESTIGE.calculateEssence()` formula: essence level e = floor(sqrt(totalStardust / 1e6)), so tier e begins at e²·1M stardust and tier (e+1) begins at (e+1)²·1M. The bar fills linearly across a tier's stardust range and resets to 0% exactly when a new essence level is reached, so it stays perfectly consistent with `calculateEssence()` (verified: at 999,999 total → 100%, at 1,000,000 → 0% and essence = 1).
- Added the DOM in index.html: a `.prestige-progress` block with a "Prestige progress / N%" label row and a `.prestige-progress-bar` track + `.prestige-progress-fill` bar (purple gradient) inserted between the existing "Next prestige" line and the explanatory paragraph.
- Added CSS (.prestige-progress, .prestige-progress-label, .prestige-progress-bar, .prestige-progress-fill) matching the existing prestige card theme: purple gradient fill, subtle border, 0.25s ease-out width transition, and a gentle pulsing glow (keyframes `prestigeGlow`) applied only while 0% < progress < 100%, so a freshly-emptied bar sits still after prestige.
- Added `PrestigeUI.updatePrestigeProgress()` (read-only: mutates DOM only) and wired it into `PrestigeUI.update()` right after the "Next prestige" number, so the bar refreshes every tick alongside the other prestige stats.
#### Files modified:
- `index.html` - added `.prestige-progress` block (label + percentage + progress bar) in Prestige tab
- `css/game.css` - `.prestige-progress*` styles + `.prestige-progress-fill.filling` glow keyframes
- `js/prestigeUI.js` - added `updatePrestigeProgress()` method; called it from `update()` each tick
#### Verification:
Wrote a headless Node.js test (`/opt/data/idle-game-test/prestige-progress-bar-test.cjs`) that loads the REAL game.js, prestige.js, and prestigeUI.js in-process via node:vm with DOM/UI/audio/particle stubs. **20/20 checks pass**, covering: empty-state (0%), mid-tier fill (500K→50%, 2M→33%, 6.5M→50%), tier boundary behavior (999,999→100% no glow, 1M→0% reset with essence=1), the glow class toggling correctly (active only while 0<pct<100), monotonicity within tiers with resets allowed only at true boundaries, cross-check against an independent reference implementation at 19 sample points, read-only guarantee (totalStardust unchanged after update), graceful no-crash when DOM elements are absent, and full `PrestigeUI.update()` integration. All modified JS passes `node --check`.
#### Next goals:
- Potential new building tiers or prestige milestones / new prestige upgrades.
- Background music track or per-effect volume sliders.
+131
View File
@@ -0,0 +1,131 @@
/**
* Headless test for Session 11 - Golden Starbloom random event.
* Verifies golden bloom scheduling, claiming, and the production boost.
*/
// Minimal DOM stubs (only what our code touches)
global.document = {
getElementById: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 200 }) }),
querySelector: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
};
// UI stub capturing notifications + spawn/remove calls
const notifications = [];
global.UI = {
showNotification: (msg) => notifications.push(msg),
update: () => {},
spawnGoldenBloom: () => { global.__spawned = (global.__spawned || 0) + 1; },
removeGoldenBloom: () => { global.__removed = (global.__removed || 0) + 1; },
updateGoldenBloomBadge: () => {},
};
// Audio / Particles stubs
global.Audio = { playGoldenBloom: () => {} };
global.Particles = { createGoldenEffect: (x, y) => { global.__goldX = x; global.__goldY = y; } };
// Load the real game engine source and eval it in this scope.
import { readFileSync } from 'fs';
const src = readFileSync('/opt/data/idle-game/js/game.js', 'utf8')
.replace(/window\.Game = Game;/, '')
+ '\nglobalThis.Game = Game;\n';
eval(src);
// Build stubs for the data modules needed by the engine.
global.BUILDINGS = {
starSprout: { id: 'starSprout', baseCost: 15, baseProduction: 0.1 },
moonBloom: { id: 'moonBloom', baseCost: 100, baseProduction: 0.5 },
};
global.UPGRADES = {};
// Minimal prestige stub returning x1 multiplier (no bonus) so we can isolate
// golden-bloom math from the real prestige system.
global.PRESTIGE = { getMultiplier: () => 1 };
global.PRESTIGE_UPGRADES = {};
// Achievements stub - check() must be a no-op for these tests.
global.Achievements = { check: () => {} };
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(` OK ${msg}`); }
else { failed++; console.error(` FAIL ${msg}`); }
}
console.log('Test 1: no bloom active initially -> remaining is 0');
Game.state.goldenBlooms.active = false;
assert(Game.getGoldenBloomRemaining() === 0, `remaining is 0 (got ${Game.getGoldenBloomRemaining()})`);
console.log('Test 2: claiming with no active bloom returns 0 and does not award stardust');
const before = Game.state.stardust;
Game.state.goldenBlooms.active = false;
const none = Game.claimGoldenBloom(10, 10);
assert(none === 0, `claim returned 0 when inactive (got ${none})`);
assert(Game.state.stardust === before, 'stardust unchanged when no bloom');
console.log('Test 3: golden bloom bonus = floor(production * mult), production boost applied');
// Give the player some buildings so production is deterministic.
Game.state.buildings = { starSprout: 10 }; // 10 * 0.1 = 1.0 /s (prestige multiplier = 1)
Game.state.productionBoostUntil = 0;
const remaining0 = Game.getGoldenBloomRemaining();
Game.state.goldenBlooms.active = true;
Game.state.goldenBlooms.timer = 5;
assert(Game.getGoldenBloomRemaining() === 5, `remaining reflects timer (got ${Game.getGoldenBloomRemaining()})`);
const prodBefore = Game.getProductionPerSecond();
assert(Math.abs(prodBefore - 1.0) < 1e-9, `production is 1.0 before boost (got ${prodBefore})`);
// Activate the boost and check the x1.5 multiplier.
Game.state.productionBoostUntil = Date.now() + 30000;
const prodBoosted = Game.getProductionPerSecond();
assert(Math.abs(prodBoosted - 1.5) < 1e-9, `production is 1.5 during boost (got ${prodBoosted})`);
const boostInfo = Game.getProductionBoostInfo();
assert(boostInfo.active === true, 'boost info reports active');
assert(boostInfo.remaining > 0 && boostInfo.remaining <= 30, `boost remaining in range (${boostInfo.remaining})`);
// Now claim the bloom. Bonus should be floor(1.5 * 15) = 22.
Game.state.productionBoostUntil = Date.now() + 30000; // keep boost on so bonus uses boosted prod
const earned = Game.claimGoldenBloom(42, 84);
assert(earned === Math.floor(1.5 * 15), `bonus is floor(prod*mult)=${Math.floor(1.5*15)} (got ${earned})`);
assert(Game.state.stardust === before + earned, 'stardust increased by exactly the bonus');
assert(Game.state.goldenBlooms.totalClaimed === 1, 'totalClaimed incremented to 1');
assert(Game.state.goldenBlooms.active === false, 'bloom no longer active after claim');
assert(global.__goldX === 42 && global.__goldY === 84, `particle coords passed through (${global.__goldX}, ${global.__goldY})`);
assert(notifications.some(n => /Golden Starbloom/.test(n)), 'golden bloom notification shown');
console.log('Test 4: boost expires correctly via getProductionBoostInfo');
Game.state.productionBoostUntil = Date.now() - 1000; // already expired
const expired = Game.getProductionBoostInfo();
assert(expired.active === false && expired.remaining === 0, 'boost inactive after expiry');
const prodExpired = Game.getProductionPerSecond();
assert(Math.abs(prodExpired - 1.0) < 1e-9, `production back to 1.0 after boost expires (got ${prodExpired})`);
console.log('Test 5: updateGoldenBloom countdown decrements active bloom and vanishes');
Game.state.goldenBlooms.active = true;
Game.state.goldenBlooms.timer = 2.0;
Game.updateGoldenBloom(1.0); // advance 1s
assert(Game.state.goldenBlooms.active === true && Game.state.goldenBlooms.timer === 1.0, `countdown decremented (timer=${Game.state.goldenBlooms.timer})`);
Game.updateGoldenBloom(1.5); // more than remaining -> vanishes
assert(Game.state.goldenBlooms.active === false && Game.state.goldenBlooms.timer === 0, 'bloom vanishes when timer hits 0');
console.log('Test 6: bloom spawns after elapsed reaches maxMs (forced)');
Game.state.goldenBlooms.active = false;
Game.state.goldenBlooms.lastSpawn = Date.now() - (Game.config.goldenBloomMaxMs + 1); // past maximum
global.__spawned = 0;
Game.updateGoldenBloom(0.1);
assert(Game.state.goldenBlooms.active === true, 'bloom spawns once maxMs has elapsed');
assert(Game.state.goldenBlooms.timer === Game.config.goldenBloomDuration, `timer set to duration (${Game.state.goldenBlooms.timer})`);
assert(global.__spawned >= 1, `UI.spawnGoldenBloom called (${global.__spawned}x)`);
console.log('Test 7: bloom does NOT spawn before minMs elapsed');
Game.state.goldenBlooms.active = false;
Game.state.goldenBlooms.lastSpawn = Date.now(); // just now
const spawnedBefore = global.__spawned || 0;
Game.updateGoldenBloom(5); // advance a few seconds (well under minMs)
assert(Game.state.goldenBlooms.active === false, 'no premature spawn before minMs');
console.log(`\nResult: ${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
+19 -1
View File
@@ -44,7 +44,10 @@
<!-- Center Panel: Buildings -->
<section id="buildings-panel">
<h2>Plants</h2>
<div class="upgrades-header">
<h2>Plants</h2>
<button id="buy-all-buildings" class="buy-all-btn" style="display:none" title="Buy as many of every building you can currently afford">Buy All</button>
</div>
<div id="buildings-list"></div>
</section>
@@ -89,6 +92,16 @@
<div class="prestige-next">
Next prestige: <span id="prestige-next-essence">0</span> ✦
</div>
<!-- Live progress toward the next Stellar Essence -->
<div class="prestige-progress">
<div class="prestige-progress-label">
Prestige progress
<span id="prestige-progress-pct">0%</span>
</div>
<div class="prestige-progress-bar">
<div id="prestige-progress-fill" class="prestige-progress-fill" style="width:0%"></div>
</div>
</div>
<p style="font-size:0.8rem; color:var(--text-muted); margin:8px 0">
Requires 1M total stardust for the first ✦. Prestige resets
stardust, buildings and upgrades — but keeps essence and
@@ -122,6 +135,11 @@
<span class="footer-label">Auto:</span>
<button id="autosave-btn" class="toggle-btn on">ON</button>
</div>
<div class="footer-section">
<span class="footer-label">Sound:</span>
<!-- Speaker icon flips to a crossed-out speaker when muted (OFF) -->
<button id="sound-btn" class="toggle-btn on" title="Toggle game sound effects">🔊</button>
</div>
<div class="footer-section">
<button id="save-btn">💾 Save</button>
<button id="export-btn">📤 Export</button>
+2 -2
View File
@@ -274,8 +274,8 @@ const Achievements = {
Game.state.achievements.push(id);
UI.showNotification(`Achievement: ${achievement.name}!`, 'achievement');
// Audio feedback
Audio.playAchievement();
// Audio feedback (gated by the sound preference)
Game.playSound('achievement');
// Particle effect for achievement unlock
const countEl = document.querySelector('#achievements-tab h3');
+131 -12
View File
@@ -23,6 +23,7 @@ const Game = {
// Save Slot System
activeSlot: 0, // index of the currently selected save slot (0..SLOTS-1)
autoSave: true, // whether the periodic autosave loop is running
soundEnabled: true, // whether game sound effects are enabled (persisted preference)
// Golden Starbloom random event state
goldenBlooms: {
@@ -56,10 +57,19 @@ const Game = {
this.setupEventListeners();
this.startGameLoop();
this.startAutoSave();
Audio.init();
// Only initialize the Web Audio context if sound is enabled. This keeps
// a muted player's browser from creating an audio context at all.
if (this.state.soundEnabled) {
Audio.init();
}
UI.init();
Stats.init();
PrestigeUI.init();
// Reflect the persisted sound preference on the footer button. If the
// player muted sound in a previous session, the button must show muted
// (green 'on' class is dropped and the icon flips to 🔇) even though the
// HTML defaults to enabled.
this.syncSoundButton();
console.log('🌌 Cosmic Garden initialized!');
},
@@ -132,6 +142,14 @@ const Game = {
});
}
// Buy All buildings button - buy as many of every building at once.
const buyAllBuildingsBtn = document.getElementById('buy-all-buildings');
if (buyAllBuildingsBtn) {
buyAllBuildingsBtn.addEventListener('click', () => {
Game.buyAllBuildings();
});
}
document.getElementById('reset-btn').addEventListener('click', () => {
if (confirm('Are you sure you want to reset all progress? This cannot be undone!')) {
this.reset();
@@ -169,6 +187,33 @@ const Game = {
localStorage.setItem('cosmicGardenAutoSave', String(this.state.autoSave));
});
// Sound toggle button - mirrors the autosave toggle pattern above, but
// uses a speaker icon instead of ON/OFF text since the button content
// is an emoji. The 'on' class keeps the green accent styling.
document.getElementById('sound-btn').addEventListener('click', () => {
this.state.soundEnabled = !this.state.soundEnabled;
const btn = document.getElementById('sound-btn');
if (this.state.soundEnabled) {
btn.classList.add('on');
btn.textContent = '🔊';
// Re-initialize the audio context now that sound is on. Some
// browsers require a user gesture to start it, which this click
// provides, so Audio.init() will succeed here.
if (!Audio.ctx) {
Audio.init();
} else {
Audio.resume();
}
UI.showNotification('Sound enabled', 'success');
} else {
btn.classList.remove('on');
btn.textContent = '🔇';
UI.showNotification('Sound muted', 'info');
}
// Persist the toggle preference immediately.
localStorage.setItem('cosmicGardenSound', String(this.state.soundEnabled));
});
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
@@ -197,8 +242,8 @@ const Game = {
this.addStardust(amount);
this.state.totalClicks++;
// Audio feedback
Audio.playClick();
// Audio feedback (gated by the sound preference)
this.playSound('click');
// Visual feedback
UI.showFloatingText(event.clientX, event.clientY, `+${this.formatNumber(amount)}`);
@@ -321,8 +366,8 @@ const Game = {
UI.showNotification(`Bought ${BUILDINGS[buildingId].name}!`, 'success');
// Audio feedback
Audio.playPurchase();
// Audio feedback (gated by the sound preference)
this.playSound('purchase');
// Particle effect on building purchase
const card = document.getElementById(`building-${buildingId}`);
@@ -379,8 +424,8 @@ const Game = {
if (count > 0) {
UI.showNotification(`Bought ${count}x ${building.name}!`, 'success');
// Audio feedback
Audio.playPurchase();
// Audio feedback (gated by the sound preference)
this.playSound('purchase');
// Particle effect on building purchase
const card = document.getElementById(`building-${buildingId}`);
@@ -395,6 +440,35 @@ const Game = {
return count;
},
/**
* Buy as many of EVERY building the player can currently afford at once,
* across all building types. Returns the total number purchased. Buildings
* cost-scales independently per type, so this loops until a full pass buys
* nothing (kept for symmetry with buyAllUpgrades / buyMaxPrestigeUpgrades
* and to stay safe if future buildings gain requirement gates); in practice
* a single pass suffices. Mirrors buyMaxBuilding per building.
*/
buyAllBuildings() {
let totalBought = 0;
// Loop until a full pass purchases nothing (buying one building type
// never unlocks another, but the loop keeps us safe against future
// requirement gates and matches the bulk-buy convention elsewhere).
let progress = true;
while (progress) {
progress = false;
for (const buildingId of Object.keys(BUILDINGS)) {
const bought = this.buyMaxBuilding(buildingId);
if (bought > 0) {
totalBought += bought;
progress = true;
}
}
}
return totalBought;
},
/**
* Buy an upgrade
*/
@@ -462,7 +536,7 @@ const Game = {
if (bought.length > 0) {
UI.showNotification(`Bought ${bought.length} upgrade(s)!`, 'success');
Audio.playPurchase();
this.playSound('purchase');
UI.update();
}
@@ -509,7 +583,7 @@ const Game = {
if (bought.length > 0) {
UI.showNotification(`Bought ${bought.length} prestige upgrade(s)!`, 'achievement');
Audio.playPurchase();
this.playSound('purchase');
UI.update();
}
@@ -529,6 +603,40 @@ const Game = {
return this.state.goldenBlooms.active ? Math.ceil(this.state.goldenBlooms.timer) : 0;
},
/**
* Play a game sound effect, gated by the player's sound preference.
* This is the single entry point for all audio so that muting sound
* (via the footer toggle) reliably silences every effect in the game —
* clicks, purchases, achievements, prestige and golden blooms alike.
*/
playSound(effect) {
if (!this.state.soundEnabled) return;
switch (effect) {
case 'click': Audio.playClick(); break;
case 'purchase': Audio.playPurchase(); break;
case 'achievement': Audio.playAchievement(); break;
case 'prestige': Audio.playPrestige(); break;
case 'goldenBloom': Audio.playGoldenBloom(); break;
}
},
/**
* Reflect the current sound preference on the footer toggle button's visual
* state (green accent class + speaker icon). Call this after loading a save
* so a muted player sees a muted button even though the HTML defaults to ON.
*/
syncSoundButton() {
const btn = document.getElementById('sound-btn');
if (!btn) return;
if (this.state.soundEnabled) {
btn.classList.add('on');
btn.textContent = '🔊';
} else {
btn.classList.remove('on');
btn.textContent = '🔇';
}
},
/**
* Award a Golden Starbloom bonus and apply the production multiplier.
* Called when the player clicks a visible bloom. Returns the amount earned.
@@ -551,7 +659,7 @@ const Game = {
this.state.productionBoostUntil = Date.now() + 30000;
UI.showNotification('⭐ Golden Starbloom! +' + this.formatNumber(bonus) + ' ✨', 'achievement');
Audio.playGoldenBloom();
this.playSound('goldenBloom');
Particles.createGoldenEffect(x, y);
UI.update();
return bonus;
@@ -704,6 +812,15 @@ const Game = {
console.error('Failed to load save:', e);
}
}
// Restore the persisted sound preference over whatever the slot save
// contained. This is stored separately from the per-slot save so a muted
// player's choice survives reloading even with no save on disk, and
// switching slots never re-enables sound unexpectedly.
const persistedSound = localStorage.getItem('cosmicGardenSound');
if (persistedSound !== null) {
this.state.soundEnabled = persistedSound === 'true';
}
},
/**
@@ -859,7 +976,8 @@ const Game = {
// from a previous slot cannot leak into a fresh slot.
productionBoostUntil: 0,
activeSlot: slot,
autoSave: this.state.autoSave
autoSave: this.state.autoSave,
soundEnabled: this.state.soundEnabled
};
UI.showNotification(
`Empty Slot ${slot + 1} - new game started!`, 'success'
@@ -941,7 +1059,8 @@ const Game = {
prestigeUpgrades: [],
goldenBlooms: { active: false, timer: 0, lastSpawn: Date.now(), totalClaimed: 0 },
activeSlot: this.state.activeSlot,
autoSave: this.state.autoSave
autoSave: this.state.autoSave,
soundEnabled: this.state.soundEnabled
};
UI.update();
UI.showNotification('Game reset!', 'warning');
+3 -3
View File
@@ -54,8 +54,8 @@ const PRESTIGE = {
prestigeUpgrades: Game.state.prestigeUpgrades || []
};
// Audio feedback
Audio.playPrestige();
// Audio feedback (gated by the sound preference)
Game.playSound('prestige');
// Particle effect for prestige
const btn = document.getElementById('prestige-btn');
@@ -88,7 +88,7 @@ const PRESTIGE = {
Game.state.prestigeUpgrades.push(upgradeId);
UI.showNotification(`Unlocked: ${upgrade.name}!`, 'achievement');
Audio.playPurchase();
Game.playSound('purchase');
UI.update();
return true;
}
+34
View File
@@ -115,6 +115,11 @@ const PrestigeUI = {
nextEl.textContent = nextEssence;
}
// Prestige progress bar: how far total stardust is toward the next
// Stellar Essence level. essence(e) = floor(sqrt(totalStardust/1e6)),
// so tier e needs e^2*1M stardust and tier (e+1) needs (e+1)^2*1M.
this.updatePrestigeProgress();
// Update prestige button state
const prestigeBtn = document.getElementById('prestige-btn');
if (prestigeBtn) {
@@ -134,6 +139,35 @@ const PrestigeUI = {
this.updateBuyMaxPrestigeButton();
},
/**
* Update the prestige progress bar showing how close total stardust is to
* earning the next Stellar Essence. essence level e = floor(sqrt(totalStardust/1e6)),
* so tier e begins at e^2*1M and tier (e+1) begins at (e+1)^2*1M; the bar
* fills linearly across that tier's stardust range and resets to 0% exactly
* when a new essence level is reached. Read-only: mutates only DOM.
*/
updatePrestigeProgress() {
const totalStardust = Game.state.totalStardust || 0;
const e = Math.floor(Math.sqrt(totalStardust / 1e6));
const tierFloor = e * e * 1e6;
const tierCeil = (e + 1) * (e + 1) * 1e6;
const progress = Math.min(1, Math.max(0, (totalStardust - tierFloor) / (tierCeil - tierFloor)));
const fillEl = document.getElementById('prestige-progress-fill');
if (fillEl) {
const pct = Math.round(progress * 100);
fillEl.style.width = `${pct}%`;
// Only apply the pulsing glow while there is progress to show, so a
// freshly-emptied bar (e.g. right after prestige) sits still.
fillEl.classList.toggle('filling', pct > 0 && pct < 100);
}
const pctLabel = document.getElementById('prestige-progress-pct');
if (pctLabel) {
pctLabel.textContent = `${Math.round(progress * 100)}%`;
}
},
/**
* Show/hide and enable/disable the "Buy Max" prestige-upgrades button
* depending on whether at least one unowned, requirement-met prestige
+29
View File
@@ -131,6 +131,35 @@ const UI = {
card.classList.toggle('locked', locked);
}
// Toggle the global "Buy All buildings" button: show it once at least
// one building type is currently affordable, and disable it otherwise.
this.updateBuyAllBuildings();
},
/**
* Show/hide and enable/disable the global "Buy All buildings" button based
* on whether at least one building type is currently affordable (at least
* one unit). Read of game state only; never mutates it. Mirrors
* Game.buyAllBuildings / buyMaxBuilding semantics.
*/
updateBuyAllBuildings() {
const btn = document.getElementById('buy-all-buildings');
if (!btn) return;
let affordableAny = false;
for (const buildingId of Object.keys(BUILDINGS)) {
const count = Game.state.buildings[buildingId] || 0;
const locked = Game.state.totalStardust < BUILDINGS[buildingId].unlockAt && count === 0;
if (locked) continue;
if (Game.getMaxAffordable(buildingId) > 0) {
affordableAny = true;
break;
}
}
btn.style.display = affordableAny ? '' : 'none';
btn.disabled = !affordableAny;
},
/**
+145
View File
@@ -0,0 +1,145 @@
/**
* Headless test for Session 15 - "Buy Max prestige upgrades" bulk-buy feature.
* Verifies Game.buyMaxPrestigeUpgrades() and PrestigeUI.updateBuyMaxPrestigeButton().
*/
// Minimal DOM stubs (only what our code touches)
const els = {
'buy-all-prestige': { disabled: false, style: {}, textContent: '' },
};
global.document = {
getElementById: (id) => els[id] || { getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 200 }) },
querySelector: () => null,
querySelectorAll: () => [],
addEventListener: () => {},
};
const notifications = [];
global.window = globalThis; // prestige.js ends with window.PRESTIGE = ...
global.UI = {
showNotification: (msg) => notifications.push(msg),
update: () => {},
updateBuyMaxPrestigeButton: () => {}, // mirror the real UI method for the test harness
};
global.Audio = { playPurchase: () => {} };
global.Particles = {};
// Load the real game engine source.
import { readFileSync } from 'fs';
const gameSrc = readFileSync('/opt/data/idle-game/js/game.js', 'utf8')
.replace(/window\.Game = Game;/, '')
+ '\nglobalThis.Game = Game;\n';
eval(gameSrc);
// Load the real prestige data + UI (game.js no longer references these).
const prestigeDataSrc = readFileSync('/opt/data/idle-game/js/prestige.js', 'utf8');
eval(prestigeDataSrc + '\nglobalThis.PRESTIGE = PRESTIGE;\nglobalThis.PRESTIGE_UPGRADES = PRESTIGE_UPGRADES;\n');
global.PRESTIGE = globalThis.PRESTIGE;
global.PRESTIGE_UPGRADES = globalThis.PRESTIGE_UPGRADES;
const prestigeUISrc = readFileSync('/opt/data/idle-game/js/prestigeUI.js', 'utf8')
.replace(/window\.PrestigeUI = PrestigeUI;/, '')
+ '\nglobalThis._RealPrestigeUI = PrestigeUI;\n';
eval(prestigeUISrc);
const realPrestigeUI = globalThis._RealPrestigeUI;
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(` OK ${msg}`); }
else { failed++; console.error(` FAIL ${msg}`); }
}
console.log('Test 1: buyMaxPrestigeUpgrades with no affordable upgrades buys nothing');
Game.state.stellarEssence = 5; // below even the cheapest upgrade (cosmicFingers cost 10)
Game.state.prestigeUpgrades = [];
Game.state.buildings = {};
const r1 = Game.buyMaxPrestigeUpgrades();
assert(r1.bought === 0, `bought count is 0 (got ${r1.bought})`);
assert(r1.ids.length === 0, 'ids array empty');
assert(Game.state.prestigeUpgrades.length === 0, 'no upgrades purchased');
assert(Game.state.stellarEssence === 5, 'essence unchanged when nothing affordable');
console.log('Test 2: buyMaxPrestigeUpgrades buys everything affordable in one pass');
// prestige upgrades are all ungated (no requirements field). cosmicFingers(10) + stellarTouch(50) = exactly 60.
Game.state.stellarEssence = 60;
Game.state.prestigeUpgrades = [];
const r2 = Game.buyMaxPrestigeUpgrades();
assert(r2.bought === 2, `bought 2 upgrades (got ${r2.bought})`);
assert(r2.ids.includes('cosmicFingers') && r2.ids.includes('stellarTouch'), 'bought cosmicFingers + stellarTouch');
const spent = 10 + 50;
assert(Game.state.stellarEssence === 60 - spent, `essence depleted correctly (${Game.state.stellarEssence}, expected ${60 - spent})`);
console.log('Test 3: requirement-style gating is a no-op for prestige upgrades (all ungated)');
// Prestige upgrades have no requirements field, so with enough essence everything buys.
Game.state.stellarEssence = 1000; // covers all six upgrades (max cost 200)
Game.state.prestigeUpgrades = [];
const r3 = Game.buyMaxPrestigeUpgrades();
assert(r3.bought === 6, `bought all 6 prestige upgrades (got ${r3.bought})`);
assert(['cosmicFingers','stellarTouch','essenceAmplifier','acceleratedGrowth','quantumGarden','temporalManipulation'].every(id => r3.ids.includes(id)), 'all six upgrades purchased');
console.log('Test 4: loop re-checks until a full pass buys nothing (idempotent convergence)');
Game.state.stellarEssence = 1000;
Game.state.prestigeUpgrades = [];
const r4 = Game.buyMaxPrestigeUpgrades();
assert(r4.bought === 6, `bought all 6 upgrades on first pass (got ${r4.bought})`);
console.log('Test 5: already-owned upgrades are skipped; no double-purchase');
Game.state.stellarEssence = 1000;
Game.state.prestigeUpgrades = ['cosmicFingers']; // already owned -> must be skipped
const r5 = Game.buyMaxPrestigeUpgrades();
assert(!r5.ids.includes('cosmicFingers'), 'cosmicFingers not re-bought (already owned)');
console.log('Test 6: idempotent second call buys nothing');
const beforeEssence = Game.state.stellarEssence;
const r6 = Game.buyMaxPrestigeUpgrades();
assert(r6.bought === 0, `second call buys 0 (got ${r6.bought})`);
assert(Game.state.stellarEssence === beforeEssence, 'essence unchanged on no-op call');
console.log('Test 7: notifications fire on purchase, silence on no-op');
notifications.length = 0;
Game.state.stellarEssence = 1000;
Game.state.prestigeUpgrades = [];
Game.state.buildings = {};
Game.buyMaxPrestigeUpgrades();
assert(notifications.some(n => /Bought .* prestige upgrade/.test(n)), 'notification shown when something bought');
notifications.length = 0;
const r7 = Game.buyMaxPrestigeUpgrades(); // now nothing left affordable (all bought)
assert(!notifications.some(n => /Bought/.test(n)) || r7.bought === 0, `no purchase notification on no-op (bought=${r7.bought})`);
console.log('Test 8: exercise the REAL PrestigeUI.updateBuyMaxPrestigeButton method against real PRESTIGE_UPGRADES');
Game.state.stellarEssence = 0;
Game.state.prestigeUpgrades = [];
Game.state.buildings = {};
realPrestigeUI.updateBuyMaxPrestigeButton();
assert(els['buy-all-prestige'].style.display === 'none', `real UI hides Buy Max when essence=0 (display='${els['buy-all-prestige'].style.display}')`);
// cosmicFingers is the cheapest real prestige upgrade (cost 10, no requirements),
// so at essence=10 a button should appear and be enabled.
Game.state.stellarEssence = 10;
realPrestigeUI.updateBuyMaxPrestigeButton();
assert(els['buy-all-prestige'].style.display !== 'none', 'real UI shows Buy Max when a cheap unowned prestige upgrade is affordable');
assert(els['buy-all-prestige'].disabled === false, 'real UI Buy Max enabled when affordable');
// Give 0 essence -> hidden again.
Game.state.stellarEssence = 0;
realPrestigeUI.updateBuyMaxPrestigeButton();
assert(els['buy-all-prestige'].style.display === 'none', 'real UI hides Buy Max when nothing affordable');
// Requirement-gated affordability: stellarTouch (cost 50, no building req) is
// affordable but essenceAmplifier (cost 25, needs solarLeaf>=25) is gated. The
// button should still show because at least one unowned upgrade is affordable.
Game.state.stellarEssence = 100;
Game.state.buildings = { starSprout: 1 }; // solarLeaf < 25 -> essenceAmplifier gated, but stellarTouch still affordable
realPrestigeUI.updateBuyMaxPrestigeButton();
assert(els['buy-all-prestige'].style.display !== 'none', 'real UI shows Buy Max when a non-gated upgrade is affordable even if gated ones are locked');
console.log('Test 9: real UI reflects state after an actual buy through Game.buyMaxPrestigeUpgrades');
Game.state.stellarEssence = 10;
Game.state.prestigeUpgrades = [];
Game.buyMaxPrestigeUpgrades(); // buys cosmicFingers (only affordable), essence now 0
assert(Game.state.prestigeUpgrades.includes('cosmicFingers'), 'cosmicFingers purchased via real Game method');
realPrestigeUI.updateBuyMaxPrestigeButton();
assert(els['buy-all-prestige'].style.display === 'none', 'real UI hides Buy Max after all affordable upgrades bought (essence now 0)');
console.log(`\nResult: ${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);