feat: New features (research, invest, gambling, new game+, trophies)

This commit is contained in:
gauvainboiche
2026-10-08 14:03:00 +02:00
parent 1caa4d92b9
commit fc0d5e3ba4
56 changed files with 4332 additions and 249 deletions
+35
View File
@@ -0,0 +1,35 @@
// Achievements: permanent small bonuses for milestones. Each one reads a
// single statistic, so adding one is a line in data/achievements.json (plus
// its title in the core locale files).
export function statValue(state, stat) {
switch (stat) {
case 'lifetimeProduced':
return state.lifetimeProduced;
case 'generatorsOwned':
return Object.values(state.generators).reduce((sum, n) => sum + n, 0);
case 'upgradesOwned':
return state.upgrades.length;
case 'researchOwned':
return state.research.length;
case 'prestiges':
return state.legacy.prestiges;
case 'chips':
return state.chips;
default:
return state.stats[stat] ?? 0; // investProfit, gambleWins, contractsDone, eventsClaimed...
}
}
/** Grant every achievement whose condition is now met. Returns the new ids. */
export function checkAchievements(state, ctx) {
const granted = [];
for (const a of ctx.achievements) {
if (state.achievements.includes(a.id)) continue;
if (statValue(state, a.condition.stat) >= a.condition.min) {
state.achievements.push(a.id);
granted.push(a.id);
}
}
return granted;
}
+64 -16
View File
@@ -1,27 +1,75 @@
import { EFFECT_TYPES } from './effects.js';
/**
* Bundle the static game data (data/*.json) with lookup tables and validate it,
* so a typo in a JSON file fails loudly at startup instead of mid-game.
*/
export function buildContext({ rules, generators, upgrades }) {
const generatorsById = new Map(generators.map((g) => [g.id, g]));
const upgradesById = new Map(upgrades.map((u) => [u.id, u]));
function byId(list, what) {
const map = new Map(list.map((item) => [item.id, item]));
if (map.size !== list.length) throw new Error(`Duplicate ${what} id`);
return map;
}
if (generatorsById.size !== generators.length) throw new Error('Duplicate generator id');
if (upgradesById.size !== upgrades.length) throw new Error('Duplicate upgrade id');
function checkEffect(owner, effect, generatorsById) {
if (!EFFECT_TYPES.includes(effect?.type)) {
throw new Error(`${owner}: unknown effect type "${effect?.type}"`);
}
if (effect.target && !generatorsById.has(effect.target)) {
throw new Error(`${owner}: unknown generator "${effect.target}"`);
}
}
/**
* Bundle the static game data (public/data/*.json) with lookup tables and
* validate it, so a typo in a JSON file fails loudly at startup (and in the
* tests) instead of mid-game.
*/
export function buildContext({
rules,
generators,
upgrades,
research,
legacy,
achievements,
investments,
tabs,
}) {
const generatorsById = byId(generators, 'generator');
const upgradesById = byId(upgrades, 'upgrade');
const researchById = byId(research, 'research');
const perksById = byId(legacy, 'legacy perk');
const achievementsById = byId(achievements, 'achievement');
const societiesById = byId(investments.societies, 'society');
byId(tabs, 'tab');
for (const u of upgrades) {
if (!EFFECT_TYPES.includes(u.effect?.type)) {
throw new Error(`Upgrade "${u.id}": unknown effect type "${u.effect?.type}"`);
}
if (u.effect.target && !generatorsById.has(u.effect.target)) {
throw new Error(`Upgrade "${u.id}": unknown generator "${u.effect.target}"`);
}
checkEffect(`Upgrade "${u.id}"`, u.effect, generatorsById);
if (u.requires && !upgradesById.has(u.requires)) {
throw new Error(`Upgrade "${u.id}": unknown requirement "${u.requires}"`);
}
}
for (const node of research) {
checkEffect(`Research "${node.id}"`, node.effect, generatorsById);
for (const req of node.requires) {
if (!researchById.has(req)) throw new Error(`Research "${node.id}": unknown requirement "${req}"`);
}
}
for (const perk of legacy) checkEffect(`Perk "${perk.id}"`, perk.effect, generatorsById);
for (const a of achievements) checkEffect(`Achievement "${a.id}"`, a.effect, generatorsById);
for (const s of investments.societies) {
if (!investments.tiers[s.tier]) throw new Error(`Society "${s.id}": unknown tier "${s.tier}"`);
}
return { rules, generators, upgrades, generatorsById, upgradesById };
return {
rules,
generators,
upgrades,
research,
legacy,
achievements,
investments,
tabs,
generatorsById,
upgradesById,
researchById,
perksById,
achievementsById,
societiesById,
};
}
+144
View File
@@ -0,0 +1,144 @@
// 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;
}
+58 -16
View File
@@ -1,5 +1,7 @@
// Upgrade effects. To add a new effect type: register it here, add its
// `effect.<type>` string to the core locale files, and use it in upgrades.json.
// Effects: the single vocabulary used by upgrades, research, legacy perks,
// achievements, chips and temporary buffs. To add a new effect type: register
// it here, add its `effect.<type>` text to the core locale files, then use it
// in any data file.
export function newModifiers() {
return {
@@ -12,29 +14,69 @@ export function newModifiers() {
batchDiscount: 1,
batchSize: 1,
autoBuyMaterial: false,
dividend: 1,
reliability: 0,
offline: 0,
contract: 1,
rp: 1,
generatorCost: 1,
startFunds: 0,
priceAdvisor: false,
materialPrice: 1,
legacyGain: 1,
};
}
// `times` lets a repeatable effect (a perk level, N achievements) stack.
function mult(key) {
return (m, e, times) => {
m[key] *= e.value ** times;
};
}
function add(key) {
return (m, e, times) => {
m[key] += e.value * times;
};
}
function flag(key) {
return (m) => {
m[key] = true;
};
}
const APPLY = {
click_mult: (m, e) => void (m.click *= e.value),
generator_mult: (m, e) => void (m.generators[e.target] = (m.generators[e.target] ?? 1) * e.value),
global_mult: (m, e) => void (m.global *= e.value),
demand_mult: (m, e) => void (m.demand *= e.value),
value_mult: (m, e) => void (m.value *= e.value),
material_efficiency: (m, e) => void (m.materialEfficiency *= e.value),
batch_discount: (m, e) => void (m.batchDiscount *= e.value),
batch_size_mult: (m, e) => void (m.batchSize *= e.value),
auto_buy_material: (m) => void (m.autoBuyMaterial = true),
click_mult: mult('click'),
generator_mult: (m, e, times) => {
m.generators[e.target] = (m.generators[e.target] ?? 1) * e.value ** times;
},
global_mult: mult('global'),
demand_mult: mult('demand'),
value_mult: mult('value'),
material_efficiency: mult('materialEfficiency'),
batch_discount: mult('batchDiscount'),
batch_size_mult: mult('batchSize'),
auto_buy_material: flag('autoBuyMaterial'),
dividend_mult: mult('dividend'),
reliability_add: add('reliability'),
offline_add: add('offline'),
contract_mult: mult('contract'),
rp_mult: mult('rp'),
generator_cost_mult: mult('generatorCost'),
start_funds: add('startFunds'),
price_advisor: flag('priceAdvisor'),
material_price_mult: mult('materialPrice'),
legacy_gain_mult: mult('legacyGain'),
};
export const EFFECT_TYPES = Object.keys(APPLY);
/** Fold every owned upgrade into a single set of multipliers. */
export function computeModifiers(ownedIds, upgradesById) {
/** Fold a list of `{ effect, times }` entries into one set of multipliers. */
export function foldEffects(entries) {
const m = newModifiers();
for (const id of ownedIds) {
const effect = upgradesById.get(id)?.effect;
if (effect) APPLY[effect.type]?.(m, effect);
for (const { effect, times = 1 } of entries) {
if (effect && times > 0) APPLY[effect.type]?.(m, effect, times);
}
return m;
}
+59 -18
View File
@@ -1,9 +1,23 @@
// Game rules. Every function takes the mutable `state` and the static `ctx`
// (see context.js) and never touches the DOM, so it runs the same in the
// browser and in Node (tests, balance simulations).
//
// This file holds the production/market loop and ties the other systems
// (investments, research, contracts, events, achievements...) into time.
import { computeModifiers } from './effects.js';
import { bulkCost, clamp, demandPerSecond, materialBatch, maxAffordable } from './formulas.js';
import { checkAchievements } from './achievements.js';
import { stepContracts, stepEvents } from './contracts.js';
import {
balancedPrice,
bulkCost,
clamp,
demandPerSecond,
materialBatch,
maxAffordable,
} from './formulas.js';
import { stepInvestments } from './investments.js';
import { modifiersFor } from './modifiers.js';
import { rpPerSecond, stepResearch } from './research.js';
const MAX_STEP_SECONDS = 1;
@@ -11,6 +25,7 @@ const MAX_STEP_SECONDS = 1;
export function isUnlocked(state, condition = {}) {
if (condition.totalProduced != null && state.totalProduced < condition.totalProduced) return false;
if (condition.lifetimeProduced != null && state.lifetimeProduced < condition.lifetimeProduced) return false;
if (condition.generator != null && (state.generators[condition.generator] ?? 0) < (condition.count ?? 1)) {
return false;
}
@@ -26,9 +41,8 @@ export function upgradeVisible(state, upgrade) {
// ------------------------------------------------------------------- derived
/** Everything computed from the state (rates, multipliers, current prices). */
export function derive(state, ctx) {
export function derive(state, ctx, mods = modifiersFor(state, ctx)) {
const { rules } = ctx;
const mods = computeModifiers(state.upgrades, ctx.upgradesById);
const perGenerator = {};
let rate = 0;
@@ -43,10 +57,11 @@ export function derive(state, ctx) {
state.totalProduced,
rate,
state.materialFactor,
mods.batchDiscount,
mods.batchDiscount * mods.materialPrice,
mods.batchSize,
);
return {
mods,
clickYield: rules.click.yield * mods.click,
perGenerator,
rate,
@@ -58,6 +73,15 @@ export function derive(state, ctx) {
batchCost: batch.cost,
autoBuyMaterial: mods.autoBuyMaterial,
marketingCost: Math.ceil(rules.marketing.baseCost * rules.marketing.costGrowth ** state.marketing),
rpRate: rpPerSecond(state, ctx, mods),
// Only researched: the price where demand matches output (see formulas.balancedPrice).
advisedPrice: mods.priceAdvisor
? clamp(
balancedPrice(rules.market, Math.max(rate, 1e-9), demandMultiplier),
rules.market.minPrice,
rules.market.maxPrice,
)
: null,
};
}
@@ -71,6 +95,7 @@ export function click(state, ctx) {
state.material -= made * d.materialPerUnit;
state.units += made;
state.totalProduced += made;
state.lifetimeProduced += made;
return true;
}
@@ -90,12 +115,13 @@ export function buyMarketing(state, ctx) {
return true;
}
/** How many generators the player gets for `amount` ("max" or a number). */
export function generatorQuote(state, ctx, id, amount) {
/** How many generators the player gets for `amount` ("max" or a number), and what it costs. */
export function generatorQuote(state, ctx, id, amount, mods = modifiersFor(state, ctx)) {
const g = ctx.generatorsById.get(id);
const owned = state.generators[id] ?? 0;
const count = amount === 'max' ? maxAffordable(g.baseCost, g.costGrowth, owned, state.funds) : amount;
return { count, cost: bulkCost(g.baseCost, g.costGrowth, owned, count) };
const base = g.baseCost * mods.generatorCost;
const count = amount === 'max' ? maxAffordable(base, g.costGrowth, owned, state.funds) : amount;
return { count, cost: bulkCost(base, g.costGrowth, owned, count) };
}
export function buyGenerator(state, ctx, id, amount = 1) {
@@ -138,7 +164,7 @@ function stepMaterialMarket(state, ctx, dt, rng) {
}
}
function step(state, ctx, dt, rng, totals) {
function step(state, ctx, dt, rng, totals, offline) {
stepMaterialMarket(state, ctx, dt, rng);
const d = derive(state, ctx);
@@ -147,6 +173,7 @@ function step(state, ctx, dt, rng, totals) {
state.material -= made * d.materialPerUnit;
state.units += made;
state.totalProduced += made;
state.lifetimeProduced += made;
// Automatic restock: patient while the price is high, urgent when nearly out.
if (d.autoBuyMaterial && state.funds >= d.batchCost) {
@@ -165,28 +192,42 @@ function step(state, ctx, dt, rng, totals) {
totals.produced += made;
totals.sold += sold;
totals.revenue += revenue;
// The other systems.
stepInvestments(state, ctx, dt, rng, d.mods);
stepResearch(state, ctx, dt, d.mods);
stepContracts(state, ctx, dt, sold, d, d.mods, rng, offline, totals);
stepEvents(state, ctx, dt, d, rng, offline, totals);
state.gambleCooldown = Math.max(0, state.gambleCooldown - dt);
for (const id of checkAchievements(state, ctx)) totals.notices.push({ kind: 'achievement', id });
}
/** Advance the simulation by `seconds`. Returns what happened during that time. */
export function advance(state, ctx, seconds, rng = Math.random) {
const totals = { produced: 0, sold: 0, revenue: 0 };
/**
* Advance the simulation by `seconds`. Returns what happened during that time,
* including `notices` (things worth telling the player). `offline` skips the
* parts that need the player present (contracts, events).
*/
export function advance(state, ctx, seconds, rng = Math.random, { offline = false } = {}) {
const totals = { produced: 0, sold: 0, revenue: 0, notices: [] };
let left = Math.max(0, seconds);
while (left > 1e-9) {
const dt = Math.min(left, MAX_STEP_SECONDS);
step(state, ctx, dt, rng, totals);
step(state, ctx, dt, rng, totals, offline);
left -= dt;
}
return totals;
}
/**
* Catch up on the time the game was closed. Deterministic (no price noise)
* and discounted by `offline.efficiency`. Returns null if too short to matter.
* Catch up on the time the game was closed. Deterministic (no price noise),
* discounted by the offline efficiency (research and legacy raise it).
* Returns null if too short to matter.
*/
export function simulateOffline(state, ctx, elapsedSeconds) {
const { maxSeconds, efficiency, minSeconds } = ctx.rules.offline;
if (elapsedSeconds < minSeconds) return null;
const seconds = Math.min(elapsedSeconds, maxSeconds) * efficiency;
const totals = advance(state, ctx, seconds, () => 0.5);
const effective = Math.min(1, efficiency + modifiersFor(state, ctx).offline);
const seconds = Math.min(elapsedSeconds, maxSeconds) * effective;
const totals = advance(state, ctx, seconds, () => 0.5, { offline: true });
return { ...totals, seconds: Math.min(elapsedSeconds, maxSeconds) };
}
+8
View File
@@ -4,6 +4,14 @@ export function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
/**
* Demand bonus from owning chips: diminishing returns (logarithmic), so a big
* lucky streak can never run away with the game.
*/
export function chipDemandBonus(chips, gamble) {
return 1 + gamble.chipBonusScale * Math.log2(1 + Math.max(0, chips));
}
/** Price of the next item when `owned` items are already owned. */
export function unitCost(base, growth, owned) {
return base * growth ** owned;
+77
View File
@@ -0,0 +1,77 @@
// The gamble (a "quantum computer" in the first theme): chips are a second
// currency, hard to buy (the price rises with every chip owned) and hard to
// grow. Stake chips on a cycle: win and the stake doubles, hit a jackpot and it
// is multiplied, lose it, or crash and lose more than you bet. Reliability
// upgrades (paid in funds) tilt the odds. Chips give a diminishing demand bonus.
import { clamp } from './formulas.js';
const MAX_EXPONENT = 200;
export function chipCost(state, ctx) {
const g = ctx.rules.gamble;
return Math.ceil(g.baseChipCost * g.chipCostGrowth ** Math.min(state.chips, MAX_EXPONENT));
}
export function reliabilityCost(state, ctx) {
const r = ctx.rules.gamble.reliability;
return Math.ceil(r.baseCost * r.costGrowth ** state.reliabilityLevel);
}
/** Probabilities of each outcome of one cycle. They always sum to 1. */
export function odds(state, ctx, mods) {
const g = ctx.rules.gamble;
const crash = g.crashChance;
const jackpot = g.jackpotChance;
const reliability = g.baseWin + state.reliabilityLevel * g.reliability.perLevel + mods.reliability;
const win = clamp(reliability, 0.05, g.maxWin - crash - jackpot);
return { crash, jackpot, win, lose: 1 - crash - jackpot - win };
}
export function buyChip(state, ctx) {
const cost = chipCost(state, ctx);
if (state.funds < cost || state.chips >= ctx.rules.gamble.maxChips) return false;
state.funds -= cost;
state.chips += 1;
return true;
}
export function buyReliability(state, ctx) {
if (state.reliabilityLevel >= ctx.rules.gamble.reliability.maxLevel) return false;
const cost = reliabilityCost(state, ctx);
if (state.funds < cost) return false;
state.funds -= cost;
state.reliabilityLevel += 1;
return true;
}
/**
* Stake `stake` chips on one cycle. Returns { outcome, stake, delta } or null
* when it cannot be played (no chips, nothing staked, still cooling down).
*/
export function gamble(state, ctx, stake, mods, rng = Math.random) {
const g = ctx.rules.gamble;
const bet = Math.min(Math.floor(stake), state.chips);
if (!(bet >= 1) || state.gambleCooldown > 0) return null;
const p = odds(state, ctx, mods);
const roll = rng();
let outcome = 'lose';
let delta = -bet;
if (roll < p.crash) {
outcome = 'crash';
delta = -Math.min(state.chips, Math.ceil(bet * g.crashMultiplier));
} else if (roll < p.crash + p.jackpot) {
outcome = 'jackpot';
delta = bet * g.jackpotMultiplier;
} else if (roll < p.crash + p.jackpot + p.win) {
outcome = 'win';
delta = bet;
}
state.chips = clamp(state.chips + delta, 0, g.maxChips);
state.gambleCooldown = g.cooldownSeconds;
if (delta > 0) state.stats.gambleWins += 1;
else state.stats.gambleLosses += 1;
return { outcome, stake: bet, delta };
}
+75
View File
@@ -0,0 +1,75 @@
// Investments: nine societies in three risk tiers. Every `intervalSeconds` each
// price moves by a random percentage within the tier's swing (low ±5 %, mid
// ±15 %, high ±50 %), then drifts back towards its fair value (more weakly for the wilder tiers), and
// holdings pay a small dividend. Buying and selling carries a small fee.
import { clamp } from './formulas.js';
export function holdingValue(state, id) {
return state.invest.holdings[id] * state.invest.prices[id];
}
export function portfolio(state, ctx) {
let value = 0;
let basis = 0;
for (const s of ctx.investments.societies) {
value += holdingValue(state, s.id);
basis += state.invest.basis[s.id];
}
return { value, basis, profit: value - basis };
}
function repriceAll(state, ctx, rng, dividendMultiplier) {
const cfg = ctx.investments;
const inv = state.invest;
for (const s of cfg.societies) {
const tier = cfg.tiers[s.tier];
const before = inv.prices[s.id];
let price = before * (1 + (rng() * 2 - 1) * tier.swing);
price += (s.basePrice - price) * tier.reversion;
price = clamp(price, s.basePrice * cfg.minRatio, s.basePrice * cfg.maxRatio);
inv.prices[s.id] = price;
inv.change[s.id] = price / before - 1;
state.funds += inv.holdings[s.id] * price * tier.dividend * dividendMultiplier;
}
}
export function stepInvestments(state, ctx, dt, rng, mods) {
state.investTimer += dt;
while (state.investTimer >= ctx.investments.intervalSeconds) {
state.investTimer -= ctx.investments.intervalSeconds;
repriceAll(state, ctx, rng, mods.dividend);
}
}
/** Spend `fraction` of the current funds on society `id`. */
export function buyShares(state, ctx, id, fraction) {
if (!ctx.societiesById.has(id)) return false;
const spend = state.funds * clamp(fraction, 0, 1);
if (!(spend >= 0.01)) return false;
state.funds -= spend;
state.invest.holdings[id] += (spend * (1 - ctx.investments.fee)) / state.invest.prices[id];
state.invest.basis[id] += spend;
return true;
}
/** Sell `fraction` of what is held in society `id`. */
export function sellShares(state, ctx, id, fraction) {
if (!ctx.societiesById.has(id)) return false;
const inv = state.invest;
const share = clamp(fraction, 0, 1);
if (!(inv.holdings[id] > 0) || share <= 0) return false;
const proceeds = inv.holdings[id] * share * inv.prices[id] * (1 - ctx.investments.fee);
const basisPart = inv.basis[id] * share;
state.funds += proceeds;
state.stats.investProfit += proceeds - basisPart;
if (share >= 1) {
inv.holdings[id] = 0;
inv.basis[id] = 0;
} else {
inv.holdings[id] -= inv.holdings[id] * share;
inv.basis[id] -= basisPart;
}
return true;
}
+52
View File
@@ -0,0 +1,52 @@
// Legacy (prestige): restart the run for permanent points. Points are spent on
// repeatable perks, and every point ever earned also gives a small passive bonus.
// What survives a restart: see META_KEYS in state.js.
import { modifiersFor } from './modifiers.js';
import { createState, META_KEYS } from './state.js';
/** Points a restart would grant right now (0 until the run is big enough). */
export function legacyGain(state, ctx, mods) {
const { threshold } = ctx.rules.legacy;
if (state.totalProduced < threshold) return 0;
return Math.floor(Math.sqrt(state.totalProduced / threshold) * mods.legacyGain);
}
export function perkLevel(state, perk) {
return state.legacy.perks[perk.id] ?? 0;
}
export function perkCost(perk, level) {
return Math.ceil(perk.baseCost * perk.costGrowth ** level);
}
export function buyPerk(state, ctx, id) {
const perk = ctx.perksById.get(id);
if (!perk) return false;
const level = perkLevel(state, perk);
const cost = perkCost(perk, level);
if (level >= perk.maxLevel || state.legacy.points < cost) return false;
state.legacy.points -= cost;
state.legacy.perks[id] = level + 1;
return true;
}
/** Restart the run in place. Returns the points gained, or 0 if not allowed. */
export function prestige(state, ctx, now = Date.now()) {
const gain = legacyGain(state, ctx, modifiersFor(state, ctx));
if (gain < 1) return 0;
const fresh = createState(ctx, now);
const kept = Object.fromEntries(META_KEYS.map((key) => [key, state[key]]));
kept.legacy = {
...state.legacy,
points: state.legacy.points + gain,
earned: state.legacy.earned + gain,
prestiges: state.legacy.prestiges + 1,
};
Object.assign(state, fresh, kept);
// Perks like "head start" shape the new run.
state.funds += modifiersFor(state, ctx).startFunds;
return gain;
}
+36
View File
@@ -0,0 +1,36 @@
// Gathers every source of bonuses for the current state and folds them.
import { foldEffects } from './effects.js';
import { chipDemandBonus } from './formulas.js';
/** All `{ effect, times }` entries that apply to `state`. */
export function collectEffects(state, ctx) {
const entries = [];
for (const id of state.upgrades) entries.push({ effect: ctx.upgradesById.get(id)?.effect });
for (const id of state.research) entries.push({ effect: ctx.researchById.get(id)?.effect });
for (const id of state.achievements) entries.push({ effect: ctx.achievementsById.get(id)?.effect });
for (const [id, level] of Object.entries(state.legacy.perks)) {
entries.push({ effect: ctx.perksById.get(id)?.effect, times: level });
}
// Every legacy point ever earned gives a small passive bonus.
const passive = 1 + ctx.rules.legacy.passivePerPoint;
entries.push({ effect: { type: 'global_mult', value: passive }, times: state.legacy.earned });
entries.push({ effect: { type: 'demand_mult', value: passive }, times: state.legacy.earned });
// Chips make the brand more attractive, with diminishing returns.
entries.push({
effect: { type: 'demand_mult', value: chipDemandBonus(state.chips, ctx.rules.gamble) },
});
// Temporary buffs from events and contracts.
for (const buff of state.events.buffs) entries.push({ effect: { type: buff.type, value: buff.value } });
return entries;
}
export function modifiersFor(state, ctx) {
return foldEffects(collectEffects(state, ctx));
}
+38
View File
@@ -0,0 +1,38 @@
// Research: labs (bought with funds) produce research points slowly over time;
// points buy nodes of a small tree. Research survives a legacy restart.
export function labCost(state, ctx) {
const r = ctx.rules.research;
return Math.ceil(r.labBaseCost * r.labCostGrowth ** state.labs);
}
export function rpPerSecond(state, ctx, mods) {
return state.labs * ctx.rules.research.rpPerLabPerSecond * mods.rp;
}
export function buyLab(state, ctx) {
const { labMax } = ctx.rules.research;
const cost = labCost(state, ctx);
if (state.labs >= labMax || state.funds < cost) return false;
state.funds -= cost;
state.labs += 1;
return true;
}
/** 'owned' | 'available' | 'locked' */
export function researchStatus(state, node) {
if (state.research.includes(node.id)) return 'owned';
return node.requires.every((id) => state.research.includes(id)) ? 'available' : 'locked';
}
export function buyResearch(state, ctx, id) {
const node = ctx.researchById.get(id);
if (!node || researchStatus(state, node) !== 'available' || state.rp < node.cost) return false;
state.rp -= node.cost;
state.research.push(id);
return true;
}
export function stepResearch(state, ctx, dt, mods) {
state.rp += rpPerSecond(state, ctx, mods) * dt;
}
+161 -13
View File
@@ -1,28 +1,85 @@
import { clamp } from './formulas.js';
export const STATE_VERSION = 1;
export const STATE_VERSION = 2;
const MAX_NUMBER = 1e300;
const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite
const MAX_MARKETING = 300;
const MAX_LEVEL = 1000;
const STAT_KEYS = ['gambleWins', 'gambleLosses', 'contractsDone', 'eventsClaimed', 'investProfit'];
// Fields that only exist while the game runs and are never saved.
const TRANSIENT = [
'materialTimer',
'materialTrend',
'investTimer',
'contractTimer',
'eventTimer',
'gambleCooldown',
];
// What a legacy reset keeps (the "meta" progression); everything else is the run.
export const META_KEYS = [
'lifetimeProduced',
'stats',
'research',
'labs',
'rp',
'legacy',
'achievements',
];
function newInvest(ctx) {
const ids = ctx.investments.societies.map((s) => s.id);
return {
prices: Object.fromEntries(ctx.investments.societies.map((s) => [s.id, s.basePrice])),
holdings: Object.fromEntries(ids.map((id) => [id, 0])),
basis: Object.fromEntries(ids.map((id) => [id, 0])),
change: Object.fromEntries(ids.map((id) => [id, 0])),
};
}
export function createState(ctx, now = Date.now()) {
const { start, market } = ctx.rules;
const { start, market, events, contracts } = ctx.rules;
return {
v: STATE_VERSION,
// --- the run (reset by a legacy restart)
units: start.units,
funds: start.funds,
material: start.material,
totalProduced: 0,
totalSold: 0,
price: start.price ?? market.basePrice,
price: start.price ?? market.referencePrice,
marketing: 0,
materialFactor: 1,
generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 0])),
upgrades: [],
invest: newInvest(ctx),
chips: 0,
reliabilityLevel: 0,
contracts: { offers: [], active: null },
events: { current: null, buffs: [] },
// --- meta progression (survives a legacy restart)
lifetimeProduced: 0,
stats: Object.fromEntries(STAT_KEYS.map((k) => [k, 0])),
research: [],
labs: 0,
rp: 0,
legacy: { points: 0, earned: 0, prestiges: 0, perks: {} },
achievements: [],
savedAt: now,
materialTimer: 0, // transient, never saved
materialTrend: 0, // transient: -1 falling, 0 steady, 1 rising
// --- transient
materialTimer: 0,
materialTrend: 0, // -1 falling, 0 steady, 1 rising
investTimer: 0,
contractTimer: contracts.offerEverySeconds,
eventTimer: events.minSeconds,
gambleCooldown: 0,
};
}
@@ -34,37 +91,128 @@ function int(value, max) {
return Math.floor(num(value, 0, 0, max));
}
function knownIds(list, known) {
return [...new Set(Array.isArray(list) ? list : [])].filter((id) => known.has(id));
}
function sanitizeInvest(raw, ctx, base) {
const inv = ctx.investments;
const out = newInvest(ctx);
for (const s of inv.societies) {
out.prices[s.id] = num(raw?.prices?.[s.id], base.prices[s.id], s.basePrice * inv.minRatio, s.basePrice * inv.maxRatio);
out.holdings[s.id] = num(raw?.holdings?.[s.id], 0);
out.basis[s.id] = out.holdings[s.id] > 0 ? num(raw?.basis?.[s.id], 0) : 0;
out.change[s.id] = num(raw?.change?.[s.id], 0, -1, 100);
}
return out;
}
function sanitizeContracts(raw, ctx) {
const templates = new Set(ctx.rules.contracts.templates.map((t) => t.id));
const rewardTypes = new Set(ctx.rules.contracts.rewards);
const offer = (o) => {
if (!o || !templates.has(o.template) || !rewardTypes.has(o.reward?.type)) return null;
return {
template: o.template,
seconds: num(o.seconds, 60, 1, 3600),
target: num(o.target, 1, 1),
reward: { type: o.reward.type, amount: num(o.reward.amount, 0) },
};
};
const offers = (Array.isArray(raw?.offers) ? raw.offers : []).map(offer).filter(Boolean);
const active = offer(raw?.active?.offer);
return {
offers: offers.slice(0, ctx.rules.contracts.maxOffers),
active: active
? {
offer: active,
progress: num(raw.active.progress, 0, 0, active.target),
remaining: num(raw.active.remaining, active.seconds, 0, active.seconds),
}
: null,
};
}
function sanitizeEvents(raw, ctx) {
const types = new Map(ctx.rules.events.types.map((t) => [t.id, t]));
const current = raw?.current;
const buffTypes = new Set(['demand_mult', 'global_mult', 'material_price_mult']);
return {
current:
current && types.has(current.type) && !types.get(current.type).auto
? { type: current.type, remaining: num(current.remaining, 0, 0, ctx.rules.events.claimSeconds) }
: null,
buffs: (Array.isArray(raw?.buffs) ? raw.buffs : [])
.filter((b) => b && buffTypes.has(b.type) && Number.isFinite(b.value) && Number.isFinite(b.remaining))
.slice(0, 20)
.map((b) => ({
type: b.type,
value: clamp(b.value, 0.01, 100),
remaining: clamp(b.remaining, 0, 3600),
})),
};
}
/**
* Turn untrusted data (cookie, imported file) into a valid state.
* Unknown ids are dropped and numbers are clamped; returns null if `raw` is not an object.
* Saves from older versions are upgraded: missing fields take their defaults.
*/
export function sanitizeState(raw, ctx, now = Date.now()) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const { market, materialMarket } = ctx.rules;
const { market, materialMarket, gamble } = ctx.rules;
const base = createState(ctx, now);
const owned = Array.isArray(raw.upgrades) ? raw.upgrades : [];
const generators = Object.fromEntries(
ctx.generators.map((g) => [g.id, int(raw.generators?.[g.id], MAX_OWNED)]),
);
const totalProduced = num(raw.totalProduced, 0);
const perks = {};
for (const perk of ctx.legacy) {
const level = int(raw.legacy?.perks?.[perk.id], perk.maxLevel);
if (level > 0) perks[perk.id] = level;
}
return {
...base,
units: num(raw.units, base.units),
funds: num(raw.funds, base.funds),
material: num(raw.material, base.material),
totalProduced: num(raw.totalProduced, 0),
totalProduced,
totalSold: num(raw.totalSold, 0),
price: Math.round(num(raw.price, base.price, market.minPrice, market.maxPrice) * 100) / 100,
marketing: int(raw.marketing, MAX_MARKETING),
materialFactor: num(raw.materialFactor, 1, materialMarket.minFactor, materialMarket.maxFactor),
generators: Object.fromEntries(
ctx.generators.map((g) => [g.id, int(raw.generators?.[g.id], MAX_OWNED)]),
),
upgrades: [...new Set(owned)].filter((id) => ctx.upgradesById.has(id)),
generators,
upgrades: knownIds(raw.upgrades, ctx.upgradesById),
invest: sanitizeInvest(raw.invest, ctx, base.invest),
chips: int(raw.chips, gamble.maxChips),
reliabilityLevel: int(raw.reliabilityLevel, gamble.reliability.maxLevel),
contracts: sanitizeContracts(raw.contracts, ctx),
events: sanitizeEvents(raw.events, ctx),
// Saves from before lifetime tracking: the current run is all we know.
lifetimeProduced: Math.max(num(raw.lifetimeProduced, 0), totalProduced),
stats: Object.fromEntries(STAT_KEYS.map((k) => [k, num(raw.stats?.[k], 0)])),
research: knownIds(raw.research, ctx.researchById),
labs: int(raw.labs, ctx.rules.research.labMax),
rp: num(raw.rp, 0),
legacy: {
points: int(raw.legacy?.points, MAX_LEVEL * 100),
earned: int(raw.legacy?.earned, MAX_LEVEL * 100),
prestiges: int(raw.legacy?.prestiges, MAX_LEVEL),
perks,
},
achievements: knownIds(raw.achievements, ctx.achievementsById),
savedAt: num(raw.savedAt, now, 0, now),
};
}
/** Compact JSON (numbers trimmed to 10 significant digits) for cookies and export. */
export function serializeState(state) {
const { materialTimer, materialTrend, ...persisted } = state;
const persisted = { ...state };
for (const key of TRANSIENT) delete persisted[key];
return JSON.stringify(persisted, (_key, value) =>
typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value,
);
+85 -11
View File
@@ -1,5 +1,6 @@
// Entry point: wires the engine, the services (cookies, i18n, themes) and the UI.
import { abandonContract, acceptContract, claimEvent } from './engine/contracts.js';
import {
advance,
buyGenerator,
@@ -11,6 +12,10 @@ import {
setPrice,
simulateOffline,
} from './engine/engine.js';
import { buyChip, buyReliability, gamble } from './engine/gamble.js';
import { buyShares, sellShares } from './engine/investments.js';
import { buyPerk, prestige } from './engine/legacy.js';
import { buyLab, buyResearch } from './engine/research.js';
import { createState, sanitizeState, serializeState } from './engine/state.js';
import { fetchLocales, fetchThemes, loadGameContext } from './services/data.js';
import { createFormatter } from './services/format.js';
@@ -26,8 +31,16 @@ import {
takeLegacySaves,
} from './services/storage.js';
import { applyThemeStyles } from './services/themes.js';
import { createAchievementsView } from './ui/achievementsView.js';
import { createContractsView } from './ui/contractsView.js';
import { $, refreshStaticIcons, setThemeAssets } from './ui/dom.js';
import { createGambleView } from './ui/gambleView.js';
import { createInvestView } from './ui/investView.js';
import { createLegacyView } from './ui/legacyView.js';
import { createResearchView } from './ui/researchView.js';
import { createSettings } from './ui/settings.js';
import { createTabs } from './ui/tabs.js';
import { formatDuration, moneyFormatter, rewardText } from './ui/text.js';
import { showToast } from './ui/toast.js';
import { createView } from './ui/view.js';
@@ -42,6 +55,7 @@ let themeId;
let locale;
let state;
let fmt;
let tabs;
let income = 0;
let lastSaveError = 0;
const themeNames = new Map();
@@ -115,7 +129,7 @@ async function fillSelects() {
);
}
/** Reload texts, assets and the lists for the current theme + language. */
/** Reload texts, assets and every screen for the current theme + language. */
async function refreshTexts() {
await loadDictionary(locale, themeId);
fmt = createFormatter(locale);
@@ -123,6 +137,9 @@ async function refreshTexts() {
applyTranslations();
document.title = t('theme.title');
view.rebuild(ctx, fmt);
for (const screen of Object.values(screens)) screen.rebuild(ctx, fmt);
tabs.rebuild();
refreshStaticIcons();
await fillSelects();
}
@@ -137,11 +154,26 @@ async function setTheme(id) {
// ------------------------------------------------------------------ the loop
function formatDuration(seconds) {
const minutes = Math.floor(seconds / 60);
if (minutes < 1) return t('time.seconds', { value: Math.round(seconds) });
if (minutes < 60) return t('time.minutes', { value: minutes });
return t('time.hoursMinutes', { hours: Math.floor(minutes / 60), minutes: minutes % 60 });
/** Tell the player about what just happened (see `notices` in engine.advance). */
function announce(notice) {
switch (notice.kind) {
case 'contract_done':
showToast(t('toast.contractDone', { reward: rewardText(notice.reward, fmt, notice.seconds) }));
break;
case 'contract_failed':
showToast(t('toast.contractFailed'), { error: true });
break;
case 'event_opportunity':
showToast(t('toast.eventOpportunity', { title: t(`event.${notice.type}.title`) }));
break;
case 'event_incident':
showToast(t('toast.eventIncident', { title: t(`event.${notice.type}.title`) }), { error: true });
break;
case 'achievement':
showToast(t('toast.achievement', { title: t(`ach.title.${notice.id}`) }));
break;
default:
}
}
/** Apply progress made while the game was closed or the device asleep. */
@@ -152,7 +184,7 @@ function catchUp(elapsedSeconds) {
t('toast.welcomeBack', {
duration: formatDuration(report.seconds),
produced: fmt.number(Math.floor(report.produced)),
funds: t('theme.fmt.funds', { value: fmt.money(report.revenue) }),
funds: moneyFormatter(fmt)(report.revenue),
}),
);
}
@@ -168,14 +200,19 @@ function frame() {
if (dt >= ctx.rules.offline.minSeconds) {
catchUp(dt); // tab was frozen or the device slept
} else {
const { revenue } = advance(state, ctx, dt);
income += (revenue / dt - income) * Math.min(1, dt / INCOME_SMOOTHING_SECONDS);
const totals = advance(state, ctx, dt);
income += (totals.revenue / dt - income) * Math.min(1, dt / INCOME_SMOOTHING_SECONDS);
totals.notices.forEach(announce);
}
render();
}
/** The resource bar and the unlocked tabs always; the open section only. */
function render() {
view.update(state, derive(state, ctx), { income });
const d = derive(state, ctx);
view.update(state, d, { income });
tabs.update(state);
if (tabs.active !== 'main') screens[tabs.active]?.update(state, d);
}
// ------------------------------------------------------------------- actions
@@ -195,6 +232,41 @@ const view = createView({
nudgePrice: (delta) => act(setPrice)(state.price + delta),
});
function restart() {
if (!confirm(t('legacy.confirm'))) return;
const gain = prestige(state, ctx);
if (!gain) return;
income = 0;
persist();
tabs.select('main');
showToast(t('toast.restarted', { gain: fmt.number(gain) }));
}
/** One screen per section; each is built and refreshed by its own module. */
const screens = {
contracts: createContractsView($('#view-contracts'), {
accept: act(acceptContract),
abandon: act(abandonContract),
claimEvent: () => {
claimEvent(state, ctx, derive(state, ctx));
render();
},
}),
invest: createInvestView($('#view-invest'), { buy: act(buyShares), sell: act(sellShares) }),
research: createResearchView($('#view-research'), { buyLab: act(buyLab), buyNode: act(buyResearch) }),
gamble: createGambleView($('#view-gamble'), {
buyChip: act(buyChip),
buyReliability: act(buyReliability),
run(stake) {
const result = gamble(state, ctx, stake, derive(state, ctx).mods);
render();
return result;
},
}),
legacy: createLegacyView($('#view-legacy'), { buyPerk: act(buyPerk), restart }),
achievements: createAchievementsView($('#view-achievements')),
};
createSettings({
getExport: () => ({
text: buildExport(state),
@@ -211,6 +283,7 @@ createSettings({
state = createState(ctx);
income = 0;
persist();
tabs.select('main');
render();
},
});
@@ -226,6 +299,8 @@ async function boot() {
[ctx, themes, locales] = await Promise.all([loadGameContext(), fetchThemes(), fetchLocales()]);
if (themes.length === 0) throw new Error('No theme installed in public/themes/');
tabs = createTabs(ctx.tabs, () => state && render());
const prefs = loadPrefs();
locale = pickLocale(prefs.lang);
const initialTheme = themes.find((th) => th.id === prefs.theme) ?? themes[0];
@@ -240,7 +315,6 @@ async function boot() {
});
await setTheme(initialTheme.id);
refreshStaticIcons();
state = loadSavedState() ?? createState(ctx);
catchUp((Date.now() - state.savedAt) / 1000);
+16 -6
View File
@@ -8,13 +8,23 @@ async function getJson(url) {
return response.json();
}
// Every data file the game needs: context key -> file name in public/data/.
export const DATA_FILES = {
rules: 'rules',
generators: 'generators',
upgrades: 'upgrades',
research: 'research',
legacy: 'legacy',
achievements: 'achievements',
investments: 'investments',
tabs: 'tabs',
};
export async function loadGameContext() {
const [rules, generators, upgrades] = await Promise.all([
getJson('data/rules.json'),
getJson('data/generators.json'),
getJson('data/upgrades.json'),
]);
return buildContext({ rules, generators, upgrades });
const entries = await Promise.all(
Object.entries(DATA_FILES).map(async ([key, file]) => [key, await getJson(`data/${file}.json`)]),
);
return buildContext(Object.fromEntries(entries));
}
/** [{ id, order, assets, locales }] discovered from public/themes/ by the server. */
+57
View File
@@ -0,0 +1,57 @@
// Achievements tab: milestones with a small permanent bonus each.
import { statValue } from '../engine/achievements.js';
import { t } from '../services/i18n.js';
import { h, icon } from './dom.js';
import { effectText, setText } from './text.js';
export function createAchievementsView(root) {
let ctx;
let fmt;
let summary;
const rows = new Map();
function rebuild(newCtx, formatter) {
ctx = newCtx;
fmt = formatter;
rows.clear();
summary = h('p', { class: 'hint' });
const list = h('ul', { class: 'cards cards--grid' });
for (const a of ctx.achievements) {
const progress = h('p', { class: 'card__meta' });
const li = h(
'li',
{ class: 'card' },
icon(`ach.${a.id}`),
h(
'div',
{ class: 'card__body' },
h('h3', { class: 'card__title', text: t(`ach.title.${a.id}`) }),
h('p', { class: 'card__desc', text: t(`ach.cond.${a.condition.stat}`, { value: fmt.number(a.condition.min) }) }),
h('p', { class: 'card__meta', text: effectText(a.effect, fmt) }),
progress,
),
);
list.append(li);
rows.set(a.id, { a, li, progress });
}
root.replaceChildren(
h('section', { class: 'panel panel--wide' }, h('h2', { class: 'panel__title', text: t('tab.achievements') }), summary, list),
);
}
function update(state) {
const bonus = state.achievements.reduce((product, id) => product * ctx.achievementsById.get(id).effect.value, 1);
setText(summary, t('ach.summary', { done: state.achievements.length, total: ctx.achievements.length, bonus: fmt.number(bonus) }));
for (const { a, li, progress } of rows.values()) {
const done = state.achievements.includes(a.id);
li.classList.toggle('card--done', done);
li.classList.toggle('card--locked', !done);
const value = Math.min(statValue(state, a.condition.stat), a.condition.min);
setText(progress, done ? t('ach.done') : `${fmt.number(Math.floor(value))} / ${fmt.number(a.condition.min)}`);
}
}
return { rebuild, update };
}
+164
View File
@@ -0,0 +1,164 @@
// Contracts tab: opportunities to seize, active effects, the active contract
// and the board of offers.
import { t } from '../services/i18n.js';
import { h } from './dom.js';
import { formatDuration, moneyFormatter, rewardText, setText } from './text.js';
const BUFF_LABEL = {
demand_mult: 'contracts.buff.demand',
global_mult: 'contracts.buff.production',
material_price_mult: 'contracts.buff.material',
};
export function createContractsView(root, actions) {
let ctx;
let fmt;
let els;
let offersSignature = '';
let buffsSignature = '';
function rebuild(newCtx, formatter) {
ctx = newCtx;
fmt = formatter;
offersSignature = '';
buffsSignature = '';
els = {
eventTitle: h('h3', { class: 'card__title' }),
eventText: h('p', { class: 'card__desc' }),
eventTime: h('p', { class: 'card__meta' }),
claim: h('button', { class: 'btn btn--primary', type: 'button', text: t('contracts.claim'), onclick: () => actions.claimEvent() }),
eventBox: h('div', { class: 'callout' }),
eventNone: h('p', { class: 'hint', text: t('contracts.eventNone') }),
buffs: h('ul', { class: 'cards' }),
activeBox: h('div', { class: 'callout' }),
activeTitle: h('h3', { class: 'card__title' }),
activeReward: h('p', { class: 'card__desc' }),
activeProgress: h('progress', { class: 'progress', max: 1, value: 0 }),
activeText: h('p', { class: 'card__meta' }),
abandon: h('button', { class: 'btn', type: 'button', text: t('contracts.abandon'), onclick: () => actions.abandon() }),
activeNone: h('p', { class: 'hint', text: t('contracts.activeNone') }),
offers: h('ul', { class: 'cards' }),
offersNone: h('p', { class: 'hint', text: t('contracts.offersNone') }),
};
els.eventBox.append(els.eventTitle, els.eventText, els.eventTime, els.claim);
els.activeBox.append(els.activeTitle, els.activeReward, els.activeProgress, els.activeText, els.abandon);
root.replaceChildren(
h(
'section',
{ class: 'panel' },
h('h2', { class: 'panel__title', text: t('contracts.events') }),
els.eventBox,
els.eventNone,
h('h3', { class: 'dialog__subtitle', text: t('contracts.effects') }),
els.buffs,
),
h(
'section',
{ class: 'panel' },
h('h2', { class: 'panel__title', text: t('contracts.active') }),
els.activeBox,
els.activeNone,
h('h3', { class: 'dialog__subtitle', text: t('contracts.board') }),
h('p', { class: 'hint', text: t('contracts.boardInfo', { seconds: ctx.rules.contracts.offerEverySeconds }) }),
els.offers,
els.offersNone,
),
);
}
function eventText(type) {
const cfg = ctx.rules.events.types.find((e) => e.id === type);
return t(`event.${type}.text`, {
value: cfg?.buff ? fmt.number(cfg.buff.value) : '',
seconds: cfg?.buff ? cfg.buff.seconds : '',
});
}
function updateEvent(state) {
const current = state.events.current;
els.eventBox.hidden = !current;
els.eventNone.hidden = Boolean(current);
if (!current) return;
setText(els.eventTitle, t(`event.${current.type}.title`));
setText(els.eventText, eventText(current.type));
setText(els.eventTime, t('contracts.timeLeft', { time: formatDuration(current.remaining) }));
}
function updateBuffs(state) {
const signature = state.events.buffs.map((b) => `${b.type}:${b.value}`).join();
if (signature !== buffsSignature) {
buffsSignature = signature;
els.buffs.replaceChildren(
...state.events.buffs.map((b) =>
h(
'li',
{ class: 'card' },
h('div', { class: 'card__body' }, h('h3', { class: 'card__title', text: t(BUFF_LABEL[b.type], { value: fmt.number(b.value) }) }), h('p', { class: 'card__meta' })),
),
),
);
}
[...els.buffs.children].forEach((li, i) => {
const buff = state.events.buffs[i];
if (buff) setText(li.querySelector('.card__meta'), t('contracts.timeLeft', { time: formatDuration(buff.remaining) }));
});
}
function updateActive(state) {
const active = state.contracts.active;
els.activeBox.hidden = !active;
els.activeNone.hidden = Boolean(active);
if (!active) return;
const { offer } = active;
setText(els.activeTitle, t(`contracts.template.${offer.template}`));
setText(els.activeReward, rewardText(offer.reward, fmt, offer.seconds));
els.activeProgress.max = offer.target;
els.activeProgress.value = active.progress;
setText(
els.activeText,
t('contracts.progress', {
done: fmt.number(Math.floor(active.progress)),
target: fmt.number(offer.target),
time: formatDuration(active.remaining),
}),
);
}
function updateOffers(state) {
const offers = state.contracts.offers;
const signature = offers.map((o) => `${o.template}:${o.target}:${o.reward.type}:${o.reward.amount}`).join();
if (signature !== offersSignature) {
offersSignature = signature;
els.offers.replaceChildren(
...offers.map((offer, index) =>
h(
'li',
{ class: 'card' },
h(
'div',
{ class: 'card__body' },
h('h3', { class: 'card__title', text: t(`contracts.template.${offer.template}`) }),
h('p', { class: 'card__desc', text: t('contracts.goal', { target: fmt.number(offer.target), duration: formatDuration(offer.seconds) }) }),
h('p', { class: 'card__meta', text: rewardText(offer.reward, fmt, offer.seconds) }),
),
h('button', { class: 'btn btn--buy', type: 'button', text: t('contracts.accept'), 'data-index': index, onclick: () => actions.accept(index) }),
),
),
);
}
els.offersNone.hidden = offers.length > 0;
for (const button of els.offers.querySelectorAll('button')) button.disabled = Boolean(state.contracts.active);
}
function update(state) {
updateEvent(state);
updateBuffs(state);
updateActive(state);
updateOffers(state);
}
return { rebuild, update };
}
+2 -5
View File
@@ -35,10 +35,7 @@ export function icon(key) {
return h('span', { class: 'icon icon--placeholder', 'aria-hidden': 'true', dataset: { icon: key } });
}
/** Re-resolve the icons that live in index.html (resource tiles). */
/** Re-resolve the icons that live in index.html (the resource bar). */
export function refreshStaticIcons(root = document) {
for (const el of root.querySelectorAll('[data-icon]')) {
if (el.closest('#generators, #upgrades')) continue; // those lists are rebuilt by view.js
el.replaceWith(icon(el.dataset.icon));
}
for (const el of root.querySelectorAll('.resourcebar [data-icon]')) el.replaceWith(icon(el.dataset.icon));
}
+167
View File
@@ -0,0 +1,167 @@
// Gamble tab: chips are hard to buy and hard to keep. Stake them on a cycle.
import { chipCost, odds, reliabilityCost } from '../engine/gamble.js';
import { chipDemandBonus } from '../engine/formulas.js';
import { t } from '../services/i18n.js';
import { h, icon } from './dom.js';
import { moneyFormatter, setText } from './text.js';
const STAKES = [
{ id: 'one', count: () => 1 },
{ id: 'tenth', fraction: 0.1 },
{ id: 'half', fraction: 0.5 },
{ id: 'all', fraction: 1 },
];
const LOG_SIZE = 6;
export function createGambleView(root, actions) {
let ctx;
let fmt;
let money;
let stake = STAKES[0];
let els;
const log = [];
const stakeCount = (chips) => (stake.count ? stake.count() : Math.max(1, Math.ceil(chips * stake.fraction)));
function stakeLabel(option) {
if (option.id === 'one') return t('gamble.stakeOne');
if (option.id === 'all') return t('gamble.stakeAll');
return t('gamble.stakePercent', { percent: option.fraction * 100 });
}
function renderLog() {
els.log.replaceChildren(
...log.map((entry) =>
h('li', { class: `log__entry log__entry--${entry.outcome}`, text: t(`gamble.result.${entry.outcome}`, { amount: fmt.number(Math.abs(entry.delta)) }) }),
),
);
els.logEmpty.hidden = log.length > 0;
}
function run(state) {
const result = actions.run(stakeCount(state.chips));
if (!result) return;
log.unshift(result);
log.length = Math.min(log.length, LOG_SIZE);
renderLog();
}
let lastState;
function rebuild(newCtx, formatter) {
ctx = newCtx;
fmt = formatter;
money = moneyFormatter(fmt);
els = {
chips: h('strong'),
bonus: h('span', { class: 'hint' }),
buyChip: h('button', { class: 'btn btn--wide', type: 'button', onclick: () => actions.buyChip() }),
reliability: h('button', { class: 'btn btn--wide', type: 'button', onclick: () => actions.buyReliability() }),
odds: h('p', { class: 'hint' }),
run: h('button', { class: 'btn btn--primary btn--big', type: 'button', onclick: () => run(lastState) }),
log: h('ul', { class: 'log' }),
logEmpty: h('p', { class: 'hint', text: t('gamble.logEmpty') }),
};
root.replaceChildren(
h(
'section',
{ class: 'panel' },
h('h2', { class: 'panel__title', text: t('theme.tab.gamble') }),
h('p', { class: 'hint', text: t('gamble.intro') }),
h(
'div',
{ class: 'tile' },
icon('res.chips'),
h('div', {}, h('span', { class: 'tile__label', text: t('theme.res.chips') }), els.chips, els.bonus),
),
els.buyChip,
els.reliability,
),
h(
'section',
{ class: 'panel' },
h('h2', { class: 'panel__title', text: t('gamble.play') }),
h('p', { class: 'field__label', text: t('gamble.stake') }),
h(
'fieldset',
{ class: 'segmented' },
h('legend', { class: 'sr-only', text: t('gamble.stake') }),
...STAKES.map((option) =>
h(
'label',
{},
h('input', {
type: 'radio',
name: 'gamble-stake',
value: option.id,
checked: option === stake,
onchange: () => {
stake = option;
},
}),
h('span', { text: stakeLabel(option) }),
),
),
),
els.odds,
els.run,
h('h3', { class: 'dialog__subtitle', text: t('gamble.log') }),
els.logEmpty,
els.log,
),
);
renderLog();
}
function update(state, d) {
lastState = state;
const g = ctx.rules.gamble;
setText(els.chips, fmt.number(state.chips));
setText(els.bonus, t('gamble.bonus', { bonus: fmt.number(chipDemandBonus(state.chips, g)) }));
const cost = chipCost(state, ctx);
setText(els.buyChip, t('gamble.buyChip', { cost: money(cost) }));
els.buyChip.disabled = state.funds < cost || state.chips >= g.maxChips;
const maxed = state.reliabilityLevel >= g.reliability.maxLevel;
setText(
els.reliability,
maxed
? t('gamble.reliabilityMax', { level: state.reliabilityLevel })
: t('gamble.reliability', {
level: state.reliabilityLevel,
max: g.reliability.maxLevel,
cost: money(reliabilityCost(state, ctx)),
}),
);
els.reliability.disabled = maxed || state.funds < reliabilityCost(state, ctx);
const p = odds(state, ctx, d.mods);
const percent = (x) => fmt.number(Math.round(x * 1000) / 10);
setText(
els.odds,
t('gamble.odds', {
win: percent(p.win),
jackpot: percent(p.jackpot),
multiplier: g.jackpotMultiplier,
crash: percent(p.crash),
lose: percent(p.lose),
}),
);
const cooling = state.gambleCooldown > 0;
setText(
els.run,
cooling
? t('gamble.cooldown', { seconds: fmt.number(Math.ceil(state.gambleCooldown * 10) / 10) })
: `${t('theme.action.gamble')} (${fmt.number(Math.min(stakeCount(state.chips), state.chips))})`,
);
els.run.disabled = cooling || state.chips < 1;
}
return { rebuild, update };
}
+181
View File
@@ -0,0 +1,181 @@
// Investments tab: nine societies in three risk tiers, all visible at once.
import { portfolio } from '../engine/investments.js';
import { t } from '../services/i18n.js';
import { h, icon } from './dom.js';
import { moneyFormatter, setText, signedPercent } from './text.js';
const AMOUNTS = [0.01, 0.1, 0.5, 1];
const TIERS = ['low', 'mid', 'high'];
export function createInvestView(root, actions) {
let ctx;
let fmt;
let money;
let amount = 0.1;
let summary;
const rows = new Map();
function societyRow(society) {
const change = h('span', { class: 'badge' });
const price = h('span');
const held = h('span');
const buy = h('button', {
class: 'btn',
type: 'button',
text: t('invest.buy'),
onclick: () => actions.buy(society.id, amount),
});
const sell = h('button', {
class: 'btn',
type: 'button',
text: t('invest.sell'),
onclick: () => actions.sell(society.id, amount),
});
const root = h(
'li',
{ class: 'card card--stack' },
h(
'div',
{ class: 'card__row' },
icon(`inv.${society.id}`),
h(
'div',
{ class: 'card__body' },
h('h3', { class: 'card__title' }, h('span', { text: t(`theme.inv.${society.id}.name`) }), change),
h('p', { class: 'card__meta' }, price),
h('p', { class: 'card__meta' }, held),
),
),
h('div', { class: 'card__actions' }, buy, sell),
);
return { root, change, price, held, buy, sell };
}
function tierPanel(tier) {
const cfg = ctx.investments.tiers[tier];
const list = h('ul', { class: 'cards' });
for (const society of ctx.investments.societies.filter((s) => s.tier === tier)) {
const row = societyRow(society);
rows.set(society.id, row);
list.append(row.root);
}
return h(
'section',
{ class: `panel panel--${tier}` },
h('h2', { class: 'panel__title', text: t(`invest.tier.${tier}`) }),
h('p', {
class: 'hint',
text: t('invest.tierInfo', {
swing: fmt.number(cfg.swing * 100),
dividend: fmt.number(cfg.dividend * 100),
}),
}),
list,
);
}
function rebuild(newCtx, formatter) {
ctx = newCtx;
fmt = formatter;
money = moneyFormatter(fmt);
rows.clear();
summary = {
value: h('strong'),
basis: h('strong'),
profit: h('strong'),
next: h('p', { class: 'hint' }),
};
const amountGroup = h(
'fieldset',
{ class: 'segmented' },
h('legend', { class: 'sr-only', text: t('invest.amount') }),
...AMOUNTS.map((a) =>
h(
'label',
{},
h('input', {
type: 'radio',
name: 'invest-amount',
value: String(a),
checked: a === amount,
onchange: () => {
amount = a;
},
}),
h('span', { text: `${a * 100}%` }),
),
),
);
root.replaceChildren(
h(
'section',
{ class: 'panel panel--wide' },
h('h2', { class: 'panel__title', text: t('tab.invest') }),
h('p', {
class: 'hint',
text: t('invest.intro', {
seconds: ctx.investments.intervalSeconds,
fee: fmt.number(ctx.investments.fee * 100),
}),
}),
h(
'dl',
{ class: 'stats' },
h('div', {}, h('dt', { text: t('invest.portfolio') }), h('dd', {}, summary.value)),
h('div', {}, h('dt', { text: t('invest.invested') }), h('dd', {}, summary.basis)),
h('div', {}, h('dt', { text: t('invest.profit') }), h('dd', {}, summary.profit)),
),
h('p', { class: 'field__label', text: t('invest.amountLabel') }),
amountGroup,
summary.next,
),
...TIERS.map(tierPanel),
);
}
function update(state) {
const p = portfolio(state, ctx);
setText(summary.value, money(p.value));
setText(summary.basis, money(p.basis));
setText(summary.profit, money(p.profit));
summary.profit.classList.toggle('pos', p.profit > 0);
summary.profit.classList.toggle('neg', p.profit < 0);
const remaining = Math.ceil(ctx.investments.intervalSeconds - state.investTimer);
setText(summary.next, t('invest.nextUpdate', { seconds: remaining }));
for (const society of ctx.investments.societies) {
const row = rows.get(society.id);
const price = state.invest.prices[society.id];
const shares = state.invest.holdings[society.id];
const change = state.invest.change[society.id];
const basis = state.invest.basis[society.id];
const arrow = change > 0 ? '▲' : change < 0 ? '▼' : '■';
setText(row.change, `${arrow} ${signedPercent(change, fmt)}`);
row.change.classList.toggle('pos', change > 0);
row.change.classList.toggle('neg', change < 0);
setText(
row.price,
t('invest.priceLine', {
price: fmt.price(price),
gap: signedPercent(price / society.basePrice - 1, fmt),
}),
);
const value = shares * price;
setText(
row.held,
shares > 0
? t('invest.heldLine', { value: money(value), profit: signedPercent(basis > 0 ? value / basis - 1 : 0, fmt) })
: t('invest.heldNone'),
);
row.buy.disabled = state.funds < 0.01;
row.sell.disabled = shares <= 0;
}
}
return { rebuild, update };
}
+106
View File
@@ -0,0 +1,106 @@
// Legacy tab: restart the run for permanent points, spend them on perks.
import { legacyGain, perkCost, perkLevel } from '../engine/legacy.js';
import { t } from '../services/i18n.js';
import { h, icon } from './dom.js';
import { effectText, setText } from './text.js';
export function createLegacyView(root, actions) {
let ctx;
let fmt;
let els;
const perkRows = new Map();
function rebuild(newCtx, formatter) {
ctx = newCtx;
fmt = formatter;
perkRows.clear();
els = {
points: h('strong'),
earned: h('dd'),
prestiges: h('dd'),
passive: h('dd'),
gain: h('p', { class: 'card__desc' }),
need: h('p', { class: 'hint' }),
progress: h('progress', { class: 'progress', max: 1, value: 0 }),
restart: h('button', { class: 'btn btn--danger btn--wide', type: 'button', onclick: () => actions.restart() }),
};
const perks = h('ul', { class: 'cards' });
for (const perk of ctx.legacy) {
const level = h('span', { class: 'badge' });
const cost = h('span', { class: 'btn__label' });
const buy = h('button', { class: 'btn btn--buy', type: 'button', onclick: () => actions.buyPerk(perk.id) }, cost);
const li = h(
'li',
{ class: 'card' },
icon(`legacy.${perk.id}`),
h(
'div',
{ class: 'card__body' },
h('h3', { class: 'card__title' }, h('span', { text: t(`legacy.perk.${perk.id}`) }), level),
h('p', { class: 'card__desc', text: effectText(perk.effect, fmt) }),
),
buy,
);
perks.append(li);
perkRows.set(perk.id, { perk, level, cost, buy });
}
root.replaceChildren(
h(
'section',
{ class: 'panel' },
h('h2', { class: 'panel__title', text: t('tab.legacy') }),
h('p', { class: 'hint', text: t('legacy.intro', { passive: fmt.number(ctx.rules.legacy.passivePerPoint * 100) }) }),
h('div', { class: 'tile tile--primary' }, icon('res.legacy'), h('div', {}, h('span', { class: 'tile__label', text: t('theme.res.legacy') }), els.points)),
h(
'dl',
{ class: 'stats' },
h('div', {}, h('dt', { text: t('legacy.earned') }), els.earned),
h('div', {}, h('dt', { text: t('legacy.restarts') }), els.prestiges),
h('div', {}, h('dt', { text: t('legacy.passive') }), els.passive),
),
h('h3', { class: 'dialog__subtitle', text: t('legacy.restartTitle') }),
els.gain,
els.progress,
els.need,
els.restart,
),
h('section', { class: 'panel' }, h('h2', { class: 'panel__title', text: t('legacy.perks') }), perks),
);
}
function update(state, d) {
const { threshold, passivePerPoint } = ctx.rules.legacy;
setText(els.points, fmt.number(state.legacy.points));
setText(els.earned, fmt.number(state.legacy.earned));
setText(els.prestiges, fmt.number(state.legacy.prestiges));
setText(els.passive, t('legacy.passiveValue', { bonus: fmt.number((1 + passivePerPoint) ** state.legacy.earned) }));
const gain = legacyGain(state, ctx, d.mods);
setText(els.gain, t('legacy.gain', { gain: fmt.number(gain) }));
els.progress.max = threshold;
els.progress.value = Math.min(state.totalProduced, threshold);
setText(
els.need,
gain >= 1
? t('legacy.ready')
: t('legacy.need', { have: fmt.number(Math.floor(state.totalProduced)), need: fmt.number(threshold) }),
);
setText(els.restart, t('legacy.restart'));
els.restart.disabled = gain < 1;
for (const { perk, level, cost, buy } of perkRows.values()) {
const lv = perkLevel(state, perk);
const maxed = lv >= perk.maxLevel;
const price = perkCost(perk, lv);
setText(level, t('legacy.level', { level: lv, max: perk.maxLevel }));
setText(cost, maxed ? t('legacy.maxed') : t('legacy.perkCost', { cost: fmt.number(price) }));
buy.disabled = maxed || state.legacy.points < price;
}
}
return { rebuild, update };
}
+114
View File
@@ -0,0 +1,114 @@
// Research tab: labs produce points over time; points buy nodes of the tree.
import { labCost, researchStatus } from '../engine/research.js';
import { t } from '../services/i18n.js';
import { h, icon } from './dom.js';
import { effectText, moneyFormatter, setText } from './text.js';
const BRANCHES = ['prod', 'mkt', 'log', 'fin', 'ins'];
export function createResearchView(root, actions) {
let ctx;
let fmt;
let money;
let els;
const nodeRows = new Map();
function nodeRow(node) {
const status = h('p', { class: 'card__meta' });
const buy = h('button', {
class: 'btn btn--buy',
type: 'button',
onclick: () => actions.buyNode(node.id),
});
const li = h(
'li',
{ class: 'card' },
icon(`research.${node.id}`),
h(
'div',
{ class: 'card__body' },
h('h3', { class: 'card__title', text: t(`research.node.${node.id}`) }),
h('p', { class: 'card__desc', text: effectText(node.effect, fmt) }),
status,
),
buy,
);
return { li, status, buy, node };
}
function rebuild(newCtx, formatter) {
ctx = newCtx;
fmt = formatter;
money = moneyFormatter(fmt);
nodeRows.clear();
els = {
rp: h('strong'),
rate: h('span', { class: 'hint' }),
labs: h('span', { class: 'hint' }),
buyLab: h('button', { class: 'btn btn--wide', type: 'button', onclick: () => actions.buyLab() }),
};
const branchPanels = BRANCHES.map((branch) => {
const list = h('ul', { class: 'cards' });
for (const node of ctx.research.filter((n) => n.branch === branch)) {
const row = nodeRow(node);
nodeRows.set(node.id, row);
list.append(row.li);
}
return h(
'section',
{ class: 'panel' },
h('h2', { class: 'panel__title', text: t(`research.branch.${branch}`) }),
list,
);
});
root.replaceChildren(
h(
'section',
{ class: 'panel panel--wide' },
h('h2', { class: 'panel__title', text: t('tab.research') }),
h('p', { class: 'hint', text: t('research.intro') }),
h(
'div',
{ class: 'tile' },
icon('res.research'),
h('div', {}, h('span', { class: 'tile__label', text: t('theme.res.research') }), els.rp, els.rate),
),
els.labs,
els.buyLab,
),
...branchPanels,
);
}
function update(state, d) {
const r = ctx.rules.research;
setText(els.rp, fmt.number(Math.floor(state.rp)));
setText(els.rate, t('research.rate', { rate: fmt.number(Math.round(d.rpRate * 1000) / 1000) }));
setText(els.labs, t('research.labs', { count: state.labs, perLab: fmt.number(r.rpPerLabPerSecond * d.mods.rp) }));
const maxed = state.labs >= r.labMax;
const cost = labCost(state, ctx);
setText(els.buyLab, maxed ? t('research.labMax') : t('research.buyLab', { cost: money(cost) }));
els.buyLab.disabled = maxed || state.funds < cost;
for (const { li, status, buy, node } of nodeRows.values()) {
const s = researchStatus(state, node);
li.classList.toggle('card--done', s === 'owned');
li.classList.toggle('card--locked', s === 'locked');
buy.hidden = s !== 'available';
buy.disabled = state.rp < node.cost;
setText(buy, t('research.cost', { cost: fmt.number(node.cost) }));
if (s === 'owned') setText(status, t('research.owned'));
else if (s === 'locked') {
const names = node.requires.map((id) => t(`research.node.${id}`)).join(', ');
setText(status, t('research.requires', { names }));
} else setText(status, '');
}
}
return { rebuild, update };
}
+89
View File
@@ -0,0 +1,89 @@
// The section menu: horizontal tabs on desktop/tablet, a burger menu on phones
// (the markup is the same, the CSS decides). Tabs unlock with progress.
import { isUnlocked } from '../engine/engine.js';
import { has, t } from '../services/i18n.js';
import { $, h } from './dom.js';
import { showToast } from './toast.js';
const labelKey = (id) => (has(`theme.tab.${id}`) ? `theme.tab.${id}` : `tab.${id}`);
/**
* @param tabs data/tabs.json
* @param onSelect (tabId) called when the visible section changes
*/
export function createTabs(tabs, onSelect) {
const list = $('#tabs');
const menu = $('#menu');
const toggle = $('#menu-toggle');
const buttons = new Map();
let active = 'main';
let known = null; // unlocked tabs seen so far (null until the first update)
const unseen = new Set();
const view = (id) => $(`[data-view="${id}"]`);
function select(id) {
active = id;
unseen.delete(id);
for (const tab of tabs) {
const section = view(tab.id);
if (section) section.hidden = tab.id !== id;
buttons.get(tab.id)?.button.setAttribute('aria-current', tab.id === id ? 'page' : 'false');
}
buttons.get(id)?.item.classList.remove('tab--new');
setMenuOpen(false);
onSelect(id);
}
function setMenuOpen(open) {
menu.toggleAttribute('data-open', open);
toggle.setAttribute('aria-expanded', String(open));
}
toggle.addEventListener('click', () => setMenuOpen(!menu.hasAttribute('data-open')));
/** Build the buttons (call again after a language or theme change to relabel). */
function rebuild() {
list.replaceChildren();
buttons.clear();
for (const tab of tabs) {
const button = h('button', {
class: 'tab',
type: 'button',
'aria-current': tab.id === active ? 'page' : 'false',
text: t(labelKey(tab.id)),
onclick: () => select(tab.id),
});
const item = h('li', { class: 'tabs__item' }, button);
item.hidden = known ? !known.has(tab.id) : tab.id !== 'main';
if (unseen.has(tab.id)) item.classList.add('tab--new');
list.append(item);
buttons.set(tab.id, { item, button });
}
}
/** Call every frame: reveals newly unlocked tabs, flags the ones needing attention. */
function update(state) {
const unlocked = new Set(tabs.filter((tab) => isUnlocked(state, tab.unlock)).map((tab) => tab.id));
for (const tab of tabs) {
const entry = buttons.get(tab.id);
const isNew = known && unlocked.has(tab.id) && !known.has(tab.id);
entry.item.hidden = !unlocked.has(tab.id);
if (isNew) {
unseen.add(tab.id);
entry.item.classList.add('tab--new');
showToast(t('toast.tabUnlocked', { name: t(labelKey(tab.id)) }));
}
}
known = unlocked;
// An opportunity is waiting on the contracts tab.
buttons.get('contracts')?.item.classList.toggle('tab--alert', Boolean(state.events.current));
if (!unlocked.has(active)) select('main');
}
return { rebuild, update, select, get active() { return active; } };
}
+51
View File
@@ -0,0 +1,51 @@
// Text helpers shared by the views: money, durations, effects and rewards.
import { t } from '../services/i18n.js';
/** Money in the theme's currency, e.g. "12.50 GAL". */
export const moneyFormatter = (fmt) => (n) => t('theme.fmt.funds', { value: fmt.money(n) });
export function formatDuration(seconds) {
const total = Math.max(0, Math.round(seconds));
const minutes = Math.floor(total / 60);
if (minutes < 1) return t('time.seconds', { value: total });
if (minutes < 60) return t('time.minutes', { value: minutes });
return t('time.hoursMinutes', { hours: Math.floor(minutes / 60), minutes: minutes % 60 });
}
/** "×1.5 production", "-20% generator cost"... from an effect object. */
export function effectText(effect, fmt) {
const params = { value: fmt.number(effect.value) };
if (effect.target) params.target = t(`theme.gen.${effect.target}.name`);
if (effect.type === 'batch_discount' || effect.type === 'generator_cost_mult') {
params.percent = fmt.number(Math.round((1 - effect.value) * 100));
}
if (effect.type === 'reliability_add' || effect.type === 'offline_add') {
params.percent = fmt.number(Math.round(effect.value * 100));
}
if (effect.type === 'start_funds') params.funds = moneyFormatter(fmt)(effect.value);
return t(`effect.${effect.type}`, params);
}
/** What a contract pays, e.g. "Reward: 1.2M GAL". */
export function rewardText(reward, fmt, seconds = 0) {
const money = moneyFormatter(fmt);
switch (reward.type) {
case 'funds':
return t('contracts.reward.funds', { amount: money(reward.amount) });
case 'boost':
return t('contracts.reward.boost', { value: fmt.number(reward.amount), duration: formatDuration(seconds) });
default: // research, chips
return t(`contracts.reward.${reward.type}`, { amount: fmt.number(reward.amount) });
}
}
export function setText(node, text) {
if (node.textContent !== text) node.textContent = text;
}
/** "+3.2%" / "-1.0%" */
export function signedPercent(fraction, fmt) {
const sign = fraction > 0 ? '+' : '';
return `${sign}${fmt.number(Math.round(fraction * 1000) / 10)}%`;
}
+4 -1
View File
@@ -1,9 +1,12 @@
import { $, h } from './dom.js';
const DURATION_MS = 6000;
const MAX_VISIBLE = 3; // a big jump (import, long absence) must not bury the page in messages
export function showToast(message, { error = false } = {}) {
const container = $('#toasts');
const toast = h('p', { class: error ? 'toast toast--error' : 'toast', text: message });
$('#toasts').append(toast);
container.append(toast);
while (container.children.length > MAX_VISIBLE) container.firstElementChild.remove();
setTimeout(() => toast.remove(), DURATION_MS);
}