Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 --------------------------------------------------
Expand Down
34 changes: 32 additions & 2 deletions js/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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';
}

Expand All @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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],
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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],
Expand All @@ -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],
Expand All @@ -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,
Expand All @@ -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. */
Expand Down
10 changes: 10 additions & 0 deletions js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions tests/game.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
25 changes: 24 additions & 1 deletion tests/save.test.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions tests/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions tests/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
dist2,
deg2rad,
pick,
shuffle,
removeWhere,
TAU,
} from '../js/utils.js';
Expand Down Expand Up @@ -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];
Expand Down