fuzzyworld

explodorama / files / src / sim / shop.ts

shop.ts

4.0 KB · history · blame

1import { takeCharge } from './charges'
2import { LAST_LEVEL } from './levelgen'
3import { stream } from './rng'
4import { applyPowerUp, heldAlready, POWER_UP_WEIGHTS } from './turn'
5import { 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 */
14export const SHOP_EVERY = 3
15export const SHOP_SIZE = 4
16
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 */
22export const PRICES: Record<OfferKind, number> = {
23 bombPlus: 4, firePlus: 4, tnt: 7, fusePlus: 5, run: 6, kick: 6, throw: 6, diagonal: 6, slide: 8, rpg: 8, detonator: 8,
24 wallPass: 6, bombPass: 6, star: 6, heart: 12, coinBag: 0, proximity: 10,
25 pierce: 4, power: 4, nuke: 6, shield: 5, freeze: 4, decoy: 3, icbm: 6,
26 // Charges since 2026-10-25 (power-ups at 9 and 10 until then).
27 flamePass: 7, teleport: 7,
28 acid: 6,
29}
30
31/** How often each charge comes up in the shop: as often as an uncommon power-up. */
32const CHARGE_WEIGHT = 7
33
34/** The price of the next restock: 1 coin, one more each time in the same shop. */
35export function rerollPrice(shop: Shop): number {
36 return 1 + shop.rerolls
37}
38
39/** Whether clearing `level` opens the shop. */
40export 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. */
45export 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
61export 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. */
67export 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)
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)
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). */
80export 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}