From 8f593a95b199c19eb9ec9166ff8a28f0b7a87ca3 Mon Sep 17 00:00:00 2001 From: luiz Date: Thu, 11 Jun 2026 19:30:18 -0700 Subject: [PATCH] Armory variety: withhold two random offers each wave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The between-wave armory always listed the same five upgrade lines, so every run shopped identically. Now each wave clear rolls two of the five slots to withhold, leaving three on the counter — build order varies run to run without touching the underlying economy. The roll happens once on wave clear (not in getShopItems, which rebuilds every frame and would flicker) and rides along in the checkpoint, so revisiting the armory after a reload shows the same stock instead of re-rolling into a fresh one. The count lives in config.shop.dropPerWave. Tests cover the drop count, the keep-at-least-one clamp, the per-wave reroll, and reload stability; the existing shop tests opt out of the drop (setup.js) so they stay deterministic. Co-Authored-By: Claude Opus 4.8 (1M context) --- js/config.js | 1 + js/game.js | 34 ++++++++++++++++++++++-- js/utils.js | 10 +++++++ tests/game.test.js | 63 +++++++++++++++++++++++++++++++++++++++++++++ tests/save.test.js | 25 +++++++++++++++++- tests/setup.js | 7 +++++ tests/utils.test.js | 15 +++++++++++ 7 files changed, 152 insertions(+), 3 deletions(-) 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];