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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
# Changelog

## v1.5.0

Social & Retention: a daily contracts board, global leaderboards,
achievements, and a daily login streak - all bonuses layered on the existing
economy, none of them gating content.

- Daily contracts: three contracts a day, rotating at midnight UTC and
generated deterministically from the date, so every player gets the same
three *types* while the numeric targets scale to their own progress.
Targets and baselines are snapshotted at rollover, so buying racks mid-day
never moves the goalposts. Claims pay wafers + tapes.
- A player who hasn't unlocked Cold Storage gets base-lane contracts
substituted for any Cold Storage ones, so nobody is ever handed a contract
they cannot possibly complete.
- Global leaderboards: all-time FLOPS, level, Legacy Cores, Singularities,
Tapes, and the most recent event's rungs - aggregated server-side from
canonical saves behind a ~60s cache, with avatars, usernames, and badge
mini-icons. The existing per-user leaderboard opt-out now covers these
boards too, and takes effect immediately.
- Achievements: 19 of them, pure prestige with no payout, unlocked
automatically the moment their condition is met - including from progress
accrued while offline. Shown as a badge case in the Social tab, and the
top three (gold first) ride along on leaderboard rows.
- Daily streak: a 7-day escalating claim (FLOPS, then wafers, then Tapes on
day 7) that stays at the day-7 reward while unbroken; a fully missed UTC
day resets it to day 1. Claimed from a banner in the sticky header.
- New Social tab holding contracts, the leaderboard, and the badge case.
- New `social.*` tunables on the Balancing tab covering contract targets and
rewards, streak rewards, and leaderboard cache/size - all overlayable by a
live event's modifiers, so e.g. a double-streak-rewards weekend needs no
code change.

## v1.4.0

Live Events: config-overlay events with goal ladders, running one at a time
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ ENV DB_PATH=/app/data/rackstack.db
LABEL org.opencontainers.image.source="https://github.com/NeverEndingCode/rackstack-server"
LABEL org.opencontainers.image.description="RackStack self-hosted server"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.version="1.4.0"
LABEL org.opencontainers.image.version="1.5.0"

VOLUME ["/app/data"]
EXPOSE 3000
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ automatically on their own annual windows via an hourly scheduler, and a
coordinator can author, schedule, and run additional ones from the admin
dashboard's Events tab. See [Live Events](#live-events) below for details.

