// Contracts and random events: the "active play" layer. // // Contracts: up to `maxOffers` offers are on the board; accept one and sell // `target` units before the time runs out (a bit more than your current sales // pace, so it takes some effort) for a reward. Events: now and then an // opportunity appears for a short window and must be claimed; rarer incidents // (like a price crash for the raw material) just happen. export function salesEstimate(state, d) { const rate = Math.max(Math.min(d.rate, d.demand), 1); return { rate, income: rate * state.price * d.valueMultiplier }; } /** A random element, safe even if rng() ever returns exactly 1. */ function pick(list, rng) { return list[Math.min(list.length - 1, Math.floor(rng() * list.length))]; } // ------------------------------------------------------------------ contracts function makeOffer(state, ctx, d, mods, rng) { const cfg = ctx.rules.contracts; const template = pick(cfg.templates, rng); const type = pick(cfg.rewards, rng); const { rate, income } = salesEstimate(state, d); const factor = template.rewardFactor * mods.contract; let amount; if (type === 'funds') amount = income * template.seconds * factor; else if (type === 'research') amount = Math.max(1, Math.round(cfg.researchPerFactor * factor)); else if (type === 'chips') amount = Math.max(1, Math.round(cfg.chipsPerFactor * factor)); else amount = 1 + (cfg.boostMultiplier - 1) * Math.min(2, template.rewardFactor); // boost: a multiplier return { template: template.id, seconds: template.seconds, target: Math.ceil(rate * template.seconds * template.targetFactor), reward: { type, amount }, }; } function grantReward(state, ctx, offer) { const { type, amount } = offer.reward; if (type === 'funds') state.funds += amount; else if (type === 'research') state.rp += amount; else if (type === 'chips') state.chips = Math.min(state.chips + amount, ctx.rules.gamble.maxChips); else state.events.buffs.push({ type: 'global_mult', value: amount, remaining: offer.seconds }); } /** Take offer number `index` from the board. Only one contract can be active. */ export function acceptContract(state, ctx, index) { const c = state.contracts; if (c.active || !c.offers[index]) return false; const [offer] = c.offers.splice(index, 1); c.active = { offer, progress: 0, remaining: offer.seconds }; return true; } export function abandonContract(state) { if (!state.contracts.active) return false; state.contracts.active = null; return true; } export function stepContracts(state, ctx, dt, sold, d, mods, rng, offline, totals) { if (offline) return; const c = state.contracts; const cfg = ctx.rules.contracts; if (c.active) { c.active.progress += sold; c.active.remaining -= dt; if (c.active.progress >= c.active.offer.target) { grantReward(state, ctx, c.active.offer); totals.notices.push({ kind: 'contract_done', reward: c.active.offer.reward, seconds: c.active.offer.seconds }); state.stats.contractsDone += 1; c.active = null; } else if (c.active.remaining <= 0) { totals.notices.push({ kind: 'contract_failed' }); c.active = null; } } state.contractTimer += dt; if (state.contractTimer >= cfg.offerEverySeconds) { state.contractTimer = 0; c.offers.push(makeOffer(state, ctx, d, mods, rng)); if (c.offers.length > cfg.maxOffers) c.offers.shift(); // the oldest offer expires } } // --------------------------------------------------------------------- events function pickEventType(cfg, rng) { const total = cfg.types.reduce((sum, t) => sum + t.weight, 0); let roll = rng() * total; for (const type of cfg.types) { roll -= type.weight; if (roll < 0) return type; } return cfg.types[0]; } export function stepEvents(state, ctx, dt, d, rng, offline, totals) { const ev = state.events; const cfg = ctx.rules.events; for (const buff of ev.buffs) buff.remaining -= dt; ev.buffs = ev.buffs.filter((buff) => buff.remaining > 0); if (offline) return; if (ev.current) { ev.current.remaining -= dt; if (ev.current.remaining <= 0) ev.current = null; } state.eventTimer -= dt; if (state.eventTimer > 0 || ev.current) return; state.eventTimer = cfg.minSeconds + rng() * (cfg.maxSeconds - cfg.minSeconds); const type = pickEventType(cfg, rng); if (type.auto) { ev.buffs.push({ type: type.buff.type, value: type.buff.value, remaining: type.buff.seconds }); totals.notices.push({ kind: 'event_incident', type: type.id }); } else { ev.current = { type: type.id, remaining: cfg.claimSeconds }; totals.notices.push({ kind: 'event_opportunity', type: type.id }); } } /** Claim the opportunity currently on offer. */ export function claimEvent(state, ctx, d) { const ev = state.events; if (!ev.current) return false; const cfg = ctx.rules.events; const type = cfg.types.find((t) => t.id === ev.current.type); if (type.buff) ev.buffs.push({ type: type.buff.type, value: type.buff.value, remaining: type.buff.seconds }); else state.funds += salesEstimate(state, d).income * cfg.windfallIncomeSeconds; state.stats.eventsClaimed += 1; ev.current = null; return true; }