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
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:
@@ -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;
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@teh-riehl/server",
|
||||
"version": "0.1.23",
|
||||
"version": "0.1.24",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
|
||||
Reference in New Issue
Block a user