As of v1.5, a Social tab adds a daily contracts board (three contracts a day,
the same three types for everyone, with targets scaled to your own progress),
global leaderboards, and a badge case of achievements that unlock on their own
as you play. A daily login streak sits in the header. See
[Social & Retention](#social--retention) below.

## Architecture

- `shared/` - a package used by both server and client (via a `@shared` Vite
Expand Down Expand Up @@ -202,6 +208,42 @@ tapes/FLOPS as they clear metric targets.
event while one is already active is rejected outright (409) - end the
running one first.

## Social & Retention

All four of these are bonuses - none of them gates content, and none
introduces a new currency.

- **Daily contracts** (Social tab): three a day, rotating at midnight UTC.
Which three is derived deterministically from the date, so everyone on the
server gets the same set and can compare notes; the numeric targets scale
to each player's own output and level. Both the targets and the progress
baselines are snapshotted at rollover, so a contract can't recede as you
grow into it. Completing one pays wafers + tapes. A player who hasn't
unlocked Cold Storage gets base-lane substitutions rather than contracts
they can't act on.
- **Daily streak** (header banner): a 7-day escalating claim - FLOPS on days
1-3, wafers on 4-6, Tapes on day 7 - which then stays at the day-7 reward
for as long as it's unbroken. Missing a full UTC day resets it to day 1.
The day boundary is the same one contracts roll over on, so showing up once
a day satisfies both.
- **Leaderboards** (Social tab): all-time FLOPS, level, Legacy Cores,
Singularities, Tapes, and the latest event's rungs. Aggregated server-side
from canonical saves behind a ~60s cache. The same per-user opt-out the
Event tab already had covers these too - tick "Hide me from all
leaderboards" and you disappear from every board immediately.
- **Achievements** (Social tab badge case): 19 of them, pure prestige - no
payout, ever. They unlock automatically the moment their condition is met,
including from progress that accrued while you were offline, and pop a
toast when they do. Your top three (gold first) show as mini-icons next to
your name on the leaderboards.
- **Tuning**: everything numeric above lives under `social.*` in the
Balancing tab, and - like every other tunable - can be overlaid by a live
event's modifiers. `social.contractFlopsMin` is worth knowing about: it's a
floor on the FLOPS contract target, there because a purely rate-scaled
target is zero for a player at zero output (a fresh save, or the instant
after a Migrate) and would auto-complete for free. Set it to 0 if you'd
rather have that.

## Notes / things worth knowing

- **SQLite, not Postgres**: chosen for zero-config, single-file persistence
Expand Down
6 changes: 6 additions & 0 deletions client/client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "rackstack-client",
"private": true,
"version": "1.4.0",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
134 changes: 131 additions & 3 deletions client/src/RackStack.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@ import { useState, useEffect, useRef, useMemo } from 'react';

import {
TICK_MS, ANOMALY_LABELS, EVENT_REFRESH_THROTTLE_MS, CONFIG_POLL_MS,
LEADERBOARD_REFRESH_THROTTLE_MS,
} from './game/constants.js';
import { cardBorder, textDim, teal, amber, danger, inset } from './game/theme.js';
import { cardBorder, textDim, textMain, teal, amber, danger, inset } from './game/theme.js';
import { TABS } from './game/data/tabs.js';
import {
fetchState, fetchConfig, makeActionQueue, startMinigame, finishMinigame,
fetchEvent, setLeaderboardOptOut,
fetchEvent, setLeaderboardOptOut, fetchLeaderboard,
} from './game/api.js';
import { evaluate } from '@shared/state.js';
import { applyAction, EVENT_CLAIM_GRACE_MS } from '@shared/reducer.js';
import { computeMults, migrateGain, xpForLevel } from '@shared/gameRules.js';
import { goalCtx } from '@shared/goals.js';
import { achievementDef } from '@shared/achievements.js';
import { achievementIcon, TIER_COLOR } from './game/data/achievementIcons.js';

import HeaderBar from './game/components/HeaderBar.jsx';
import StatsRow from './game/components/StatsRow.jsx';
Expand All @@ -28,6 +31,8 @@ import GoalsPanel from './game/components/GoalsPanel.jsx';
import GamesPanel from './game/components/GamesPanel.jsx';
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 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 @@ -109,6 +114,17 @@ export default function RackStack({ user }) {
// Unlike `activeEvent` these carry their own progress snapshot, because the
// canonical meta.eventProgress has already moved on to the newer event.
const [pendingClaims, setPendingClaims] = useState([]);
// Social (v1.5): the leaderboard payload (all six boards at once) from GET
// /api/leaderboard. Like the event leaderboard this isn't part of canonical
// state - contracts, streak and achievements all live in `meta` and arrive
// free on every reconcile, but cross-user boards can only come from the
// server.
const [leaderboards, setLeaderboards] = useState(null);
const [leaderboardLoading, setLeaderboardLoading] = useState(false);
// Achievement unlocks waiting to be shown, oldest first. Queued rather than
// rendered all at once so several landing together (common on the first
// load after a long absence) don't stack overlapping toasts.
const [achievementQueue, setAchievementQueue] = useState([]);
// Local mirror of the display name so a successful username change (see
// ProfileSettings) reflects in the header/profile immediately, without a
// page reload or waiting on the next /api/me fetch (App.jsx's `user`
Expand All @@ -128,6 +144,15 @@ export default function RackStack({ user }) {
// heartbeat effect below.
const configKeyRef = useRef(null);
const lastConfigPollAtRef = useRef(0);
const lastLeaderboardFetchAtRef = useRef(0);
// Social (v1.5): serverTime-minus-Date.now(), refreshed from every state
// load and every reconcile. Only the UTC day boundary needs this: the
// streak and the contracts board are gated on a calendar DAY, a hard cliff
// the server owns, so a client whose clock is off by minutes near midnight
// would otherwise render a Claim button the server rejects (or hide one it
// would accept). Countdowns elsewhere in this file keep using Date.now()
// directly - a few seconds of skew just shifts a displayed number.
const serverClockOffsetRef = useRef(0);
const stateRef = useRef(null);
const queueRef = useRef(null);
const lastTickAtRef = useRef(Date.now());
Expand Down Expand Up @@ -218,7 +243,10 @@ export default function RackStack({ user }) {
}
}

function handleReconcile(serverState, results) {
function handleReconcile(serverState, results, serverTime, unlocked) {
if (typeof serverTime === 'number' && Number.isFinite(serverTime)) {
serverClockOffsetRef.current = serverTime - Date.now();
}
const resultCids = new Set((results || []).map((r) => r._cid));
pendingActionsRef.current = pendingActionsRef.current.filter((a) => !resultCids.has(a._cid));

Expand All @@ -228,6 +256,8 @@ export default function RackStack({ user }) {
if (result.ok && result.reward) openAnomalyRewardModal(result.reward);
}

queueAchievements(unlocked);

let rungClaimed = false;
for (const result of results || []) {
if (!pendingRungClaimIdsRef.current.has(result._cid)) continue;
Expand Down Expand Up @@ -312,6 +342,14 @@ export default function RackStack({ user }) {
}
}, [state, activeTab, pendingClaims]);

// Drains the achievement queue one badge at a time, ~3.2s each, so a batch
// that lands together reads as a sequence rather than a pile.
useEffect(() => {
if (achievementQueue.length === 0) return undefined;
const t = setTimeout(() => setAchievementQueue((q) => q.slice(1)), 3200);
return () => clearTimeout(t);
}, [achievementQueue]);

const REJECT_MESSAGES = {
insufficient_credits: 'Not enough FLOPS',
cooldown_active: 'On cooldown',
Expand Down Expand Up @@ -405,6 +443,10 @@ export default function RackStack({ user }) {
configRef.current = configRes.data;
configKeyRef.current = configKeyOf(configRes);
setConfig(configRes);
if (typeof stateRes.serverTime === 'number' && Number.isFinite(stateRes.serverTime)) {
serverClockOffsetRef.current = stateRes.serverTime - Date.now();
}
queueAchievements(stateRes.unlockedAchievements);
const initial = { run: stateRes.run, meta: stateRes.meta, server: stateRes.server };
stateRef.current = initial;
setState(initial);
Expand Down Expand Up @@ -572,6 +614,42 @@ export default function RackStack({ user }) {
// the optimistic flip is reverted - the reducer action is purely for
// display, so it must not keep claiming an opt-out that didn't actually
// persist server-side.
// Social (v1.5). Achievements unlock server-side with no claim step, so the
// only client-side job is telling the player it happened. Ids are appended
// (de-duplicated against what's already waiting) and drained one at a time
// by the effect below.
function queueAchievements(ids) {
if (!Array.isArray(ids) || ids.length === 0) return;
setAchievementQueue((q) => {
const seen = new Set(q);
return [...q, ...ids.filter((id) => !seen.has(id))];
});
}

// GET /api/leaderboard. Throttled because the Social tab's board section
// refetches on every open; the server serves one shared cached payload
// regardless, so this bounds client request volume rather than server work.
// `force` skips the throttle for the opt-out toggle, where the whole point
// is seeing yourself (dis)appear immediately.
async function refreshLeaderboard(force = false) {
const now = Date.now();
if (!force && now - lastLeaderboardFetchAtRef.current < LEADERBOARD_REFRESH_THROTTLE_MS) return;
lastLeaderboardFetchAtRef.current = now;
setLeaderboardLoading(true);
const res = await fetchLeaderboard();
setLeaderboardLoading(false);
if (!res || res.error) return;
setLeaderboards(res.boards || null);
}

function claimContract(index) {
dispatchAction({ type: 'claimContract', index });
}

function claimStreak() {
dispatchAction({ type: 'claimStreak' });
}

function toggleLeaderboardOptOut(next) {
dispatchAction({ type: 'setLeaderboardOptOut', optOut: next });
(async () => {
Expand All @@ -584,6 +662,9 @@ export default function RackStack({ user }) {
// next unrelated reconcile to refresh it.
lastEventFetchAtRef.current = Date.now();
refreshEventData();
// The same column gates the v1.5 global boards, so refresh those too
// (forced past the throttle for the same reason).
refreshLeaderboard(true);
} else {
dispatchAction({ type: 'setLeaderboardOptOut', optOut: !next });
}
Expand Down Expand Up @@ -910,6 +991,11 @@ export default function RackStack({ user }) {
// (isEventLive/isEventTabVisible are the module-scope helpers above the
// component, shared with the activeTab-reset effect so this can't drift
// from that check).
// Social (v1.5): wall-clock corrected to the server's clock, for the two
// displays gated on a UTC calendar DAY (the streak banner, the contracts
// rollover countdown). Everything else in this render keeps using `now`.
const serverNow = now + serverClockOffsetRef.current;

const eventProgress = state.meta.eventProgress;
const eventLive = isEventLive(eventProgress, now);
const eventTabVisible = isEventTabVisible(eventProgress, pendingClaims, now);
Expand Down Expand Up @@ -950,6 +1036,13 @@ export default function RackStack({ user }) {
{eventLive && activeEvent && (
<EventBanner event={activeEvent} endsAt={eventProgress.endsAt} onOpen={() => setActiveTab('event')} />
)}
<StreakBanner
streak={state.meta.streak}
serverTime={serverNow}
config={config.data}
ctx={ctx}
onClaim={claimStreak}
/>
<MigrateBar gain={gain} showCollectAll={anyManualOwned} collectDisabled={!anyReady} onMigrate={() => setModal({ type: 'migrate' })} onCollectAll={collectAll} />
<TabBar tabs={visibleTabs} activeTab={activeTab} setActiveTab={setActiveTab} gridUnlocked={gridUnlocked} overclockUnlocked={overclockUnlocked} singularityUnlocked={singularityUnlocked} coldStorageUnlocked={coldStorageUnlocked} eventLive={eventLive} />
</div>
Expand Down Expand Up @@ -1028,13 +1121,48 @@ export default function RackStack({ user }) {
/>
)}

{activeTab === 'social' && (
<SocialPanel
meta={state.meta}
serverTime={serverNow}
userId={user && user.id}
boards={leaderboards}
leaderboardLoading={leaderboardLoading}
optOut={state.meta.leaderboardOptOut}
onClaimContract={claimContract}
onToggleOptOut={toggleLeaderboardOptOut}
onRefreshLeaderboard={refreshLeaderboard}
/>
)}

{minigame && minigame.type === 'rush' && <RushOverlay minigame={minigame} onTap={tapRush} onCancel={cancelMinigame} />}
{minigame && minigame.type === 'debug' && <DebugOverlay minigame={minigame} onTap={tapDebugTile} onCancel={cancelMinigame} />}
{minigame && minigame.type === 'match' && <MatchOverlay minigame={minigame} pairCount={config.data.minigames.match.pairCount} onTap={tapMatchTile} onCancel={cancelMinigame} />}
{minigame && minigame.type === 'balance' && <BalanceOverlay minigame={minigame} balanceConfig={config.data.minigames.balance} onScore={balanceScore} onCancel={cancelMinigame} />}

<AnomalyToast anomalyState={anomalyState} windowMs={config.data.anomaly.windowMs} onClaim={claimAnomaly} />

{achievementQueue.length > 0 && (() => {
const def = achievementDef(achievementQueue[0]);
if (!def) return null;
const Icon = achievementIcon(def.icon);
const accent = TIER_COLOR[def.tier];
return (
<div className="fixed left-4 right-4 bottom-4 z-20 max-w-sm mx-auto">
<div
className="w-full rounded-xl p-3 flex items-center gap-3 tier-card"
style={{ background: inset, border: `1px solid ${accent}`, boxShadow: '0 8px 24px rgba(0,0,0,0.45)' }}
>
<Icon size={22} color={accent} className="shrink-0" />
<div className="min-w-0">
<div className="text-xs font-mono" style={{ color: accent }}>Achievement unlocked</div>
<div className="text-sm font-semibold truncate" style={{ color: textMain }}>{def.name}</div>
</div>
</div>
</div>
);
})()}

{rejectToast && (
<div className="fixed left-4 right-4 top-4 z-20 max-w-sm mx-auto">
<div className="w-full rounded-xl p-3 text-sm font-semibold text-center" style={{ background: inset, border: `1px solid ${danger}`, color: danger, boxShadow: '0 8px 24px rgba(0,0,0,0.45)' }}>
Expand Down
Loading