Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.**
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 43 additions & 1 deletion client/src/RackStack.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }); }
Expand Down Expand Up @@ -1122,6 +1152,7 @@ export default function RackStack({ user }) {
<div className="max-w-2xl mx-auto px-4 pt-3">
<HeaderBar user={user} displayName={displayName} level={state.meta.level} onOpenProfile={() => setProfileOpen(true)} />
<StatsRow run={state.run} meta={state.meta} totalOutputPerSec={ctx.totalOutputPerSec} xpNeeded={xpNeeded} boost={boost} boostMultNow={boostMultNow} />
<OutageStrip outages={state.server.outages} gridMaintenance={state.server.gridMaintenance} now={now} />
{eventLive && activeEvent && (
<EventBanner event={activeEvent} endsAt={eventProgress.endsAt} onOpen={() => setActiveTab('event')} />
)}
Expand All @@ -1138,7 +1169,7 @@ export default function RackStack({ user }) {
</div>

{activeTab === 'racks' && (
<RacksPanel run={state.run} unlockedUpTo={ctx.unlockedUpTo} racksMult={racksMult} thresholds={thresholds} eff={eff} onBuy={buy} onCollect={collectTier} onHire={hireManager} />
<RacksPanel run={state.run} unlockedUpTo={ctx.unlockedUpTo} racksMult={racksMult} thresholds={thresholds} eff={eff} outages={state.server.outages} now={now} onBuy={buy} onCollect={collectTier} onHire={hireManager} />
)}

{activeTab === 'grid' && (
Expand All @@ -1161,6 +1192,17 @@ export default function RackStack({ user }) {
/>
)}

{activeTab === 'resilience' && (
<ResiliencePanel
state={state}
config={config.data}
totalOutputPerSec={ctx.totalOutputPerSec}
now={now}
onBuySupply={buySupply}
onResolveOutage={resolveOutage}
/>
)}

{activeTab === 'upgrades' && <UpgradesPanel meta={state.meta} config={config.data} onBuy={buyUpgrade} />}

{activeTab === 'singularity' && (
Expand Down
78 changes: 78 additions & 0 deletions client/src/game/components/OutageStrip.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mt-2 flex flex-col gap-1" data-testid="outage-strip">
{live.map((o) => (
<div
key={o.id}
className="rounded-md px-2 py-1 text-xs flex items-center gap-1.5"
style={{ background: 'rgba(220,60,60,0.08)', border: `1px solid ${danger}`, color: danger }}
>
<AlertTriangle size={12} />
<span>
{scopeLabel(o.scope)}
{o.factor === 0 ? ' offline' : ` at ${Math.round(o.factor * 100)}%`}
{' · '}{KIND_LABEL[o.kind] || o.kind}
{' · '}{remaining(o.endAt - now)} left
</span>
</div>
))}
{/* Maintenance is telegraphed (spec decision 3) - the one thing in this
release the player gets to see coming and route around. */}
{upcoming && (
<div
className="rounded-md px-2 py-1 text-xs flex items-center gap-1.5"
style={{ background: 'rgba(240,180,60,0.08)', border: `1px solid ${cardBorder}`, color: amber }}
>
<CalendarClock size={12} />
<span style={{ color: textDim }}>
Scheduled maintenance: {scopeLabel({ lane: 'grid', index: upcoming.index })}
{' · in '}{remaining(upcoming.startAt - now)}
</span>
</div>
)}
</div>
);
}
4 changes: 2 additions & 2 deletions client/src/game/components/OverclockPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default function OverclockPanel({ run, overclockMult, thresholds, onBuy,
</button>
</div>
<div className="rounded-lg p-3 text-xs" style={{ background: cardBg, border: `1px solid ${cardBorder}`, color: textDim }}>
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.
</div>
{OVERCLOCK_DEFS.map((def, i) => {
const o = run.overclock[i];
Expand All @@ -54,7 +54,7 @@ export default function OverclockPanel({ run, overclockMult, thresholds, onBuy,
<div className="font-mono text-xs" style={{ color: textDim }}>&times;{o.owned}</div>
</div>
<div className="text-xs font-mono" style={{ color: textDim }}>
{fmt(rate)} F/s &middot; {def.heatPerSec.toFixed(2)} heat/s each
+{fmt(rate)} to Racks &middot; {def.heatPerSec.toFixed(2)} heat/s each
{msMult > 1 && <span style={{ color: teal }}> &middot; &times;{msMult} milestone</span>}
</div>
</div>
Expand Down
17 changes: 15 additions & 2 deletions client/src/game/components/RacksPanel.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="max-w-2xl mx-auto px-4 py-4 flex flex-col gap-3">
Expand All @@ -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 (
<div key={def.id} className="tier-card rounded-xl p-3" style={{ background: cardBg, border: `1px solid ${cardBorder}`, animationDelay: `${i * 40}ms` }}>
<div className="flex items-center gap-3">
Expand All @@ -32,6 +37,14 @@ export default function RacksPanel({ run, unlockedUpTo, racksMult, thresholds, e
{fmt(rate)} F/s{ts.manager ? ' · automated' : ''}
{msMult > 1 && <span style={{ color: teal }}> &middot; &times;{msMult} milestone</span>}
</div>
{down && (
<div className="text-xs mt-0.5" style={{ color: danger }}>
{down.factor === 0 ? 'Offline' : `At ${Math.round(down.factor * 100)}%`}
{down.kind === 'overheat'
? ' - overheated'
: down.kind === 'driveFailure' ? ' - drive failure' : ' - incident'}
</div>
)}
</div>
</div>

Expand Down
Loading
Loading