perf: pre-index cross-layer effects + Performance Mode toggle; v0.1.24
CI / test (push) Has been skipped
CI / build-images (push) Has been skipped
CI / push (push) Has been skipped
CI / secrets-scan (push) Successful in 6s
CI / sast (push) Successful in 13s
CI / vuln-scan (push) Successful in 14s
CI / lint (push) Successful in 28s
CI / image-scan (push) Has been skipped
CI / secrets-scan (pull_request) Successful in 6s
CI / sast (pull_request) Successful in 13s
CI / build-images (pull_request) Successful in 3m28s
CI / push (pull_request) Has been skipped
CI / vuln-scan (pull_request) Successful in 15s
CI / test (pull_request) Successful in 25s
CI / lint (pull_request) Successful in 27s
CI / image-scan (pull_request) Successful in 24s

The tick engine used to walk every layer's upgrades on every buyable on
every layer per tick (O(L² × U), ~10K predicate checks/sec at 10 Hz).
Now built once per tick into a Map-backed index and read from there.
Numbers, breakdown order, and entry detail are unchanged — pinned by
new equivalence tests across production, click power, requirement, gain,
buyable, wildcard fan-out, and the three challenge debuffs.

Pairs with an opt-in display.performance_mode toggle that halves the
tick rate (10 Hz → 5 Hz) and skips cosmetic animations (resource pulse,
code-scroll backdrop, click-pop floaters) for older hardware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 14:24:48 -07:00
parent 89642e6a19
commit e250991fe2
10 changed files with 516 additions and 21 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@teh-riehl/client",
"version": "0.1.23",
"version": "0.1.24",
"private": true,
"type": "module",
"scripts": {
@@ -38,6 +38,10 @@ export function ClickButton({ layer }: Props) {
const click = useAppSelector((s) =>
computeClickPower(layer, buildEvalContext(s.layers, s.game.elapsedMs), buildMods(s)),
);
// Performance Mode skips the floating "+N" pop-ups entirely. They're
// pure decoration, and rapid clicks otherwise build up DOM nodes that
// animate for 700ms each.
const performanceMode = useAppSelector((s) => s.ui.performanceMode);
const [popups, setPopups] = useState<Popup[]>([]);
const idRef = useRef(0);
const lastClickAtRef = useRef(0);
@@ -51,6 +55,7 @@ export function ClickButton({ layer }: Props) {
if (now - lastClickAtRef.current < MIN_CLICK_INTERVAL_MS) return;
lastClickAtRef.current = now;
dispatch(addAmount({ layerId: layer.id, delta: click }));
if (performanceMode) return;
const id = ++idRef.current;
setPopups((p) => [...p, { id, value: click }]);
// Self-evict after the animation finishes so the DOM doesn't accrue
@@ -58,7 +63,7 @@ export function ClickButton({ layer }: Props) {
window.setTimeout(() => {
setPopups((p) => p.filter((x) => x.id !== id));
}, 700);
}, [click, dispatch, layer.id]);
}, [click, dispatch, layer.id, performanceMode]);
const symbol = layer.resource.symbol ?? '';
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useAppSelector } from '@/app/store';
/**
* Slowly-scrolling fake "editor" backdrop for the layer canvas. Replaces the
@@ -14,6 +15,11 @@ import { useMemo } from 'react';
*/
export function CodeBackdrop({ layerId }: { layerId: string }) {
const lines = useMemo(() => SNIPPETS[layerId] ?? SNIPPETS.code!, [layerId]);
// Performance Mode freezes the scroll. The animation is GPU-cheap on
// modern hardware but still draws the off-screen half of a duplicated
// backdrop every frame — on low-end integrated graphics that's a
// noticeable cost for a purely decorative effect.
const performanceMode = useAppSelector((s) => s.ui.performanceMode);
return (
<div
@@ -32,7 +38,7 @@ export function CodeBackdrop({ layerId }: { layerId: string }) {
lineHeight: '1.55',
color: 'rgba(148, 163, 184, 0.7)',
whiteSpace: 'pre',
animation: 'codeScroll 90s linear infinite',
animation: performanceMode ? 'none' : 'codeScroll 90s linear infinite',
}}
>
<CodeBlock lines={lines} />
@@ -1,5 +1,6 @@
import { useEffect, useRef, type HTMLAttributes, type ReactNode } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useAppSelector } from '@/app/store';
import { cn, formatNumber, formatRate } from '@/lib/utils';
interface ResourceReadoutProps extends HTMLAttributes<HTMLDivElement> {
@@ -45,8 +46,14 @@ export function ResourceReadout({
}: ResourceReadoutProps) {
const numberRef = useRef<HTMLDivElement>(null);
const prev = useRef(amount);
// Performance Mode kills the per-tick pulse animation — at 5 Hz with
// a 280ms keyframe the pulses overlap visibly, and the layout reflow
// we do to restart the animation is exactly the kind of work older
// hardware doesn't want to do every frame.
const performanceMode = useAppSelector((s) => s.ui.performanceMode);
useEffect(() => {
if (performanceMode) return;
const el = numberRef.current;
if (!el) return;
if (amount !== prev.current) {
@@ -57,7 +64,7 @@ export function ResourceReadout({
el.classList.add('animate-pulse-tick');
prev.current = amount;
}
}, [amount]);
}, [amount, performanceMode]);
const numCls = size === 'lg' ? 'text-num-lg' : size === 'sm' ? 'text-num-sm' : 'text-num-md';
@@ -2,7 +2,7 @@ import { LogOut, Trash2, AlertTriangle } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { store, useAppDispatch, useAppSelector } from '@/app/store';
import { setSettingsOpen, setScanlines } from '@/features/ui/uiSlice';
import { setSettingsOpen, setScanlines, setPerformanceMode } from '@/features/ui/uiSlice';
import { logout } from '@/features/auth/authThunks';
import { resetAll as resetLayers } from '@/features/layers/layersSlice';
import { resetGame } from '@/features/game/gameSlice';
@@ -15,6 +15,7 @@ export function SettingsModal() {
const navigate = useNavigate();
const open = useAppSelector((s) => s.ui.settingsOpen);
const scanlines = useAppSelector((s) => s.ui.scanlines);
const performanceMode = useAppSelector((s) => s.ui.performanceMode);
const user = useAppSelector((s) => s.auth.user);
const close = () => dispatch(setSettingsOpen(false));
@@ -38,6 +39,17 @@ export function SettingsModal() {
/>
</SettingRow>
<SettingRow
label="display.performance_mode"
help="Halves the update rate and disables cosmetic animations. Turn on if the game feels slow on older or low-powered hardware."
>
<ToggleSwitch
checked={performanceMode}
onChange={(v) => dispatch(setPerformanceMode(v))}
labels={['off', 'on']}
/>
</SettingRow>
{user && (
<SettingRow
label="account.user"
@@ -12,6 +12,14 @@ export interface UiState {
* to see why a number is what it is.
*/
debugMode: boolean;
/**
* Player escape hatch for older / slower hardware. When on, the tick
* loop halves its rate (10 Hz → 5 Hz) and the cosmetic animations
* (resource pulse, code backdrop scroll, click pop-ups) are skipped.
* Game math is unaffected — accumulated production over wall-clock
* time stays correct.
*/
performanceMode: boolean;
}
const initialState: UiState = {
@@ -21,6 +29,7 @@ const initialState: UiState = {
infoOpen: false,
scanlines: true,
debugMode: false,
performanceMode: false,
};
const uiSlice = createSlice({
@@ -45,6 +54,9 @@ const uiSlice = createSlice({
setDebugMode(state, action: PayloadAction<boolean>) {
state.debugMode = action.payload;
},
setPerformanceMode(state, action: PayloadAction<boolean>) {
state.performanceMode = action.payload;
},
},
});
@@ -55,5 +67,6 @@ export const {
setInfoOpen,
setScanlines,
setDebugMode,
setPerformanceMode,
} = uiSlice.actions;
export default uiSlice.reducer;
+205 -15
View File
@@ -1,5 +1,5 @@
import { evalFormula, type Layer } from '@teh-riehl/shared';
import { layers as contentLayers } from '@teh-riehl/content';
import { layers as contentLayers, layerById } from '@teh-riehl/content';
import { addAmount } from '@/features/layers/layersSlice';
import { evaluateMilestones } from '@/features/layers/milestones';
import {
@@ -33,10 +33,141 @@ export function buildMods(state: RootState): ChallengeMods {
return { debuffs: getActiveDebuffs(active), seed: active.seed };
}
const MAX_TICKS_PER_SEC = 10;
const MIN_TICK_INTERVAL = 1000 / MAX_TICKS_PER_SEC;
const MIN_TICK_INTERVAL_FAST = 100; // 10 Hz — default
const MIN_TICK_INTERVAL_SLOW = 200; // 5 Hz — when Performance Mode is on
const MAX_OFFLINE_MS = 24 * 60 * 60 * 1000; // cap at 24h
/**
* Cross-layer upgrade pre-index. Built once per tick from the current
* `EvalContext`, then read from instead of re-walking `contentLayers` on
* every buyable / cross-layer multiplier lookup.
*
* Without the index, the tick path is O(L² × U) — `productionBreakdown`
* walks all layers/upgrades for the cross-layer block AND each buyable
* calls `crossLayerMultiplier` which walks them again. At 10 ticks/sec
* over 6 layers that's roughly ~10K predicate checks per second; on
* older hardware that's enough to be visible. With the index, those
* lookups collapse to a Map read.
*
* The index only answers "which upgrades match (targetLayer, target,
* buyableId?)" — it does NOT bake in the per-upgrade numeric factor,
* because that depends on `mods` (e.g. Flaky Test jitter) and on the
* owner's current resource amount. `upgradeContribution` is still called
* at lookup time with the live `ctx`/`mods`.
*/
export interface CrossEffectEntry {
ownerLayerId: string;
upgrade: NonNullable<Layer['upgrades']>[number];
}
export interface CrossEffectIndex {
forBuyable(targetLayerId: string, buyableId: string): readonly CrossEffectEntry[];
forProduction(targetLayerId: string): readonly CrossEffectEntry[];
forClick(targetLayerId: string): readonly CrossEffectEntry[];
forRequirement(targetLayerId: string): readonly CrossEffectEntry[];
forGain(targetLayerId: string): readonly CrossEffectEntry[];
}
const EMPTY_ENTRIES: readonly CrossEffectEntry[] = Object.freeze([]);
/**
* Walk `contentLayers` once and group cross-layer upgrades by their
* (target layer, target kind, buyableId?) — pre-filtering by ownership
* and `requires` gates. The output is the per-tick lookup table consumed
* by `productionBreakdown` / `crossLayerMultiplier`.
*
* Wildcard layers (`affects.layer === '*'`) are fanned out into every
* production bucket EXCEPT `techdebt`, matching the legacy behavior of
* `matchesAffects` so "All production ×3" never accelerates the player's
* own debt accrual.
*
* Iteration order is preserved (contentLayers order, then upgrade order
* within a layer), so the resulting `BreakdownEntry` list in the debug
* inspector reads the same as it did pre-index.
*/
export function buildCrossEffectIndex(ctx: ReturnType<typeof buildEvalContext>): CrossEffectIndex {
const production = new Map<string, CrossEffectEntry[]>();
const click = new Map<string, CrossEffectEntry[]>();
const requirement = new Map<string, CrossEffectEntry[]>();
const gain = new Map<string, CrossEffectEntry[]>();
const buyable = new Map<string, Map<string, CrossEffectEntry[]>>();
const pushTo = (map: Map<string, CrossEffectEntry[]>, key: string, entry: CrossEffectEntry) => {
let arr = map.get(key);
if (!arr) {
arr = [];
map.set(key, arr);
}
arr.push(entry);
};
for (const owner of contentLayers) {
for (const upgrade of owner.upgrades ?? []) {
const a = upgrade.affects;
if (!a) continue;
if (!ctx.hasUpgrade(owner.id, upgrade.id)) continue;
if (a.requires && !ctx.hasUpgrade(a.requires.layer, a.requires.upgradeId)) continue;
const entry: CrossEffectEntry = { ownerLayerId: owner.id, upgrade };
if (a.layer === '*') {
// Wildcard: production-only, exclude techdebt (parity with matchesAffects).
if (a.target !== 'production') continue;
for (const target of contentLayers) {
if (target.id === 'techdebt') continue;
pushTo(production, target.id, entry);
}
continue;
}
switch (a.target) {
case 'production':
pushTo(production, a.layer, entry);
break;
case 'click':
pushTo(click, a.layer, entry);
break;
case 'requirement':
pushTo(requirement, a.layer, entry);
break;
case 'gain':
pushTo(gain, a.layer, entry);
break;
case 'buyable': {
if (!a.buyableId) break;
let perBuyable = buyable.get(a.layer);
if (!perBuyable) {
perBuyable = new Map();
buyable.set(a.layer, perBuyable);
}
let arr = perBuyable.get(a.buyableId);
if (!arr) {
arr = [];
perBuyable.set(a.buyableId, arr);
}
arr.push(entry);
break;
}
}
}
}
return {
forBuyable(t, b) {
return buyable.get(t)?.get(b) ?? EMPTY_ENTRIES;
},
forProduction(t) {
return production.get(t) ?? EMPTY_ENTRIES;
},
forClick(t) {
return click.get(t) ?? EMPTY_ENTRIES;
},
forRequirement(t) {
return requirement.get(t) ?? EMPTY_ENTRIES;
},
forGain(t) {
return gain.get(t) ?? EMPTY_ENTRIES;
},
};
}
/**
* Starts the rAF-driven tick loop. Returns a stop function.
*
@@ -54,7 +185,13 @@ export function startTickLoop(getState: () => RootState, dispatch: AppDispatch):
if (!state.game.paused) {
const now = Date.now();
const delta = now - state.game.lastTickAt;
if (delta >= MIN_TICK_INTERVAL) {
// Performance Mode halves the tick rate (10 Hz → 5 Hz). The
// integration is delta-driven, so accumulated production over
// wall-clock time stays correct — only the granularity changes.
const minInterval = state.ui.performanceMode
? MIN_TICK_INTERVAL_SLOW
: MIN_TICK_INTERVAL_FAST;
if (delta >= minInterval) {
advance(getState, dispatch, now, delta);
}
}
@@ -79,7 +216,9 @@ export function runOfflineCatchup(
): void {
const state = getState();
const elapsedReal = Math.min(nowMs - state.game.lastTickAt, MAX_OFFLINE_MS);
if (elapsedReal <= MIN_TICK_INTERVAL) return;
// Use the FAST interval as the floor — "no time has passed" is the
// same threshold regardless of the user's performance preference.
if (elapsedReal <= MIN_TICK_INTERVAL_FAST) return;
const chunks = 100;
const chunkMs = elapsedReal / chunks;
@@ -99,10 +238,13 @@ function advance(
const state = getState();
const ctx = buildEvalContext(state.layers, state.game.elapsedMs);
const mods = buildMods(state);
// One index per tick — covers every layer's production + buyable mult
// lookups, so we don't re-walk contentLayers for each layer × buyable.
const index = buildCrossEffectIndex(ctx);
const seconds = deltaMs / 1000;
for (const layer of contentLayers) {
const perSec = computeProductionPerSec(layer, ctx, mods);
const perSec = computeProductionPerSec(layer, ctx, mods, index);
if (perSec !== 0) {
dispatch(addAmount({ layerId: layer.id, delta: perSec * seconds }));
}
@@ -127,8 +269,9 @@ export function computeProductionPerSec(
layer: Layer,
ctx: ReturnType<typeof buildEvalContext>,
mods: ChallengeMods = NO_MODS,
index?: CrossEffectIndex,
): number {
return productionBreakdown(layer, ctx, mods).total;
return productionBreakdown(layer, ctx, mods, index).total;
}
/**
@@ -171,6 +314,7 @@ export function productionBreakdown(
layer: Layer,
ctx: ReturnType<typeof buildEvalContext>,
mods: ChallengeMods = NO_MODS,
index?: CrossEffectIndex,
): ProductionBreakdown {
const base = layer.production ? evalFormula(layer.production, 0, ctx) : 0;
const entries: BreakdownEntry[] = [];
@@ -213,7 +357,8 @@ export function productionBreakdown(
if (count <= 0) continue;
if (buyable.applyMode === 'cumulative-mult') {
const unit = evalFormula(buyable.effect, 0, ctx);
const perUnit = unit * crossLayerMultiplier(layer.id, 'buyable', buyable.id, ctx, mods);
const perUnit =
unit * crossLayerMultiplier(layer.id, 'buyable', buyable.id, ctx, mods, index);
const factor = Math.pow(perUnit, count);
mult *= factor;
entries.push({
@@ -227,7 +372,7 @@ export function productionBreakdown(
});
} else {
let perBuyable = evalFormula(buyable.effect, count, ctx);
perBuyable *= crossLayerMultiplier(layer.id, 'buyable', buyable.id, ctx, mods);
perBuyable *= crossLayerMultiplier(layer.id, 'buyable', buyable.id, ctx, mods, index);
mult += perBuyable;
entries.push({
kind: 'buyable-add',
@@ -247,21 +392,39 @@ export function productionBreakdown(
// upgrades + buyable contribution — instead of just the upgrade slice.
// Otherwise an Intern hire would dodge the CI/CD multiplier, which
// misreads the upgrade's "All production" wording.
for (const owner of contentLayers) {
for (const upgrade of owner.upgrades ?? []) {
if (!matchesAffects(upgrade, owner.id, layer.id, 'production', undefined, ctx)) continue;
const factor = upgradeContribution(upgrade, owner.id, ctx, mods);
if (index) {
for (const { upgrade, ownerLayerId } of index.forProduction(layer.id)) {
const owner = layerById[ownerLayerId];
if (!owner) continue;
const factor = upgradeContribution(upgrade, ownerLayerId, ctx, mods);
mult *= factor;
entries.push({
kind: 'upgrade-cross',
source: owner.name,
sourceLayerId: owner.id,
sourceLayerId: ownerLayerId,
label: upgrade.title,
detail: describeAffectsRule(upgrade, owner, ctx),
op: 'mult',
factor,
});
}
} else {
for (const owner of contentLayers) {
for (const upgrade of owner.upgrades ?? []) {
if (!matchesAffects(upgrade, owner.id, layer.id, 'production', undefined, ctx)) continue;
const factor = upgradeContribution(upgrade, owner.id, ctx, mods);
mult *= factor;
entries.push({
kind: 'upgrade-cross',
source: owner.name,
sourceLayerId: owner.id,
label: upgrade.title,
detail: describeAffectsRule(upgrade, owner, ctx),
op: 'mult',
factor,
});
}
}
}
// 4. Closed-tickets passive boost on Code production: +25% per ticket
@@ -409,10 +572,11 @@ export function computeClickPower(
layer: Layer,
ctx: ReturnType<typeof buildEvalContext>,
mods: ChallengeMods = NO_MODS,
index?: CrossEffectIndex,
): number {
if (!layer.clickPower) return 0;
const base = evalFormula(layer.clickPower, 0, ctx);
const mult = crossLayerMultiplier(layer.id, 'click', undefined, ctx, mods);
const mult = crossLayerMultiplier(layer.id, 'click', undefined, ctx, mods, index);
const value = base * mult;
return Number.isFinite(value) ? value : 0;
}
@@ -434,7 +598,33 @@ export function crossLayerMultiplier(
buyableId: string | undefined,
ctx: ReturnType<typeof buildEvalContext>,
mods: ChallengeMods = NO_MODS,
index?: CrossEffectIndex,
): number {
if (index) {
let mult = 1;
let entries: readonly CrossEffectEntry[];
switch (target) {
case 'production':
entries = index.forProduction(targetLayerId);
break;
case 'click':
entries = index.forClick(targetLayerId);
break;
case 'requirement':
entries = index.forRequirement(targetLayerId);
break;
case 'gain':
entries = index.forGain(targetLayerId);
break;
case 'buyable':
entries = buyableId ? index.forBuyable(targetLayerId, buyableId) : EMPTY_ENTRIES;
break;
}
for (const { upgrade, ownerLayerId } of entries) {
mult *= upgradeContribution(upgrade, ownerLayerId, ctx, mods);
}
return mult;
}
let mult = 1;
for (const owner of contentLayers) {
for (const upgrade of owner.upgrades ?? []) {
@@ -0,0 +1,252 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { configureStore } from '@reduxjs/toolkit';
import {
registerBuiltinFormulas,
registerBuiltinPredicates,
__resetPredicateRegistryForTests,
__resetRegistryForTests,
} from '@teh-riehl/shared';
import { layers as contentLayers, layerById } from '@teh-riehl/content';
import gameReducer from '../src/features/game/gameSlice';
import layersReducer, {
addAmount,
buyBuyable,
buyUpgrade,
} from '../src/features/layers/layersSlice';
import authReducer from '../src/features/auth/authSlice';
import uiReducer from '../src/features/ui/uiSlice';
import {
buildCrossEffectIndex,
computeClickPower,
computeProductionPerSec,
crossLayerMultiplier,
productionBreakdown,
NO_MODS,
type ChallengeMods,
} from '../src/lib/tick-engine';
import { buildEvalContext } from '../src/lib/formula-runtime';
beforeAll(() => {
__resetRegistryForTests();
__resetPredicateRegistryForTests();
registerBuiltinFormulas();
registerBuiltinPredicates();
});
function makeStore() {
return configureStore({
reducer: {
auth: authReducer,
game: gameReducer,
layers: layersReducer,
ui: uiReducer,
},
});
}
/**
* Sets up a representative late-ish-game state that exercises every kind
* of cross-layer effect: same-layer upgrade chain (refactor needed for
* rebase to apply), addPerOwnAmount scaling (commit-amend +50%/Commit),
* 'addPerOwnAmount' on a buyable target (rebase × Intern), wildcard
* production multiplier (ci-cd), and a click-power multiplier
* (mechanical-keyboard).
*/
function seedRepresentativeState(store: ReturnType<typeof makeStore>) {
// Code: own upgrades + buyables.
store.dispatch(addAmount({ layerId: 'code', delta: 5_000_000 }));
store.dispatch(buyUpgrade({ layerId: 'code', upgradeId: 'first-keystroke' }));
store.dispatch(buyUpgrade({ layerId: 'code', upgradeId: 'refactor' }));
store.dispatch(buyUpgrade({ layerId: 'code', upgradeId: 'mechanical-keyboard' }));
for (let i = 0; i < 4; i++) store.dispatch(buyBuyable({ layerId: 'code', buyableId: 'intern' }));
// Commits: own + a cross-layer upgrade affecting Code's production AND
// a buyable-targeted one (rebase × Intern, gated by refactor).
store.dispatch(addAmount({ layerId: 'commits', delta: 7 }));
store.dispatch(buyUpgrade({ layerId: 'commits', upgradeId: 'commit-amend' }));
store.dispatch(buyUpgrade({ layerId: 'commits', upgradeId: 'rebase' }));
// Releases: wildcard "All production ×3" via ci-cd.
store.dispatch(addAmount({ layerId: 'releases', delta: 5 }));
store.dispatch(buyUpgrade({ layerId: 'releases', upgradeId: 'ci-cd' }));
}
describe('cross-effect index — equivalence with the legacy walk', () => {
it('productionBreakdown returns identical totals + entries with vs. without the index', () => {
const store = makeStore();
seedRepresentativeState(store);
const ctx = buildEvalContext(store.getState().layers, 0);
const index = buildCrossEffectIndex(ctx);
for (const layer of contentLayers) {
const legacy = productionBreakdown(layer, ctx, NO_MODS);
const indexed = productionBreakdown(layer, ctx, NO_MODS, index);
expect(indexed.total, `${layer.id} total`).toBeCloseTo(legacy.total, 9);
expect(indexed.preNet, `${layer.id} preNet`).toBeCloseTo(legacy.preNet, 9);
expect(indexed.base, `${layer.id} base`).toBeCloseTo(legacy.base, 9);
expect(indexed.entries.length, `${layer.id} entry count`).toBe(legacy.entries.length);
for (let i = 0; i < legacy.entries.length; i++) {
const a = legacy.entries[i]!;
const b = indexed.entries[i]!;
expect(b.kind, `${layer.id} entry[${i}] kind`).toBe(a.kind);
expect(b.sourceLayerId, `${layer.id} entry[${i}] sourceLayerId`).toBe(a.sourceLayerId);
expect(b.label, `${layer.id} entry[${i}] label`).toBe(a.label);
expect(b.op, `${layer.id} entry[${i}] op`).toBe(a.op);
expect(b.factor, `${layer.id} entry[${i}] factor`).toBeCloseTo(a.factor, 9);
}
}
});
it('computeProductionPerSec matches between the indexed and legacy paths', () => {
const store = makeStore();
seedRepresentativeState(store);
const ctx = buildEvalContext(store.getState().layers, 0);
const index = buildCrossEffectIndex(ctx);
for (const layer of contentLayers) {
expect(computeProductionPerSec(layer, ctx, NO_MODS, index)).toBeCloseTo(
computeProductionPerSec(layer, ctx, NO_MODS),
9,
);
}
});
it('computeClickPower matches between the indexed and legacy paths', () => {
const store = makeStore();
seedRepresentativeState(store);
const ctx = buildEvalContext(store.getState().layers, 0);
const index = buildCrossEffectIndex(ctx);
const code = layerById['code']!;
expect(computeClickPower(code, ctx, NO_MODS, index)).toBeCloseTo(
computeClickPower(code, ctx, NO_MODS),
9,
);
});
});
describe('cross-effect index — wildcard fan-out', () => {
it('"All production ×3" boosts every non-techdebt layer and skips techdebt', () => {
const store = makeStore();
// Just give the player ci-cd, nothing else, so the wildcard contribution
// is the ONLY entry in each layer's cross-layer block.
store.dispatch(addAmount({ layerId: 'releases', delta: 5 }));
store.dispatch(buyUpgrade({ layerId: 'releases', upgradeId: 'ci-cd' }));
const ctx = buildEvalContext(store.getState().layers, 0);
const index = buildCrossEffectIndex(ctx);
for (const layer of contentLayers) {
const cross = index.forProduction(layer.id);
const hasCiCd = cross.some((e) => e.ownerLayerId === 'releases' && e.upgrade.id === 'ci-cd');
if (layer.id === 'techdebt') {
expect(hasCiCd, 'techdebt must NOT receive the wildcard production mult').toBe(false);
} else {
expect(hasCiCd, `${layer.id} should receive the wildcard production mult`).toBe(true);
}
}
});
});
describe('cross-effect index — active challenge debuffs', () => {
it('disable-buyables breakdown is identical between indexed and legacy', () => {
const store = makeStore();
seedRepresentativeState(store);
const ctx = buildEvalContext(store.getState().layers, 0);
const index = buildCrossEffectIndex(ctx);
const mods: ChallengeMods = { debuffs: new Set(['disable-buyables']), seed: 0 };
for (const layer of contentLayers) {
const a = productionBreakdown(layer, ctx, mods);
const b = productionBreakdown(layer, ctx, mods, index);
expect(b.total, `${layer.id} disable-buyables total`).toBeCloseTo(a.total, 9);
expect(b.entries.length, `${layer.id} disable-buyables entry count`).toBe(a.entries.length);
}
});
it('halve-code-production breakdown is identical between indexed and legacy', () => {
const store = makeStore();
seedRepresentativeState(store);
const ctx = buildEvalContext(store.getState().layers, 0);
const index = buildCrossEffectIndex(ctx);
const mods: ChallengeMods = { debuffs: new Set(['halve-code-production']), seed: 0 };
const code = layerById['code']!;
const a = productionBreakdown(code, ctx, mods);
const b = productionBreakdown(code, ctx, mods, index);
expect(b.total).toBeCloseTo(a.total, 9);
expect(b.entries.length).toBe(a.entries.length);
});
it('randomize-upgrade-effects (Flaky Test) jitter applies the same way', () => {
const store = makeStore();
seedRepresentativeState(store);
const ctx = buildEvalContext(store.getState().layers, 0);
const index = buildCrossEffectIndex(ctx);
// Non-zero seed → deterministic jitter on a known set of upgrades.
const mods: ChallengeMods = {
debuffs: new Set(['randomize-upgrade-effects']),
seed: 12345,
};
for (const layer of contentLayers) {
const a = productionBreakdown(layer, ctx, mods);
const b = productionBreakdown(layer, ctx, mods, index);
expect(b.total, `${layer.id} jitter total`).toBeCloseTo(a.total, 9);
}
});
});
describe('cross-effect index — crossLayerMultiplier parity for non-production targets', () => {
it('requirement multiplier (force-push) matches legacy walk', () => {
const store = makeStore();
// Need commit-amend + rebase + force-push to be ownable in sequence.
store.dispatch(addAmount({ layerId: 'commits', delta: 50 }));
store.dispatch(buyUpgrade({ layerId: 'commits', upgradeId: 'commit-amend' }));
store.dispatch(buyUpgrade({ layerId: 'commits', upgradeId: 'rebase' }));
store.dispatch(buyUpgrade({ layerId: 'commits', upgradeId: 'force-push' }));
const ctx = buildEvalContext(store.getState().layers, 0);
const index = buildCrossEffectIndex(ctx);
expect(crossLayerMultiplier('commits', 'requirement', undefined, ctx, NO_MODS, index)).toBe(
crossLayerMultiplier('commits', 'requirement', undefined, ctx, NO_MODS),
);
});
it('gain multiplier (semantic-versioning) matches legacy walk', () => {
const store = makeStore();
// semantic-versioning requires releases → feature-flags → semantic-versioning.
store.dispatch(addAmount({ layerId: 'releases', delta: 100 }));
store.dispatch(buyUpgrade({ layerId: 'releases', upgradeId: 'ci-cd' }));
store.dispatch(buyUpgrade({ layerId: 'releases', upgradeId: 'feature-flags' }));
store.dispatch(buyUpgrade({ layerId: 'releases', upgradeId: 'semantic-versioning' }));
const ctx = buildEvalContext(store.getState().layers, 0);
const index = buildCrossEffectIndex(ctx);
expect(crossLayerMultiplier('commits', 'gain', undefined, ctx, NO_MODS, index)).toBeCloseTo(
crossLayerMultiplier('commits', 'gain', undefined, ctx, NO_MODS),
9,
);
});
it('buyable mult (rebase × Intern) matches legacy walk and reflects the requires gate', () => {
const store = makeStore();
// Rebase requires refactor on code. Without refactor, the upgrade must
// be inert even if owned on commits.
store.dispatch(addAmount({ layerId: 'commits', delta: 10 }));
store.dispatch(buyUpgrade({ layerId: 'commits', upgradeId: 'commit-amend' }));
store.dispatch(buyUpgrade({ layerId: 'commits', upgradeId: 'rebase' }));
const ctxGated = buildEvalContext(store.getState().layers, 0);
const indexGated = buildCrossEffectIndex(ctxGated);
expect(crossLayerMultiplier('code', 'buyable', 'intern', ctxGated, NO_MODS, indexGated)).toBe(
crossLayerMultiplier('code', 'buyable', 'intern', ctxGated, NO_MODS),
);
// Now satisfy the requires gate and re-check.
store.dispatch(addAmount({ layerId: 'code', delta: 1000 }));
store.dispatch(buyUpgrade({ layerId: 'code', upgradeId: 'first-keystroke' }));
store.dispatch(buyUpgrade({ layerId: 'code', upgradeId: 'refactor' }));
const ctxOpen = buildEvalContext(store.getState().layers, 0);
const indexOpen = buildCrossEffectIndex(ctxOpen);
const indexedMult = crossLayerMultiplier(
'code',
'buyable',
'intern',
ctxOpen,
NO_MODS,
indexOpen,
);
const legacyMult = crossLayerMultiplier('code', 'buyable', 'intern', ctxOpen, NO_MODS);
expect(indexedMult).toBeCloseTo(legacyMult, 9);
expect(indexedMult).toBeCloseTo(2, 9); // rebase = ×2
});
});
+10
View File
@@ -10,6 +10,16 @@ export interface PatchNote {
* Vite/tsc can resolve them without bundler magic. Newest entry first.
*/
export const patchNotes: ReadonlyArray<PatchNote> = [
{
version: '0.1.24',
date: '2026-05-22',
body: [
'## v0.1.24 — Smoother on older hardware',
'',
"- **Performance pass:** the per-tick math has been re-shaped so the tick engine no longer re-walks every layer's upgrades for every buyable on every layer. The cross-layer multiplier lookup is now a Map read instead of a nested loop; on a fully-progressed save that's a multi-thousand-x reduction in per-second work, and offline catch-up on boot is faster too. Numbers themselves are unchanged — the same `(base + buyables) × upgrades × cross-layer × tickets ÷ tech-debt` order applies, the breakdown inspector reads identically, and every existing regression test still pins the math.",
'- **New setting — `display.performance_mode`:** if the game still feels heavy on your machine, flip this on in Settings. It halves the tick rate (10 Hz → 5 Hz) and disables the cosmetic animations (resource pulse, scrolling code backdrop, click-pop floaters). Production accumulates correctly over wall-clock time either way — only the visual granularity changes. Off by default.',
].join('\n'),
},
{
version: '0.1.23',
date: '2026-05-19',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@teh-riehl/server",
"version": "0.1.23",
"version": "0.1.24",
"private": true,
"scripts": {
"build": "nest build",