explodorama / files / src / sim / shop.ts
shop.ts
d3e4ed26 2026-10-25 Pavel Safronov | 1 | import { takeCharge } from './charges' |
e78b7781 2026-10-25 Pavel Safronov | 2 | import { LAST_LEVEL } from './levelgen' |
| 3 | import { stream } from './rng' | |
| 4 | import { applyPowerUp, heldAlready, POWER_UP_WEIGHTS } from './turn' | |
| 5 | import { CHARGE_IDS, isCharge, type GameEvent, type GameState, type OfferKind, type Shop } from './types' | |
| 6 | ||
| 7 | /** | |
| 8 | * The shop (PLAN.md §8; user's choices, 2026-10-24): it opens after every SHOP_EVERY-th cleared | |
| 9 | * level (not the last), before the next one starts. It sells power-ups (none already held; no | |
| 10 | * Coin bag — coins for coins) and charges, SHOP_SIZE drawn by the gem's rarity weights, priced | |
| 11 | * by rarity; a paid restock that gets dearer each time. Its draws come from the seed, the level | |
| 12 | * and the restock count, so a run replays exactly. Buying takes no turn. | |
| 13 | */ | |
| 14 | export const SHOP_EVERY = 3 | |
| 15 | export const SHOP_SIZE = 4 | |
| 16 | ||
0b63d158 2026-10-25 Pavel Safronov | 17 | /** |
| 18 | * Prices by rarity: common things cheap, rare ones dear (user's choice). Lowered 2026-10-24 (user | |
| 19 | * request, "leaning towards cheap"): three levels earn about 12 coins, or about 27 within par each | |
| 20 | * time (PLAN.md §8), so the first shop buys two cheap things, or three or four with par bonuses. | |
| 21 | */ | |
e78b7781 2026-10-25 Pavel Safronov | 22 | export const PRICES: Record<OfferKind, number> = { |
e6bb7c42 2026-10-25 Pavel Safronov | 23 | bombPlus: 4, firePlus: 4, tnt: 7, fusePlus: 5, run: 6, kick: 6, throw: 6, diagonal: 6, slide: 8, rpg: 8, detonator: 8, |
fcf44f32 2026-10-25 Pavel Safronov | 24 | wallPass: 6, bombPass: 6, star: 6, heart: 12, coinBag: 0, proximity: 10, |
0b63d158 2026-10-25 Pavel Safronov | 25 | pierce: 4, power: 4, nuke: 6, shield: 5, freeze: 4, decoy: 3, icbm: 6, |
fcf44f32 2026-10-25 Pavel Safronov | 26 | // Charges since 2026-10-25 (power-ups at 9 and 10 until then). |
| 27 | flamePass: 7, teleport: 7, | |
5f066960 2026-10-26 Pavel Safronov | 28 | acid: 6, |
e78b7781 2026-10-25 Pavel Safronov | 29 | } |
| 30 | ||
| 31 | /** How often each charge comes up in the shop: as often as an uncommon power-up. */ | |
| 32 | const CHARGE_WEIGHT = 7 | |
| 33 | ||
0b63d158 2026-10-25 Pavel Safronov | 34 | /** The price of the next restock: 1 coin, one more each time in the same shop. */ |
e78b7781 2026-10-25 Pavel Safronov | 35 | export function rerollPrice(shop: Shop): number { |
0b63d158 2026-10-25 Pavel Safronov | 36 | return 1 + shop.rerolls |
e78b7781 2026-10-25 Pavel Safronov | 37 | } |
| 38 | ||
| 39 | /** Whether clearing `level` opens the shop. */ | |
| 40 | export function shopAfter(level: number): boolean { | |
| 41 | return level % SHOP_EVERY === 0 && level < LAST_LEVEL | |
| 42 | } | |
| 43 | ||
| 44 | /** The shop's items: SHOP_SIZE different ones, drawn without repeats, listed power-ups first. */ | |
| 45 | export function drawShop(s: GameState, rerolls: number): OfferKind[] { | |
| 46 | const pool: [OfferKind, number][] = [ | |
| 47 | ...POWER_UP_WEIGHTS.filter(([kind]) => kind !== 'coinBag' && !heldAlready(s, kind)), | |
| 48 | ...CHARGE_IDS.map((id): [OfferKind, number] => [id, CHARGE_WEIGHT]), | |
| 49 | ] | |
| 50 | const order = pool.map(([kind]) => kind) | |
| 51 | const rng = stream(s.seed, 'shop', s.level * 100 + rerolls) | |
| 52 | const picked = new Set<OfferKind>() | |
| 53 | while (picked.size < SHOP_SIZE && pool.length) { | |
| 54 | let r = rng.next() * pool.reduce((sum, [, w]) => sum + w, 0) | |
| 55 | const i = Math.max(0, pool.findIndex(([, w]) => (r -= w) < 0)) | |
| 56 | picked.add(pool.splice(i, 1)[0]![0]) | |
| 57 | } | |
| 58 | return order.filter((kind) => picked.has(kind)) | |
| 59 | } | |
| 60 | ||
| 61 | export function openShop(s: GameState, events: GameEvent[]): void { | |
| 62 | s.shop = { items: drawShop(s, 0), sold: [], rerolls: 0 } | |
| 63 | events.push({ type: 'shopOpened' }) | |
| 64 | } | |
| 65 | ||
| 66 | /** Buys `kind`: it must be on offer, not sold yet, and affordable; it works at once. */ | |
| 67 | export function buy(s: GameState, kind: OfferKind, events: GameEvent[]): void { | |
| 68 | const shop = s.shop | |
| 69 | if (!shop || !shop.items.includes(kind) || shop.sold.includes(kind)) throw new Error(`${kind} is not for sale`) | |
| 70 | if (s.coins < PRICES[kind]) throw new Error(`${kind} costs ${PRICES[kind]}, ${s.coins} coins`) | |
| 71 | s.coins -= PRICES[kind] | |
| 72 | shop.sold.push(kind) | |
d3e4ed26 2026-10-25 Pavel Safronov | 73 | // A charge bought goes in the slot; with another kind held, the player chooses (PLAN.md §19.7). |
| 74 | if (isCharge(kind)) takeCharge(s, kind, events) | |
e78b7781 2026-10-25 Pavel Safronov | 75 | else applyPowerUp(s, kind) |
| 76 | events.push({ type: 'bought', kind, price: PRICES[kind] }) | |
| 77 | } | |
| 78 | ||
| 79 | /** Restocks the shop for `rerollPrice` coins: a new draw (what was bought stays bought). */ | |
| 80 | export function reroll(s: GameState, events: GameEvent[]): void { | |
| 81 | const shop = s.shop | |
| 82 | if (!shop) throw new Error('no shop') | |
| 83 | const price = rerollPrice(shop) | |
| 84 | if (s.coins < price) throw new Error(`a restock costs ${price}, ${s.coins} coins`) | |
| 85 | s.coins -= price | |
| 86 | shop.rerolls++ | |
| 87 | shop.items = drawShop(s, shop.rerolls) | |
| 88 | shop.sold = [] | |
| 89 | events.push({ type: 'rerolled', price }) | |
| 90 | } |