diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c65697..77d611f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## v1.11.0 + +- **Things can now go wrong.** Every few hours something breaks: ransomware + halves every lane, an ISP outage takes the Grid dark, a drive failure kills + one rack tier. Incidents only ever **reduce output** — they never destroy + racks, FLOPS, tapes or upgrades, so there is no such thing as a dead save + and no repair you must be able to afford. In an idle game the real currency + is lost time, and that is all this takes. + + You are never told when the next one is coming, only the standing rate + (about one every six hours). That is deliberate: a schedule you can see + turns preparation into buying one licence twenty minutes beforehand. + +- **Prepaid supplies, and a cure priced worse than preparing.** Antivirus + licences, backup ISP lines and spare drives are bought with FLOPS and absorb + one matching incident automatically — **including while you are offline**, + which is the only defence that can reach an incident that starts and ends + during a nine-hour absence. They live in your permanent progress, so they + survive a Migrate; spend down before you prestige rather than watching the + balance evaporate. + + Something already broken can be resolved on the spot for FLOPS, scaled by + how much of it is left. That price is always higher than the supply that + would have prevented it. Coming back to a running incident should never + leave you a spectator, but it should never be the cheap path either. + +- **Cold Storage never fails.** No incident touches it — not blocks, not jobs, + not tapes, not the tape tree. It is the one lane that always pays, and a + real reason to invest before a long absence. + +- **The Grid takes scheduled maintenance.** Unlike incidents, a maintenance + window is announced well ahead and shown with a countdown, so you can route + around it. Downtime you can plan for is a decision; downtime you cannot is + indistinguishable from the game being broken. + +- **The Overclock Bay no longer produces FLOPS. It multiplies your Racks.** + This changes how an existing lane works, so read it carefully: the nodes you + own now contribute a multiplier to Racks output instead of generating output + of their own. At the shipped balance the conversion is **exactly neutral** — + your total output is the same the moment it deploys — but the lane now + scales with your racks rather than beside them. + + Overheating changed to match. Instead of freezing the Overclock lane, it now + knocks **one rack tier offline** for a few minutes. Running hot risks the + very thing it amplifies, and the punishment is self-limiting. Nothing is + ever destroyed, and no nodes are lost. + +- **All of it is switchable from the Balancing tab**, including a master kill + switch. Turning the system off is a true kill, not a pause: any incident + already running is cleared on the next reconcile, so nobody is left + throttled by a system that no longer exists. The config schema grew a proper + boolean type to make that possible — a 0/1 "boolean" is exactly the kind of + thing that later gets set to 2. + ## v1.10.0 - **Triggering a Singularity deleted you from the Legacy Cores leaderboard.** diff --git a/Dockerfile b/Dockerfile index 86697df..ccbbd1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,7 @@ LABEL org.opencontainers.image.licenses="MIT" # only on a pushed vX.Y.Z tag, and docker/metadata-action derives the # published image's version label from that tag - so this literal only # affects locally-built images, not what GHCR publishes. -LABEL org.opencontainers.image.version="1.10.0" +LABEL org.opencontainers.image.version="1.11.0" VOLUME ["/app/data"] EXPOSE 3000 diff --git a/client/src/RackStack.jsx b/client/src/RackStack.jsx index 6e0b079..f29275c 100644 --- a/client/src/RackStack.jsx +++ b/client/src/RackStack.jsx @@ -34,6 +34,8 @@ import ColdStoragePanel from './game/components/ColdStoragePanel.jsx'; import EventPanel from './game/components/EventPanel.jsx'; import SocialPanel from './game/components/SocialPanel.jsx'; import StreakBanner from './game/components/StreakBanner.jsx'; +import OutageStrip from './game/components/OutageStrip.jsx'; +import ResiliencePanel from './game/components/ResiliencePanel.jsx'; import AnomalyToast from './game/components/AnomalyToast.jsx'; import RushOverlay from './game/components/minigames/RushOverlay.jsx'; import DebugOverlay from './game/components/minigames/DebugOverlay.jsx'; @@ -97,6 +99,16 @@ function buildTourCtx(state, now) { }; } +// v1.11: display names for the one-shot outage notices evaluate() attaches to +// server.outageNotices. Module scope, not component scope - it is static, and +// handleReconcile (defined above where a component-scope const would live) +// reads it. +const OUTAGE_NOTICE_LABEL = { + ransomware: 'Ransomware', + ispOutage: 'ISP outage', + driveFailure: 'Drive failure', +}; + // Identity of the EFFECTIVE gameplay config. The stored config's `version` // alone is not enough: activating or ending a live event changes the numbers // the server evaluates with (its modifiers are overlaid on the baseline) @@ -329,6 +341,18 @@ export default function RackStack({ user }) { if (serverState.server.overheated) setModal({ type: 'meltdown' }); + // v1.11: one-shot outage notices, same lifecycle as `overheated` above. + // Toast, not modal - these are information, not a reward (the v1.10 rule: + // rewards use the modal, everything else uses the toast). The ABSORBED + // notice is mandatory (spec §6): the moment a hedge pays off is the only + // time the player learns hedging was worth it, and a silent save is a + // wasted save. + for (const n of serverState.server.outageNotices || []) { + showToast(n.absorbed + ? `${OUTAGE_NOTICE_LABEL[n.kind] || n.kind} absorbed. ${n.remaining} left.` + : `${OUTAGE_NOTICE_LABEL[n.kind] || n.kind} - part of your farm is degraded.`); + } + // Live Events (v1.4): activeEvent/eventLeaderboard aren't part of // canonical state (see refreshEventData's own doc comment) - piggyback // their refresh on the cadence reconciles already happen at, throttled, @@ -602,6 +626,12 @@ export default function RackStack({ user }) { function buyGrid(i, mode) { dispatchAction({ type: 'buy', lane: 'grid', index: i, mode }); } function buyOverclock(i, mode) { dispatchAction({ type: 'buy', lane: 'overclock', index: i, mode }); } function ventHeat() { dispatchAction({ type: 'vent' }); } + // v1.11. Neither belongs in api.js's IMMEDIATE set: both are ordinary + // economy actions whose optimistic result is exactly what the server will + // confirm, so the normal 1s flush is right. (claimAnomaly is IMMEDIATE only + // because its reward is rolled server-side and cannot be predicted.) + function buySupply(id) { dispatchAction({ type: 'buySupply', id }); } + function resolveOutage(id) { dispatchAction({ type: 'resolveOutage', id }); } function buyUpgrade(u) { dispatchAction({ type: 'buyUpgrade', id: u.id }); } function buyShardUpgrade(u) { dispatchAction({ type: 'buyShardUpgrade', id: u.id }); } function claimBlock(index) { dispatchAction({ type: 'claimBlock', index }); } @@ -1122,6 +1152,7 @@ export default function RackStack({ user }) {
setProfileOpen(true)} /> + {eventLive && activeEvent && ( setActiveTab('event')} /> )} @@ -1138,7 +1169,7 @@ export default function RackStack({ user }) {
{activeTab === 'racks' && ( - + )} {activeTab === 'grid' && ( @@ -1161,6 +1192,17 @@ export default function RackStack({ user }) { /> )} + {activeTab === 'resilience' && ( + + )} + {activeTab === 'upgrades' && } {activeTab === 'singularity' && ( diff --git a/client/src/game/components/OutageStrip.jsx b/client/src/game/components/OutageStrip.jsx new file mode 100644 index 0000000..1bc41a2 --- /dev/null +++ b/client/src/game/components/OutageStrip.jsx @@ -0,0 +1,78 @@ +import { AlertTriangle, CalendarClock } from 'lucide-react'; +import { cardBorder, textDim, danger, amber } from '../theme.js'; +import { activeAt } from '@shared/outages.js'; +import { GRID_DEFS, TIER_DEFS } from '@shared/gameData.js'; + +// One coherent story about a slowdown, read from server.outages - the single +// representation every source shares (spec §3). There is no separate hazard +// list and maintenance list to reconcile here because there is no separate +// list anywhere. +const KIND_LABEL = { + ransomware: 'ransomware', + ispOutage: 'ISP outage', + driveFailure: 'drive failure', + maintenance: 'maintenance', + overheat: 'overheat', +}; + +function scopeLabel(scope) { + if (!scope) return 'Something'; + if (scope.lane === '*') return 'All lanes'; + if (scope.lane === 'grid') { + const def = GRID_DEFS[scope.index]; + return def ? `Grid: ${def.name}` : 'Grid'; + } + if (scope.lane === 'tiers') { + const def = TIER_DEFS[scope.index]; + return def ? def.name : 'A rack tier'; + } + return 'Overclock'; +} + +function remaining(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + return `${Math.floor(m / 60)}h ${m % 60}m`; +} + +export default function OutageStrip({ outages, gridMaintenance, now }) { + const live = activeAt(outages, now); + const upcoming = gridMaintenance && gridMaintenance.startAt > now ? gridMaintenance : null; + if (live.length === 0 && !upcoming) return null; + + return ( +
+ {live.map((o) => ( +
+ + + {scopeLabel(o.scope)} + {o.factor === 0 ? ' offline' : ` at ${Math.round(o.factor * 100)}%`} + {' · '}{KIND_LABEL[o.kind] || o.kind} + {' · '}{remaining(o.endAt - now)} left + +
+ ))} + {/* Maintenance is telegraphed (spec decision 3) - the one thing in this + release the player gets to see coming and route around. */} + {upcoming && ( +
+ + + Scheduled maintenance: {scopeLabel({ lane: 'grid', index: upcoming.index })} + {' · in '}{remaining(upcoming.startAt - now)} + +
+ )} +
+ ); +} diff --git a/client/src/game/components/OverclockPanel.jsx b/client/src/game/components/OverclockPanel.jsx index 0bf64e4..6e3759d 100644 --- a/client/src/game/components/OverclockPanel.jsx +++ b/client/src/game/components/OverclockPanel.jsx @@ -30,7 +30,7 @@ export default function OverclockPanel({ run, overclockMult, thresholds, onBuy,
- Overclock nodes run on their own like the Grid, but generate heat. Let it hit 100% and the lane freezes for {Math.round(overheatCooldownMs / 1000)}s while it cools down - no nodes are ever lost. Venting sheds {Math.round(ventPercent)}% of your heat capacity, so keep venting to avoid the lockout. + Overclock nodes no longer produce FLOPS on their own - they multiply your Racks output instead, and generate heat doing it. Let heat hit 100% and one of your rack tiers goes dark for a while: running hot risks the very thing it amplifies. No nodes are ever lost. Venting sheds {Math.round(ventPercent)}% of your heat capacity, so keep venting to avoid it.
{OVERCLOCK_DEFS.map((def, i) => { const o = run.overclock[i]; @@ -54,7 +54,7 @@ export default function OverclockPanel({ run, overclockMult, thresholds, onBuy,
×{o.owned}
- {fmt(rate)} F/s · {def.heatPerSec.toFixed(2)} heat/s each + +{fmt(rate)} to Racks · {def.heatPerSec.toFixed(2)} heat/s each {msMult > 1 && · ×{msMult} milestone}
diff --git a/client/src/game/components/RacksPanel.jsx b/client/src/game/components/RacksPanel.jsx index 0618888..af82d94 100644 --- a/client/src/game/components/RacksPanel.jsx +++ b/client/src/game/components/RacksPanel.jsx @@ -1,8 +1,9 @@ import { costAt, costForN, maxAffordable, milestoneMult, nextMilestone, tierRate, fmt } from '../helpers.js'; -import { cardBg, cardBorder, inset, textMain, textDim, amber, teal, buyBtnStyle } from '../theme.js'; +import { cardBg, cardBorder, inset, textMain, textDim, amber, teal, danger, buyBtnStyle } from '../theme.js'; import { TIER_DEFS } from '../data/tiers.js'; +import { laneOutageFor } from '@shared/outages.js'; -export default function RacksPanel({ run, unlockedUpTo, racksMult, thresholds, eff, onBuy, onCollect, onHire }) { +export default function RacksPanel({ run, unlockedUpTo, racksMult, thresholds, eff, outages, now, onBuy, onCollect, onHire }) { const LockedIcon = unlockedUpTo + 1 < TIER_DEFS.length ? TIER_DEFS[unlockedUpTo + 1].Icon : null; return (
@@ -17,6 +18,10 @@ export default function RacksPanel({ run, unlockedUpTo, racksMult, thresholds, e const msMult = milestoneMult(ts.owned, thresholds); const nextMs = nextMilestone(ts.owned, thresholds); const managerCost = def.managerCost * eff.automationDiscount; + // v1.11: a tier silently producing nothing reads as a bug, so say + // why. laneOutageFor returns the most severe cover, which is the one + // worth naming. + const down = laneOutageFor(outages, 'tiers', i, now); return (
@@ -32,6 +37,14 @@ export default function RacksPanel({ run, unlockedUpTo, racksMult, thresholds, e {fmt(rate)} F/s{ts.manager ? ' · automated' : ''} {msMult > 1 && · ×{msMult} milestone}
+ {down && ( +
+ {down.factor === 0 ? 'Offline' : `At ${Math.round(down.factor * 100)}%`} + {down.kind === 'overheat' + ? ' - overheated' + : down.kind === 'driveFailure' ? ' - drive failure' : ' - incident'} +
+ )}
diff --git a/client/src/game/components/ResiliencePanel.jsx b/client/src/game/components/ResiliencePanel.jsx new file mode 100644 index 0000000..d888947 --- /dev/null +++ b/client/src/game/components/ResiliencePanel.jsx @@ -0,0 +1,120 @@ +import { ShieldAlert, ShieldCheck, Zap } from 'lucide-react'; +import { cardBg, cardBorder, inset, textMain, textDim, teal, danger, amber, buyBtnStyle } from '../theme.js'; +import { fmt } from '../helpers.js'; +import { + SUPPLY_IDS, supplyPrice, cureCost, hazardRatePerHour, activeAt, +} from '@shared/outages.js'; + +const SUPPLY_META = { + antivirus: { + name: 'Antivirus licence', + counters: 'Ransomware', + blurb: 'Absorbs one ransomware incident - even while you are away.', + }, + backupIsp: { + name: 'Backup ISP line', + counters: 'ISP outage', + blurb: 'Keeps the Grid up through one connectivity failure.', + }, + spareDrives: { + name: 'Spare drive', + counters: 'Drive failure', + blurb: 'Swaps in for one failed rack tier before it costs you anything.', + }, +}; + +export default function ResiliencePanel({ + state, config, totalOutputPerSec, now, onBuySupply, onResolveOutage, +}) { + const rate = hazardRatePerHour(config); + // The RATE, never the next time (spec decision 3) - showing nextHazardAt + // would turn the prepaid economy into buying one licence twenty minutes + // before it fires. + const perHours = rate > 0 ? Math.round(1 / rate) : 0; + const live = activeAt(state.server.outages, now); + const curable = live.filter((o) => o.source === 'hazard' && o.endAt > now); + + return ( +
+
+
+ Standing risk +
+
+ {rate > 0 + ? `Roughly one incident every ${perHours}h. You are never told when - stock up instead.` + : 'No incidents are currently possible.'} +
+
+ + {curable.length > 0 && ( +
+
+ Running incidents +
+ {curable.map((o) => { + const cost = cureCost(o, config, totalOutputPerSec, now); + const affordable = state.run.credits >= cost; + return ( + + ); + })} +
+ Always dearer than having stocked the supply. Preparation is the cheap path. +
+
+ )} + +
+ {SUPPLY_IDS.map((id) => { + const meta = SUPPLY_META[id]; + const stock = (state.meta.supplies && state.meta.supplies[id]) || 0; + const cost = supplyPrice(id, config, totalOutputPerSec); + const affordable = state.run.credits >= cost; + return ( +
+
+
+ 0 ? teal : textDim} /> +
+
+
+
{meta.name}
+
0 ? teal : textDim }}>×{stock}
+
+
Counters {meta.counters}. {meta.blurb}
+
+
+ +
+ ); + })} +
+ +
+ Supplies are spent automatically the moment a matching incident starts - including while you are offline, which is the only defence that can reach one. They survive a Migrate, so spend down before you prestige rather than watching the balance evaporate. Cold Storage is never affected by any of this. +
+
+ ); +} diff --git a/client/src/game/components/profile/AdminBalancing.jsx b/client/src/game/components/profile/AdminBalancing.jsx index 5fc68ae..b875ea2 100644 --- a/client/src/game/components/profile/AdminBalancing.jsx +++ b/client/src/game/components/profile/AdminBalancing.jsx @@ -35,6 +35,8 @@ const GROUP_LABELS = { // v1.3 added 11 batchQueue.* tunables; without this they rendered under the // raw key. Keep in sync with AdminEvents.jsx's copy of this map. batchQueue: 'Cold Storage (batch queue)', + // v1.11. Keep in sync with AdminEvents.jsx's copy of this map. + risk: 'Risk & Reliability', }; function buildGroups() { @@ -51,20 +53,30 @@ const GROUPS = buildGroups(); function rawFromData(data) { const out = {}; - for (const t of TUNABLES) out[t.path] = String(getAtPath(data, t.path)); + for (const t of TUNABLES) { + const v = getAtPath(data, t.path); + // v1.11: a boolean row holds a real boolean in `raw`, not a string - the + // checkbox binds to it directly. + out[t.path] = t.type === 'boolean' ? v === true : String(v); + } return out; } -// Parses/validates one field's current raw (string) input against its -// TUNABLES range, and reports whether it differs from the last-known -// server value for that path. +// Parses/validates one field's current raw input against its TUNABLES row, +// and reports whether it differs from the last-known server value for that +// path. A boolean row is always valid - a checkbox cannot hold a malformed +// value - so only `dirty` is meaningful for it. function fieldStatus(raw, serverValue, tunable) { + if (tunable.type === 'boolean') { + const value = raw === true; + return { value, valid: true, dirty: value !== (serverValue === true) }; + } const num = raw === '' ? NaN : Number(raw); const valid = raw !== '' && !Number.isNaN(num) && num >= tunable.min && num <= tunable.max && (!tunable.integer || Number.isInteger(num)); const dirty = valid ? num !== serverValue : String(raw) !== String(serverValue); - return { num, valid, dirty }; + return { value: num, valid, dirty }; } function fmtDate(ms) { @@ -135,7 +147,7 @@ export default function AdminBalancing({ onConfigSaved }) { setGeneralErrors([]); setSaveNote(null); const clone = structuredClone(serverConfig.data); - for (const t of TUNABLES) setAtPath(clone, t.path, statuses[t.path].num); + for (const t of TUNABLES) setAtPath(clone, t.path, statuses[t.path].value); const res = await putAdminConfig(clone); setSaving(false); if (res && typeof res.version === 'number') { @@ -213,22 +225,35 @@ export default function AdminBalancing({ onConfigSaved }) { - handleChange(t.path, e.target.value)} - step={t.integer ? 1 : 'any'} - className="w-24 rounded-md px-2 py-1 text-xs font-mono text-right" - style={{ - background: '#0E141B', - border: `1px solid ${err ? danger : (st.dirty ? amber : cardBorder)}`, - color: st.valid ? textMain : danger, - }} - /> + {t.type === 'boolean' ? ( + handleChange(t.path, e.target.checked)} + className="w-4 h-4" + style={{ accentColor: amber }} + /> + ) : ( + handleChange(t.path, e.target.value)} + step={t.integer ? 1 : 'any'} + className="w-24 rounded-md px-2 py-1 text-xs font-mono text-right" + style={{ + background: '#0E141B', + border: `1px solid ${err ? danger : (st.dirty ? amber : cardBorder)}`, + color: st.valid ? textMain : danger, + }} + /> + )}
- range [{t.min}, {t.max}]{t.integer ? ', integer' : ''} · default {defaultVal} + {t.type === 'boolean' + ? `default ${String(defaultVal)}` + : `range [${t.min}, ${t.max}]${t.integer ? ', integer' : ''} · default ${defaultVal}`}
{err &&
{err}
} diff --git a/client/src/game/components/profile/AdminEvents.jsx b/client/src/game/components/profile/AdminEvents.jsx index a3dd17e..eb4f95c 100644 --- a/client/src/game/components/profile/AdminEvents.jsx +++ b/client/src/game/components/profile/AdminEvents.jsx @@ -56,11 +56,18 @@ const GROUP_LABELS = { anomaly: 'Anomaly', upgrades: 'Upgrade max levels', batchQueue: 'Cold Storage (batch queue)', + // v1.11. Keep in sync with AdminBalancing.jsx's copy of this map. + risk: 'Risk & Reliability', }; const TUNABLE_GROUPS = (() => { const order = []; const byKey = new Map(); for (const t of TUNABLES) { + // v1.11: boolean tunables are admin-only, never event-overlayable + // (validateModifiers rejects them), so they must not be offerable in the + // modifier path picker - and ModifierRow's min/max validation would read + // undefined on them anyway. + if (t.type === 'boolean') continue; const key = groupKeyFor(t.path); if (!byKey.has(key)) { byKey.set(key, []); order.push(key); } byKey.get(key).push(t); diff --git a/client/src/game/data/tabs.js b/client/src/game/data/tabs.js index 9e31395..4b9246a 100644 --- a/client/src/game/data/tabs.js +++ b/client/src/game/data/tabs.js @@ -1,4 +1,4 @@ -import { Layers, Network, Flame, ShoppingBag, Sparkles, ListChecks, Gamepad2, Archive, Trophy, Users } from 'lucide-react'; +import { Layers, Network, Flame, ShoppingBag, Sparkles, ListChecks, Gamepad2, Archive, Trophy, Users, ShieldAlert } from 'lucide-react'; export const TABS = [ { id: 'racks', label: 'Racks', Icon: Layers }, @@ -14,6 +14,10 @@ export const TABS = [ // daily contracts board and the streak both work from level 0, so there's // no progression gate to render it disabled behind (see TabBar.jsx). { id: 'social', label: 'Social', Icon: Users }, + // Risk & Reliability (v1.11): supplies, the standing risk rate, and any + // running incident. Never locked - a fresh save can be hit by a hazard, so + // it must always be able to stock against one. + { id: 'resilience', label: 'Resilience', Icon: ShieldAlert }, // Live Events (v1.4): unlike every other tab above (which is locked-but- // always-rendered until progression clears it, see TabBar.jsx), this one // is entirely absent from the bar outside its window - RackStack.jsx diff --git a/client/src/game/data/tours/onboarding.js b/client/src/game/data/tours/onboarding.js index eb4fe8c..6461c4c 100644 --- a/client/src/game/data/tours/onboarding.js +++ b/client/src/game/data/tours/onboarding.js @@ -1,7 +1,7 @@ import { welcomeSteps, racksSteps, gridSteps, overclockSteps, upgradesSteps, goalsSteps, gamesSteps, coldStorageSteps, socialSteps, singularitySteps, migrateSteps, - eventSteps, wrapUpSteps, + eventSteps, wrapUpSteps, resilienceSteps, } from './steps.js'; import { ONBOARDING_TOUR_ID } from '../../../../../shared/tours.js'; @@ -23,6 +23,10 @@ export const onboardingTour = { ...goalsSteps, ...gamesSteps, ...coldStorageSteps, + // v1.11: appended per the maintenance obligation above. No separate + // Resilience tour is registered in CLIENT_TOURS - these steps exist only + // here, so onboarding remains a strict superset. + ...resilienceSteps, ...socialSteps, ...singularitySteps, ...migrateSteps, diff --git a/client/src/game/data/tours/steps.js b/client/src/game/data/tours/steps.js index 5f3d0a6..7da3806 100644 --- a/client/src/game/data/tours/steps.js +++ b/client/src/game/data/tours/steps.js @@ -176,3 +176,20 @@ export const wrapUpSteps = [ body: 'Locked tabs open up as you grow. You can replay this tour any time from Profile -> Settings -> Tutorials.', }, ]; + +export const resilienceSteps = [ + { + id: 'resilience-risk', + tab: 'resilience', + anchor: 'resilience-risk', + title: 'Things go wrong', + body: 'Every few hours something breaks - ransomware, a dead link, a failed drive. It only ever slows you down: you never lose racks, FLOPS, tapes or upgrades. You are told the rate, never the schedule.', + }, + { + id: 'resilience-supplies', + tab: 'resilience', + anchor: 'resilience-supplies', + title: 'Stock up before it happens', + body: 'Each supply absorbs one matching incident automatically - even while you are offline, which is the only time it can save you. Fixing something already broken always costs more than having prepared. Cold Storage is never affected.', + }, +]; diff --git a/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md new file mode 100644 index 0000000..5ea1d11 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability-notes.md @@ -0,0 +1,159 @@ +# v1.11 Risk & Reliability — execution notes + +Companion to `2026-08-08-v1.11-risk-reliability.md`. Same role as +`2026-08-08-v1.10-qol-notes.md` had for v1.10: a running log of what is done, +what surprised us, and exactly where to pick up. + +**Branch:** `v1.11-risk-reliability` (worktree +`.claude/worktrees/v1.9-supertokens-client`) + +## Status + +| Task | State | Commit | +|---|---|---| +| 1. Boolean tunables + `risk` config block | **done** | `6bf881b` | +| 2. `shared/outages.js` + the integral | **done** | `1ad8c9e` | +| 3. Evaluation wiring, no sources | **done** | `6458e76` | +| 4. Hazards: derivation, scheduling, firing | **done** | `90dfecf` | +| 5. Stockpiles and absorption | **done** | `a3b20fb` | +| 6. The reactive cure | **done** | `2f0d991` | +| 7. Grid maintenance | **done** | `b6a5e08` | +| 8. The Overclock rework | **done** | `ec427a8` | +| 9. Master kill switch + decision-1 property | **done** | `3291e69` | +| 10. Client surfaces | **done** | `2054448` | +| 11. Smoke, changelog, version, release | **done** (code) | `d02810b` | + +**All 11 tasks implemented.** Remaining: PR, whole-branch review, merge, then +tag `main` (never the branch) as `v1.11.0` and push the tag — the tag push is +what triggers the GHCR publish. + +### Verification at completion + +| Gate | Result | +|---|---| +| `TEST_BACKEND=sqlite vitest run` | 793 passed, 29 skipped | +| Postgres (`npm run test:all`) | 819 passed, 3 skipped | +| `npm run smoke` (all suites) | 66 PASS, 0 FAIL | +| `cd client && npm run build` | clean | + +## How to resume + +1. `cd` to the worktree above; confirm `git branch --show-current` is + `v1.11-risk-reliability`. +2. Read this file's Status table for the first `not started` task. +3. Open the plan at that task and follow its steps verbatim. +4. Update this file after **every** task — the table, plus a Log entry for + anything that differed from the plan. + +Test commands (from the worktree root): + +```bash +TEST_BACKEND=sqlite npx vitest run # fast inner loop +npm run test:all # both backends, needs podman +node tests/e2e/smoke-v111.mjs # once Task 11 exists +``` + +Postgres needs a container runtime; this machine has podman, not docker: + +```bash +systemctl --user start podman.socket +export DOCKER_HOST=unix:///run/user/1000/podman/podman.sock +export TESTCONTAINERS_RYUK_DISABLED=true +``` + +## Log + +_(newest last)_ + +- **Plan committed** `c895dc9`. Before execution began, the four load-bearing + algorithms were verified numerically outside the repo (21/21): the + closed-form integral vs a 2M-sample brute force, the derivation hash's + determinism and distribution, the Overclock conversion's output-neutrality + at gain 1, and the cure price staying above the supply price across the + whole space. + +- **Tasks 1-3 executed clean, no deviations from the plan.** Suite went + 726 → 749 passing, with every pre-existing test untouched. Worth knowing: + + - Task 1's plan text was right that `tests/configSchema.test.js` asserts + `toBeTypeOf('number')` for *every* TUNABLES row; that test needed the + boolean branch or the whole task fails on its own scaffolding. + - `shared/events.js`'s `validateModifiers` was the non-obvious second + validator needing the boolean guard. Missing it would not have failed any + test at Task 1 — it would have surfaced much later as an event author + getting "risk.enabled: missing or not a boolean" from a path they were + never meant to reach. + - Task 3's `const outages = s.server.outages` binding sits *above* the + online/offline split so both branches share it. Task 9 moves it below the + kill-switch block — that reassignment is why the plan calls it out. + +- **Tasks 4-7 done.** Suite 749 → 782 passing. Two deviations and one real + bug, all deliberate: + + - **Deviation (Task 4):** `absorbWithSupply` was written in full in Task 4 + rather than landing as a stub and being replaced in Task 5. It is inert + until `meta.supplies` exists (the `!bag` guard), so Task 4's tests are + unaffected and the firing loop never needed rewriting. + - **Bug found by a test (Task 7):** `activateDueMaintenance` had an + `if (gm.endAt <= now) return null` guard meaning "this window came and + went, skip it". Wrong: a window covering the *whole* evaluation gap ends + exactly at `now`, so the guard paid the player in full for time they were + down. The guard cannot see `lastEvaluatedAt` so it cannot make that + judgement at all — removed, and the integral (which already ignores + anything ending before the window starts) decides instead. + - **Harness note:** shell heredocs and `>` redirects are refused in this + worktree ("too complex to verify it stays inside the worktree"). Use the + Write tool for new files and `printf '%s\n' ... >> file` for appends — + and beware that a bare `printf ... >>` append lands *after* a closing + `});`, so appending an `it()` to an existing `describe` needs a follow-up + Edit. Backticks in `git commit -m` get command-substituted; write the + message to a file and use `git commit -F` instead. + +- **Tasks 8-10 done.** Suite 782 → 793 passing, client builds clean. + + - **The Overclock bet paid off.** `tests/contracts.test.js`, + `achievements`, `streak` and `reducer.economy` needed **zero edits** and + pass — which was the whole point of defining the conversion as a ratio of + the Racks lane. Only `tests/goals.test.js` changed, and only the one test + whose premise the rework deliberately removes (it asserted overclock nodes + with *no racks* produce output; they no longer do, there is nothing to + amplify). If a future change makes those other four suites move, the + conversion has drifted from output-neutral — check + `risk.overclockBoostGain` and `overclockBoost()` before touching a test. + - **Second bug caught while writing Task 8.** `legacyFreeze` was initially + gated on `!riskOn(config, 'overheatShutdownEnabled')`. But + `overheatOutage` falls back to setting `heatCooldownUntil` when the + shutdown is *enabled* and there is simply no owned rack to down — so the + cooldown was set and then never honoured, and heat re-crossed the cap on + every evaluation. Now it is simply "is a cooldown active", which also + makes it identical to `goalCtx`'s condition, so the displayed rate and the + produced rate cannot disagree. + - `tests/tours.test.js` hardcodes onboarding step counts (17/11 → 19/13). + Both moved by exactly 2, confirming the new steps are ungated. + - `OUTAGE_NOTICE_LABEL` lives at module scope in `RackStack.jsx`, not + component scope: `handleReconcile` is defined above where a + component-scope const would sit. + +- **Task 11 done.** Building the smoke suite surfaced four API-shape mistakes + worth recording, because the next e2e author will hit the same ones: + + - **`GET /api/state` returns `run`/`meta`/`server` FLATTENED at the top + level.** `POST /api/actions` returns them wrapped in `state`. The two are + not interchangeable; `smoke-v111.mjs` has a `stateOf()` helper for it. + - **`PUT /api/admin/config` takes the document wrapped as `{ data }`.** + Sending the bare document gets `["not an object"]`, which reads like a + validator bug and is not. + - **`setToursCompleted(userId, ids)` takes an ARRAY**, and seeding it is + mandatory for any browser check: the onboarding overlay is a full-screen + `fixed inset-0` div that swallows every click, so Playwright times out + with a misleading "element intercepts pointer events". + - A credits-were-charged assertion must baseline **after** the offline gap + is credited. An hour of accrual dwarfs a supply price, so comparing + against the seeded value passes even when nothing is charged. + + Two pre-existing smoke checks asserted behaviour v1.11 deliberately + changes and were updated, not worked around: `smoke-v12`'s overheat lockout + (now asserts the overheat outage and the strip naming it — assert on the + **strip**, not the Racks panel, since the downed tier can be past + `unlockedUpTo` and therefore not rendered), and `smoke-v16`'s hardcoded + onboarding step count. diff --git a/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability.md b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability.md new file mode 100644 index 0000000..e00971f --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-v1.11-risk-reliability.md @@ -0,0 +1,3495 @@ +# v1.11 Risk & Reliability — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the game a second axis — output you can lose and must defend — +by unifying hazards, scheduled Grid maintenance and the reworked Overclock +overheat penalty into one admin-toggleable outage model. + +**Architecture:** Every effect in this release is the same object: an *outage* +(`{ id, kind, scope, factor, startAt, endAt, source }`) living in +`state.server.outages`. A new pure module `shared/outages.js` owns the model, +the closed-form piecewise-constant integral that applies outages to a +production window, deterministic hazard derivation, and the schedulers. +`evaluate()` calls into that module rather than growing the logic itself. +Overclock stops producing FLOPS directly and instead multiplies the Racks lane, +which makes overheating a rack-tier shutdown rather than a lane freeze. + +**Tech Stack:** Node 20, Express, React 18 + Vite, vitest, better-sqlite3 and +node-postgres (both backends must pass), Playwright for smoke suites. + +**Spec:** `docs/superpowers/specs/2026-08-08-v1.11-risk-reliability-design.md` + +**Branch:** `v1.11-risk-reliability` + +## Global Constraints + +- **`shared/` must not import from `client/`** and must stay free of runtime + dependencies. `shared/outages.js` may import from `shared/gameData.js` only. +- **The server is authoritative.** Anything the client computes is a request, + never a fact. +- **Decision 1 is absolute: no hazard may ever reduce a stored value.** Hazards + multiply *production*. Nothing in this release may subtract from + `run.credits`, `meta.wafers`, `meta.coldStorage.tapes`, or any `owned`. + The one deliberate exception is `meta.supplies`, which is a consumable the + player bought for exactly this purpose — Task 5 defines it, and the property + test in Task 9 excludes it by name. +- **Decision 3: hazards are never telegraphed.** `server.nextHazardAt` must + never reach the client's UI. Only the *rate*, derived from config, is shown. + Grid maintenance is the opposite — it is scheduled ahead and visible. +- **Decision 6: Cold Storage is a safe harbour.** No outage scope may ever + cover it. Block accrual, tapes, jobs and tape upgrades are untouched. +- **Decision 7: everything is admin-toggleable**, and `risk.enabled` is a true + kill switch that clears live outages, not a pause. +- **The offline cap samples the whole absence** (decision 5). The factor is + computed over `[lastEvaluatedAt, now]` and applied to the *capped* payout. + This is deliberate and must be commented at the call site. +- **Both backends must pass:** `npm run test:all` (SQLite and Postgres). + Postgres needs a container runtime; this machine has podman, not docker: + ```bash + systemctl --user start podman.socket + export DOCKER_HOST=unix:///run/user/1000/podman/podman.sock + export TESTCONTAINERS_RYUK_DISABLED=true + ``` +- **No database schema migration.** `run`/`meta`/`server` are JSON inside the + save; `migrateSave()` defaults and shape-pins every new field. +- **Docs change in the task that changes the behaviour**, not afterwards. +- **Commit after every task.** + +### Verified signatures (checked against the code on this branch) + +The v1.10 plan's snippets were wrong four times. These were re-verified before +this plan was written — use them as given: + +| Fact | Verified | +|---|---| +| `requireAuth` populates **`req.user.sub`**, never `req.user.id` | `server/auth.js:191`, used throughout `server/routes/api.js` | +| The racks lane is **`tiers`**, not `racks` | `LANE_DEFS` in `shared/reducer.js:13` | +| `scheduleAnomaly(server, config, now, rng = Math.random)` | `shared/reducer.js:392` | +| `applyAction(state, action, config, now, rng = Math.random)`; handlers are `(s, action, config, now, rng)` | `shared/reducer.js:654` | +| `evaluate(state, config, lastEvaluatedAt, now)` returns `{ state, gained }` | `shared/state.js:182` | +| `computeMults(meta, config, boostMult = 1)` returns `{ eff, thresholds, racksMult, gridMult, overclockMult }` | `shared/gameRules.js:85` | +| `tierRate(owned, baseProd, mult, thresholds)` | `shared/gameRules.js:26` | +| `goalCtx(state, config, now)` returns `{ run, meta, totalOutputPerSec, unlockedUpTo }` | `shared/goals.js:42` | +| `validateConfig` currently requires **every** tunable to be a number | `shared/configSchema.js:203` | +| `validateModifiers` also requires modifier values to be numbers | `shared/events.js:76` | +| `err(...)` codes in use: `invalid_target`, `insufficient_credits`, `not_met`, `cooldown_active`, `max_level`, `no_milestone`, `already_automated` | `shared/reducer.js` | + +### One stated deviation from the spec + +Spec §4 sketches `effectiveFactor(outages, scope, from, to)`. This plan uses +**`effectiveFactor(outages, lane, index, from, to)`** instead. Rationale: every +call site is a loop over a lane's indices, and the sketched signature would +require allocating a throwaway `{ lane, index }` object per tier per +evaluation. The semantics are identical. Nothing else in the spec is changed — +all seven decisions in §2 are implemented as written. + +--- + +### Task 1: Boolean tunables, and the `risk` config block + +Front-loads the only piece of shared machinery that does not exist yet. +`validateConfig` accepts numbers only, so a toggle has nowhere to live until +this lands. Encoding toggles as 0/1 numbers is explicitly rejected by the spec +(§8): a 0/1 "boolean" is exactly the kind of thing that later gets set to 2. + +**Files:** +- Modify: `shared/configSchema.js` (`DEFAULT_CONFIG`, `TUNABLES`, `validateConfig` at :194, `upgradeConfig` at :210) +- Modify: `shared/events.js` (`validateModifiers` at :66) +- Modify: `client/src/game/components/profile/AdminBalancing.jsx` (`rawFromData`, `fieldStatus`, `GROUP_LABELS`, the input render) +- Modify: `client/src/game/components/profile/AdminEvents.jsx` (`GROUP_LABELS`, the modifier path picker) +- Test: `tests/configSchema.test.js`, `tests/events.test.js` + +**Interfaces:** +- Produces: `TUNABLES` entries may now carry `type: 'boolean'`. Entries with no + `type` are numeric, exactly as today — no existing row changes. +- Produces: `config.risk.*` — the full block every later task reads. Boolean + keys: `enabled`, `hazardsEnabled`, `maintenanceEnabled`, + `overheatShutdownEnabled`, `ransomwareEnabled`, `ispOutageEnabled`, + `driveFailureEnabled`. Numeric keys as listed in Step 3. +- Produces: `AdminBalancing`'s `fieldStatus()` now returns `{ value, valid, dirty }` + (renamed from `num`, because it now carries a boolean for boolean rows). + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/configSchema.test.js`: + +```js +describe('boolean tunables (v1.11)', () => { + it('validates booleans on boolean paths and rejects numbers there', () => { + expect(validateConfig(DEFAULT_CONFIG).ok).toBe(true); + + const bad = structuredClone(DEFAULT_CONFIG); + bad.risk.enabled = 1; + const res = validateConfig(bad); + expect(res.ok).toBe(false); + expect(res.errors.some((e) => e.startsWith('risk.enabled:'))).toBe(true); + }); + + it('rejects a boolean on a numeric path', () => { + const bad = structuredClone(DEFAULT_CONFIG); + bad.heat.capacity = true; + expect(validateConfig(bad).ok).toBe(false); + }); + + it('upgradeConfig copies booleans through and fills missing ones', () => { + const old = { schemaVersion: 1, risk: { enabled: false } }; + const up = upgradeConfig(old); + expect(up.risk.enabled).toBe(false); // preserved + expect(up.risk.hazardsEnabled).toBe(true); // filled from defaults + expect(validateConfig(up).ok).toBe(true); + }); + + it('has the v1.11 risk defaults and every risk leaf is a TUNABLES row', () => { + expect(DEFAULT_CONFIG.risk.enabled).toBe(true); + expect(DEFAULT_CONFIG.risk.ransomwareFactor).toBe(0.5); + expect(DEFAULT_CONFIG.risk.overclockBoostGain).toBe(1); + const paths = new Set(TUNABLES.map((t) => t.path)); + for (const key of Object.keys(DEFAULT_CONFIG.risk)) { + expect(paths.has(`risk.${key}`), `risk.${key}`).toBe(true); + } + }); +}); +``` + +Replace the existing `'every TUNABLES path resolves in DEFAULT_CONFIG and is in range'` +test body (it asserts `toBeTypeOf('number')` for every row, which boolean rows +would fail) with: + +```js + it('every TUNABLES path resolves in DEFAULT_CONFIG and is in range', () => { + for (const t of TUNABLES) { + const v = getAtPath(DEFAULT_CONFIG, t.path); + if (t.type === 'boolean') { + expect(v, t.path).toBeTypeOf('boolean'); + continue; + } + expect(v, t.path).toBeTypeOf('number'); + expect(v).toBeGreaterThanOrEqual(t.min); + expect(v).toBeLessThanOrEqual(t.max); + } + }); +``` + +Add to `tests/events.test.js`: + +```js +describe('event modifiers vs boolean tunables (v1.11)', () => { + it('rejects a modifier targeting a boolean tunable', () => { + const res = validateModifiers([{ path: 'risk.enabled', value: 0 }]); + expect(res.ok).toBe(false); + expect(res.errors.some((e) => e.includes('risk.enabled'))).toBe(true); + }); + + it('still accepts a numeric risk modifier', () => { + expect(validateModifiers([{ path: 'risk.ransomwareFactor', value: 0.25 }]).ok).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the tests and verify they fail** + +Run: `TEST_BACKEND=sqlite npx vitest run tests/configSchema.test.js tests/events.test.js` +Expected: FAIL — `DEFAULT_CONFIG.risk` is undefined. + +- [ ] **Step 3: Add the `risk` block to `DEFAULT_CONFIG`** + +In `shared/configSchema.js`, add after the `social` block (keep the trailing +comma on `social`): + +```js + // v1.11 Risk & Reliability. Every effect in the release is an "outage" + // (shared/outages.js); these are its dials. The seven booleans AND together + // with `enabled` first, so the owner can kill the whole system in one click + // without auditing the rest - see shared/outages.js's riskOn(). + risk: { + enabled: true, + hazardsEnabled: true, + maintenanceEnabled: true, + overheatShutdownEnabled: true, + ransomwareEnabled: true, + ispOutageEnabled: true, + driveFailureEnabled: true, + + // ~1 incident per 6h on average. The player is shown this RATE, derived + // from these two numbers - never server.nextHazardAt (spec decision 3). + hazardMinDelayMs: 14400000, // 4h + hazardMaxDelayMs: 28800000, // 8h + + ransomwareFactor: 0.5, + ransomwareDurationMs: 1800000, // 30m, all lanes at half + ispOutageFactor: 0, + ispOutageDurationMs: 900000, // 15m, Grid dark + driveFailureFactor: 0, + driveFailureDurationMs: 1200000, // 20m, one rack tier dark + + // Supply prices are expressed in SECONDS OF CURRENT OUTPUT, the same + // idiom as social.contractFlopsSeconds and batchQueue.blockFlopsSeconds, + // so a sink priced today still bites at 1e12 FLOPS/s. supplyPriceMin is + // the floor for a fresh save whose output is ~0. + antivirusPriceSeconds: 900, + backupIspPriceSeconds: 600, + spareDrivesPriceSeconds: 750, + supplyPriceMin: 500, + + // The reactive cure is priced strictly worse than preparing (decision 2): + // cost = supplyPrice * cureMultiplier * (1 + remaining/total), so its + // FLOOR is cureMultiplier times the supply it should have been. + cureMultiplier: 2.5, + + maintenanceMinDelayMs: 43200000, // 12h + maintenanceMaxDelayMs: 86400000, // 24h + maintenanceDurationMs: 1800000, // 30m + + overheatOutageMs: 600000, // 10m of one rack tier offline + + // Overclock's conversion factor (spec §7). At 1 the lane contributes + // exactly the output it used to produce directly, so a mid-game save's + // total output is unchanged on the deploy - see Task 8. + overclockBoostGain: 1, + }, +``` + +Then append the `TUNABLES` rows at the end of the array: + +```js + { path: 'risk.enabled', label: 'Risk system enabled (master)', type: 'boolean' }, + { path: 'risk.hazardsEnabled', label: 'Hazards enabled', type: 'boolean' }, + { path: 'risk.maintenanceEnabled', label: 'Grid maintenance enabled', type: 'boolean' }, + { path: 'risk.overheatShutdownEnabled', label: 'Overheat knocks a rack offline', type: 'boolean' }, + { path: 'risk.ransomwareEnabled', label: 'Hazard enabled: Ransomware', type: 'boolean' }, + { path: 'risk.ispOutageEnabled', label: 'Hazard enabled: ISP outage', type: 'boolean' }, + { path: 'risk.driveFailureEnabled', label: 'Hazard enabled: Drive failure', type: 'boolean' }, + + { path: 'risk.hazardMinDelayMs', label: 'Hazard min delay (ms)', min: 60000, max: 604800000, integer: true }, + { path: 'risk.hazardMaxDelayMs', label: 'Hazard max delay (ms)', min: 60000, max: 604800000, integer: true }, + { path: 'risk.ransomwareFactor', label: 'Ransomware output factor', min: 0, max: 1, integer: false }, + { path: 'risk.ransomwareDurationMs', label: 'Ransomware duration (ms)', min: 1000, max: 86400000, integer: true }, + { path: 'risk.ispOutageFactor', label: 'ISP outage output factor', min: 0, max: 1, integer: false }, + { path: 'risk.ispOutageDurationMs', label: 'ISP outage duration (ms)', min: 1000, max: 86400000, integer: true }, + { path: 'risk.driveFailureFactor', label: 'Drive failure output factor', min: 0, max: 1, integer: false }, + { path: 'risk.driveFailureDurationMs', label: 'Drive failure duration (ms)', min: 1000, max: 86400000, integer: true }, + { path: 'risk.antivirusPriceSeconds', label: 'Antivirus price (seconds of output)', min: 0, max: 86400, integer: true }, + { path: 'risk.backupIspPriceSeconds', label: 'Backup ISP price (seconds of output)', min: 0, max: 86400, integer: true }, + { path: 'risk.spareDrivesPriceSeconds', label: 'Spare drive price (seconds of output)', min: 0, max: 86400, integer: true }, + { path: 'risk.supplyPriceMin', label: 'Supply price floor (FLOPS)', min: 0, max: 1e12, integer: false }, + { path: 'risk.cureMultiplier', label: 'Cure price multiplier', min: 1, max: 100, integer: false }, + { path: 'risk.maintenanceMinDelayMs', label: 'Maintenance min delay (ms)', min: 60000, max: 604800000, integer: true }, + { path: 'risk.maintenanceMaxDelayMs', label: 'Maintenance max delay (ms)', min: 60000, max: 604800000, integer: true }, + { path: 'risk.maintenanceDurationMs', label: 'Maintenance duration (ms)', min: 1000, max: 86400000, integer: true }, + { path: 'risk.overheatOutageMs', label: 'Overheat rack shutdown (ms)', min: 1000, max: 86400000, integer: true }, + { path: 'risk.overclockBoostGain', label: 'Overclock boost gain', min: 0, max: 100, integer: false }, +``` + +- [ ] **Step 4: Teach `validateConfig` and `upgradeConfig` about booleans** + +Replace the `for (const t of TUNABLES)` loop body inside `validateConfig`: + +```js + for (const t of TUNABLES) { + const v = getAtPath(doc, t.path); + // A boolean tunable accepts ONLY a boolean, and a numeric tunable only a + // number. Both directions are enforced: without the second half, a + // boolean assigned to a numeric path would sail through `typeof v !== + // 'number'`... it wouldn't, but a future `type` value would, and a 0/1 + // "boolean" on a boolean path is exactly what this type exists to stop. + if (t.type === 'boolean') { + if (typeof v !== 'boolean') errors.push(`${t.path}: missing or not a boolean`); + continue; + } + if (typeof v !== 'number' || Number.isNaN(v)) { errors.push(`${t.path}: missing or not a number`); continue; } + if (v < t.min || v > t.max) errors.push(`${t.path}: ${v} outside [${t.min}, ${t.max}]`); + if (t.integer && !Number.isInteger(v)) errors.push(`${t.path}: must be an integer`); + } +``` + +And `upgradeConfig`'s loop body: + +```js + for (const t of TUNABLES) { + const v = getAtPath(doc || {}, t.path); + if (t.type === 'boolean') { + if (typeof v === 'boolean') setAtPath(out, t.path, v); + continue; + } + if (typeof v === 'number' && !Number.isNaN(v)) setAtPath(out, t.path, v); + } +``` + +- [ ] **Step 5: Keep event modifiers numeric-only** + +Live events overlay config through `mergeEventModifiers`, and a boolean path +reached by a numeric modifier would produce a document `validateConfig` then +rejects. Event modifiers stay numeric — an event may turn the risk system *up*, +but may not flip its switches. In `shared/events.js`, inside +`validateModifiers`'s loop, replace the value check: + +```js + const tDef = TUNABLES.find((t) => t.path === path); + if (tDef && tDef.type === 'boolean') { + // v1.11: boolean tunables are admin-only. mergeEventModifiers would + // happily setAtPath a number onto a boolean path, and the merged + // document would then fail validateConfig below with a confusing + // "not a boolean" - reject it here, where the author can read it. + errors.push(`${path}: boolean tunables cannot be set by an event modifier`); + continue; + } + if (typeof value !== 'number' || Number.isNaN(value)) { + errors.push(`${path}: value must be a number`); + } +``` + +- [ ] **Step 6: Render boolean tunables as checkboxes** + +In `client/src/game/components/profile/AdminBalancing.jsx`: + +Add to `GROUP_LABELS`: + +```js + // v1.11. Keep in sync with AdminEvents.jsx's copy of this map. + risk: 'Risk & Reliability', +``` + +Replace `rawFromData` and `fieldStatus`: + +```js +function rawFromData(data) { + const out = {}; + for (const t of TUNABLES) { + const v = getAtPath(data, t.path); + out[t.path] = t.type === 'boolean' ? v === true : String(v); + } + return out; +} + +// Parses/validates one field's current raw input against its TUNABLES row, +// and reports whether it differs from the last-known server value. A boolean +// row is always valid - a checkbox cannot hold a malformed value - so only +// `dirty` is meaningful for it. +function fieldStatus(raw, serverValue, tunable) { + if (tunable.type === 'boolean') { + const value = raw === true; + return { value, valid: true, dirty: value !== (serverValue === true) }; + } + const num = raw === '' ? NaN : Number(raw); + const valid = raw !== '' && !Number.isNaN(num) + && num >= tunable.min && num <= tunable.max + && (!tunable.integer || Number.isInteger(num)); + const dirty = valid ? num !== serverValue : String(raw) !== String(serverValue); + return { value: num, valid, dirty }; +} +``` + +In `handleSave`, change the write-back to use the renamed field: + +```js + for (const t of TUNABLES) setAtPath(clone, t.path, statuses[t.path].value); +``` + +In the render, replace the `` element and the range +hint beneath it with a branch on `t.type`: + +```js + {t.type === 'boolean' ? ( + handleChange(t.path, e.target.checked)} + className="w-4 h-4" + style={{ accentColor: amber }} + /> + ) : ( + handleChange(t.path, e.target.value)} + step={t.integer ? 1 : 'any'} + className="w-24 rounded-md px-2 py-1 text-xs font-mono text-right" + style={{ + background: '#0E141B', + border: `1px solid ${err ? danger : (st.dirty ? amber : cardBorder)}`, + color: st.valid ? textMain : danger, + }} + /> + )} +``` + +and the hint line: + +```js +
+ {t.type === 'boolean' + ? `default ${String(defaultVal)}` + : `range [${t.min}, ${t.max}]${t.integer ? ', integer' : ''} · default ${defaultVal}`} +
+``` + +- [ ] **Step 7: Hide boolean rows from the event modifier picker** + +In `client/src/game/components/profile/AdminEvents.jsx`, add the same +`risk: 'Risk & Reliability',` entry to its `GROUP_LABELS`, then make its +grouping skip boolean rows so the `