diff --git a/js/config.js b/js/config.js index d31e93b..36ddb5b 100644 --- a/js/config.js +++ b/js/config.js @@ -336,6 +336,7 @@ export const CONFIG = { fireRateCosts: [30, 50, 75, 110, 160, 220], fireRateFactor: 0.82, // fire interval multiplier per level twinBarrelCost: 100, // one-time: a second barrel firing side by side + dropPerWave: 2, // omit this many random offers each visit, for run-to-run variety }, // --- 3D render / bloom -------------------------------------------------- diff --git a/js/game.js b/js/game.js index 6a2378c..af8ca5c 100644 --- a/js/game.js +++ b/js/game.js @@ -5,7 +5,7 @@ // --------------------------------------------------------------------------- import { CONFIG } from './config.js'; -import { clamp, rand, randInt, dist2, removeWhere, pick, TAU } from './utils.js'; +import { clamp, rand, randInt, dist2, removeWhere, pick, shuffle, TAU } from './utils.js'; import { City, Turret, @@ -73,6 +73,7 @@ export class Game { this.waveLeaks = 0; // missiles that reached the ground this wave this.waveBreakdown = null; // credit sources for the shop summary this._shopRects = []; // hit-test rects, rebuilt each shop frame + this.shopDropped = []; // offer keys omitted from this visit's armory (rerolled each wave) this.state = 'menu'; // menu | playing | intermission | gameover this.scoreboard = scoreboard; // injectable for tests @@ -282,6 +283,10 @@ export class Game { this.nextWave = s.wave; this.waveEarned = s.waveEarned ?? 0; this.waveBreakdown = s.waveBreakdown ?? null; + // Honour the saved reroll so a reload shows the same stock; older saves + // without it just roll a fresh offer. + if (Array.isArray(s.shopDropped)) this.shopDropped = s.shopDropped; + else this.rollShopOffer(); this.state = 'intermission'; } @@ -304,6 +309,7 @@ export class Game { this.laserBeams = []; this.laserBeamLive = null; this.shieldLevel = 0; + this.shopDropped = []; this.pendingNukes = []; this.mushrooms = []; this.paused = false; // restarting (R) always unpauses @@ -367,6 +373,7 @@ export class Game { credits: this.credits, waveEarned: this.waveEarned, waveBreakdown: this.waveBreakdown, + shopDropped: this.shopDropped, shieldLevel: this.shieldLevel, ciws: { fireRateLevel: this.ciws.fireRateLevel, twin: this.ciws.twin }, interceptor: { @@ -461,6 +468,7 @@ export class Game { this.nextWave = this.wave + 1; this.state = 'intermission'; this.shopSelected = 0; // touch armory: detail panel opens on the top item + this.rollShopOffer(); // pick this visit's withheld offers before checkpointing this.laserBeamLive = null; this.laser.target = null; // A cleared wave is a checkpoint — closing the tab at the armory (or any @@ -2124,6 +2132,20 @@ export class Game { // ------------------------------------------------------------------------- // Shop (between-wave armory) // ------------------------------------------------------------------------- + /** The fixed roster of shop offer slots — one per upgrade line. */ + static SHOP_KEYS = ['interceptor', 'shield', 'laser', 'fireRate', 'twin']; + + /** + * Roll which offers this armory visit withholds. Dropping a couple at random + * each wave means no two runs hand you the same shop, so build order varies. + * Rerolled once per wave clear and saved with the checkpoint, so revisiting + * the armory (reload, re-enter) shows the same stock rather than re-rolling. + */ + rollShopOffer() { + const n = clamp(CONFIG.shop.dropPerWave ?? 0, 0, Game.SHOP_KEYS.length - 1); + this.shopDropped = shuffle(Game.SHOP_KEYS).slice(0, n); + } + /** Build the current shop offer list (availability/cost reflect game state). */ getShopItems() { const S = CONFIG.shop; @@ -2134,6 +2156,7 @@ export class Game { if (!iw.owned) { items.push({ + key: 'interceptor', label: X.interceptor.label, desc: X.interceptor.desc, cost: S.interceptorCost, @@ -2147,6 +2170,7 @@ export class Game { const ilMax = il >= S.interceptorCooldownCosts.length; const cds = CONFIG.interceptor.cooldowns; items.push({ + key: 'interceptor', label: ilMax ? X.interceptorReload.labelMax : X.interceptorReload.label(il + 1), desc: X.interceptorReload.desc(iw.cooldown), cost: ilMax ? null : S.interceptorCooldownCosts[il], @@ -2161,6 +2185,7 @@ export class Game { const slMax = sl >= CONFIG.shield.costs.length; const rts = CONFIG.shield.rechargeTimes; items.push({ + key: 'shield', label: sl === 0 ? X.shield.label @@ -2178,6 +2203,7 @@ export class Game { const L = CONFIG.laser; if (!this.laser.owned) { items.push({ + key: 'laser', label: X.laser.label, desc: X.laser.desc, cost: L.cost, @@ -2190,6 +2216,7 @@ export class Game { const ll = this.laser.level; const llMax = ll >= L.upgradeCosts.length; items.push({ + key: 'laser', label: llMax ? X.laserRecharge.labelMax : X.laserRecharge.label(ll + 1), desc: X.laserRecharge.desc(this.laser.rechargeTime), cost: llMax ? null : L.upgradeCosts[ll], @@ -2203,6 +2230,7 @@ export class Game { const fl = this.ciws.fireRateLevel; const frMax = fl >= S.fireRateCosts.length; items.push({ + key: 'fireRate', label: frMax ? X.fireRate.labelMax : X.fireRate.label(fl + 1), desc: X.fireRate.desc, cost: frMax ? null : S.fireRateCosts[fl], @@ -2214,6 +2242,7 @@ export class Game { const hasTwin = this.ciws.twin; items.push({ + key: 'twin', label: X.twin.label, desc: hasTwin ? X.twin.descOwned : X.twin.desc, cost: hasTwin ? null : S.twinBarrelCost, @@ -2223,7 +2252,8 @@ export class Game { info: X.twin.info, }); - return items; + // This wave's reroll withholds a couple of offers for run-to-run variety. + return items.filter((it) => !this.shopDropped.includes(it.key)); } /** Deterministic layout for the shop rows + the proceed button. */ diff --git a/js/utils.js b/js/utils.js index 2756161..098834d 100644 --- a/js/utils.js +++ b/js/utils.js @@ -27,6 +27,16 @@ export const deg2rad = (d) => (d * Math.PI) / 180; /** Pick a random element of an array. */ export const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; +/** Fisher-Yates shuffle, returning a new array (the input is left untouched). */ +export const shuffle = (arr) => { + const out = arr.slice(); + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +}; + /** * Remove array elements for which `pred` returns true, in place. * Used every frame to cull dead entities without allocating new arrays. diff --git a/tests/game.test.js b/tests/game.test.js index 6c684f8..f665ae3 100644 --- a/tests/game.test.js +++ b/tests/game.test.js @@ -471,6 +471,69 @@ describe('Shop', () => { }); }); +describe('Shop offer variety (random drops)', () => { + const KEYS = ['interceptor', 'shield', 'laser', 'fireRate', 'twin']; + + // Opt back into the drop that setup.js disables for the deterministic tests. + const withDrop = (n, fn) => { + const orig = CONFIG.shop.dropPerWave; + CONFIG.shop.dropPerWave = n; + try { + return fn(); + } finally { + CONFIG.shop.dropPerWave = orig; + } + }; + + it('withholds dropPerWave distinct offers, and the shop hides them', () => { + const g = newGame(); + g.startGame(); + withDrop(2, () => { + withRandom(0.5, () => g.rollShopOffer()); + expect(g.shopDropped).toHaveLength(2); + expect(new Set(g.shopDropped).size).toBe(2); // distinct slots + for (const k of g.shopDropped) expect(KEYS).toContain(k); + + const shown = g.getShopItems().map((i) => i.key); + expect(shown).toHaveLength(KEYS.length - 2); + for (const dropped of g.shopDropped) expect(shown).not.toContain(dropped); + }); + }); + + it('never drops more than the roster minus one, even if misconfigured', () => { + const g = newGame(); + g.startGame(); + withDrop(99, () => { + g.rollShopOffer(); + expect(g.shopDropped).toHaveLength(KEYS.length - 1); + expect(g.getShopItems()).toHaveLength(1); // always at least one thing to buy + }); + }); + + it('a fresh run and disabled drop offer the full roster', () => { + const g = newGame(); + g.startGame(); // dropPerWave is 0 under test + g.rollShopOffer(); + expect(g.shopDropped).toHaveLength(0); + expect(g.getShopItems()).toHaveLength(KEYS.length); + }); + + it('clearing a wave rerolls the withheld offers', () => { + const g = newGame(); + g.startGame(); + withDrop(2, () => { + // Two different RNG draws must be able to withhold different slots. + withRandom(0.1, () => g.endWave()); + const first = g.shopDropped.slice().sort(); + withRandom(0.9, () => g.endWave()); + const second = g.shopDropped.slice().sort(); + expect(first).toHaveLength(2); + expect(second).toHaveLength(2); + expect(first).not.toEqual(second); + }); + }); +}); + describe('update() guards', () => { it('is a no-op while paused', () => { const g = newGame(); diff --git a/tests/save.test.js b/tests/save.test.js index e55b5c4..eb6da36 100644 --- a/tests/save.test.js +++ b/tests/save.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'bun:test'; import { SaveSlot, SAVE_VERSION } from '../js/save.js'; import { CONFIG } from '../js/config.js'; -import { newGame } from './helpers.js'; +import { newGame, withRandom } from './helpers.js'; /** Minimal in-memory localStorage stand-in. */ function fakeStorage() { @@ -145,6 +145,29 @@ describe('Game checkpointing', () => { expect(reborn.saveSlot.load().interceptor.owned).toBe(true); }); + it('saves the armory reroll, so a reload shows the same withheld stock', () => { + const orig = CONFIG.shop.dropPerWave; + CONFIG.shop.dropPerWave = 2; + try { + const storage = fakeStorage(); + const game = newSavingGame(storage); + game.startGame(); + withRandom(0.3, () => clearWave(game)); // rolls the drop, then checkpoints it + expect(game.shopDropped).toHaveLength(2); + + // Reloading must replay the saved offer rather than re-rolling a new one, + // so a player can't reroll the shop by closing and reopening the tab. + const reborn = newSavingGame(storage); + reborn.continueGame(); + expect(reborn.shopDropped).toEqual(game.shopDropped); + expect(reborn.getShopItems().map((i) => i.key)).toEqual( + game.getShopItems().map((i) => i.key) + ); + } finally { + CONFIG.shop.dropPerWave = orig; + } + }); + it('round-trips every upgrade ladder, not just ownership flags', () => { const storage = fakeStorage(); const game = newSavingGame(storage); diff --git a/tests/setup.js b/tests/setup.js index c71f48f..1bf3067 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -4,6 +4,13 @@ // inert until unlock(), so nothing else from the DOM is needed. // --------------------------------------------------------------------------- +import { CONFIG } from '../js/config.js'; + +// The armory withholds a couple of random offers each wave (run-to-run variety), +// which would make shop contents nondeterministic across the behaviour tests. +// Default it off here; the tests that exercise the drop opt back in explicitly. +CONFIG.shop.dropPerWave = 0; + const noop = () => {}; if (!globalThis.window) { diff --git a/tests/utils.test.js b/tests/utils.test.js index 249b387..822edf3 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -8,6 +8,7 @@ import { dist2, deg2rad, pick, + shuffle, removeWhere, TAU, } from '../js/utils.js'; @@ -78,6 +79,20 @@ describe('pick', () => { }); }); +describe('shuffle', () => { + it('returns a new array with the same elements, leaving the input untouched', () => { + const arr = [1, 2, 3, 4, 5]; + const out = shuffle(arr); + expect(out).not.toBe(arr); + expect(arr).toEqual([1, 2, 3, 4, 5]); // original unchanged + expect([...out].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]); + }); + it('handles empty and single-element arrays', () => { + expect(shuffle([])).toEqual([]); + expect(shuffle([7])).toEqual([7]); + }); +}); + describe('removeWhere', () => { it('removes matching elements in place, preserving order', () => { const arr = [1, 2, 3, 4, 5, 6];