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
86 changes: 86 additions & 0 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAdminDb } from '@/firebase-config'
import { normalizeWalletAddress } from '@/lib/vault-utils'
import { readStreak, touchStreak, describeReward, STREAK_LADDER } from '@/lib/server/loginStreak'

// Reads ?wallet on GET, so it can only ever be served on demand — declare it
// dynamic or the build tries to prerender it and logs DYNAMIC_SERVER_USAGE.
export const dynamic = 'force-dynamic'

// =============================================
// LOGIN STREAK (GAME-DESIGN.md §5.3)
//
// GET /api/streak?wallet=0x...
// -> { dayId, streak, best, day, claimedToday, today, tomorrow, ladder }
// Pure read. Never advances anything, so the profile screen and any other
// passive reader can call it freely.
//
// POST /api/streak Body: { wallet }
// Registers today's visit and pays for it, at most once per UTC day.
// -> the same state plus `granted` (the reward, or null if today was already
// counted) and `grantedLabel` for the UI to show without re-deriving it.
//
// Guests are first-class here. A guest id has the same 20-byte shape as a
// wallet (see /api/player/seen), so it keys the same records — and a player who
// has not connected a wallet yet is exactly the player a retention mechanic is
// for. Every currency it pays is off-chain, so nothing about this needs a
// signature.
//
// Degrades OPEN, matching every other daily route here: with Firebase
// unconfigured this reports "no streak" rather than failing. The worst case is
// that the chip does not appear — never that the map breaks.
// =============================================

const EMPTY = {
streak: 0,
best: 0,
day: 1,
claimedToday: false,
today: STREAK_LADDER[0],
tomorrow: STREAK_LADDER[1],
}

function walletFrom(raw: string | null | undefined): string | null {
const w = String(raw || '').toLowerCase()
return /^0x[a-f0-9]{40}$/.test(w) ? normalizeWalletAddress(w) : null
}

export async function GET(req: NextRequest) {
try {
const wallet = walletFrom(req.nextUrl.searchParams.get('wallet'))
if (!wallet) return NextResponse.json({ error: 'Invalid wallet' }, { status: 400 })

const db = getAdminDb()
if (!db) return NextResponse.json({ ...EMPTY, ladder: STREAK_LADDER }, { status: 200 })

const state = await readStreak(db, wallet)
return NextResponse.json({ ...state, ladder: STREAK_LADDER }, { status: 200 })
} catch (error) {
console.error('[streak] GET error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({}))
const wallet = walletFrom(body?.wallet)
if (!wallet) return NextResponse.json({ error: 'Invalid wallet' }, { status: 400 })

const db = getAdminDb()
if (!db) return NextResponse.json({ ...EMPTY, granted: null, ladder: STREAK_LADDER }, { status: 200 })

const state = await touchStreak(db, wallet)
return NextResponse.json(
{
...state,
grantedLabel: state.granted ? describeReward(state.granted) : null,
ladder: STREAK_LADDER,
},
{ status: 200 },
)
} catch (error) {
console.error('[streak] POST error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
65 changes: 59 additions & 6 deletions components/game/DailyStatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,9 @@ import { playUiSound } from '@/lib/uiSound'
// already takes on the way to the only action on the screen, so it is read
// rather than discovered.
//
// WHAT IS DELIBERATELY NOT HERE. The login streak is in the build order but
// not built, so there is no chip for it. A placeholder showing a number nobody
// is tracking is how game-config.ts happened — see rule 2 in GAME-DESIGN.md
// §10. Chips appear when their system does; contracts got theirs the day they
// shipped.
// Every chip here is backed by a system that actually tracks the number it
// shows — rule 2 in GAME-DESIGN.md §10, and the reason each one arrived on the
// day its feature did rather than as a placeholder.
//
// Each chip hides itself when it has nothing true to say: no craft running, no
// fragments left to earn, energy not yet loaded. With all three quiet the bar
Expand Down Expand Up @@ -58,6 +56,10 @@ export default function DailyStatusBar({ address, onCrafting }: DailyStatusBarPr
const [contractList, setContractList] = useState<
{ id: string; label: string; progress: number; target: number; done: boolean }[]
>([])
const [streak, setStreak] = useState<{ streak: number; day: number; best: number; tomorrow: string } | null>(null)
// Shown once, on the visit that actually earned it. A reward the player is
// never told about is a reward that does not retain anyone.
const [streakGrant, setStreakGrant] = useState<string | null>(null)
const [showContracts, setShowContracts] = useState(false)
// Drives the countdown. Cheap: one re-render every 30s, and only while a
// craft is actually running (see the effect's guard).
Expand All @@ -72,12 +74,23 @@ export default function DailyStatusBar({ address, onCrafting }: DailyStatusBarPr
// Every one of them degrades to "chip hidden" rather than an error state —
// a status bar that can show an error is a status bar that can make the
// home screen look broken.
// The streak is a POST because opening the app IS the event it records —
// there is nothing to tap and no claim step, matching the decision Daily
// Contracts already made. It is idempotent per UTC day, so a remount or a
// second tab costs nothing.
const streakReq = fetch('/api/streak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wallet: address }),
}).then((r) => (r.ok ? r.json() : null)).catch(() => null)

Promise.all([
j(`/api/energy?wallet=${encodeURIComponent(address)}`),
j(`/api/vault/fragments?wallet=${encodeURIComponent(address)}`),
j(`/api/weapons/craft?wallet=${encodeURIComponent(address)}`),
j(`/api/contracts?wallet=${encodeURIComponent(address)}`),
]).then(([e, f, c, d]) => {
streakReq,
]).then(([e, f, c, d, s]) => {
if (!alive) return
if (e && typeof e.total === 'number') setEnergy({ total: e.total, free: e.freeRemaining ?? 0 })
if (f && typeof f.fragments === 'number' && f.nextGoal) {
Expand All @@ -87,6 +100,20 @@ export default function DailyStatusBar({ address, onCrafting }: DailyStatusBarPr
setContracts({ done: d.completed ?? 0, total: d.contracts.length })
setContractList(d.contracts)
}
if (s && typeof s.streak === 'number' && s.streak > 0) {
const label = (r: { kind?: string; amount?: number; tier?: string } | null | undefined) =>
!r ? '' : r.kind === 'point' ? `+${r.amount} Point`
: r.kind === 'shard' ? `+${r.amount} Shard ${String(r.tier || '').toUpperCase()}`
: `+${r.amount} energy`
setStreak({ streak: s.streak, day: s.day ?? 1, best: s.best ?? s.streak, tomorrow: label(s.tomorrow) })
// The energy chip is fetched in the same breath as this, so a streak
// that just paid energy would otherwise show yesterday's number until
// the next mount.
if (s.granted?.kind === 'energy' && e && typeof e.total === 'number') {
setEnergy({ total: e.total + (s.granted.amount || 0), free: e.freeRemaining ?? 0 })
}
if (s.grantedLabel) setStreakGrant(s.grantedLabel)
}
if (c?.craft?.completesAt) {
// Correct for client clock skew — the server's own clock is the one the
// craft timer is measured against.
Expand Down Expand Up @@ -114,6 +141,22 @@ export default function DailyStatusBar({ address, onCrafting }: DailyStatusBarPr

const chips: Chip[] = []

// First, and always visible once it exists. This is the one number on the bar
// the player can LOSE, and loss aversion only works if the thing at risk is
// in front of them — a streak they have to go looking for is not at stake.
if (streak) {
const last = streak.day === 7
chips.push({
key: 'streak',
icon: last ? '★' : '🔥',
label: `${streak.streak}`,
tone: last ? 'ready' : 'amber',
title: last
? `Day 7 — the big one. Come back tomorrow and the ladder starts again. Best: ${streak.best} days`
: `${streak.streak}-day streak · tomorrow: ${streak.tomorrow}. Miss a day and it goes back to 1. Best: ${streak.best} days`,
})
}

if (craftDoneAt !== null) {
const left = craftDoneAt - now
chips.push({
Expand Down Expand Up @@ -171,6 +214,16 @@ export default function DailyStatusBar({ address, onCrafting }: DailyStatusBarPr

return (
<>
{streakGrant && streak && (
<button
type="button"
className="ns-hub-streak-note"
onClick={() => { playUiSound('panel'); setStreakGrant(null) }}
aria-label={`Day ${streak.day} streak reward: ${streakGrant}. Tap to dismiss.`}
>
<span aria-hidden="true">🔥</span> Day {streak.day} · {streakGrant}
</button>
)}
{showContracts && contractList.length > 0 && (
<div className="ns-hub-contracts" role="region" aria-label="Today's contracts">
<p className="ns-hub-contracts-head">Today · resets 00:00 UTC</p>
Expand Down
67 changes: 56 additions & 11 deletions docs/GAME-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,9 @@ more mode, not surgery.

## 3. The four time layers

A live game needs an answer at every timescale. Miss one and the player
falls out of the loop there. NullState today has layers 1, 3, and a broken
4. **Layer 2 is empty, and that is the whole problem.**
A live game needs an answer at every timescale. Miss one and the player falls
out of the loop there. Layer 2 was empty, and that was the whole problem; it is
now filled. Layer 4 is the one still half-broken (§9).

### Layer 1 — Session: "why play right now" — `[TODAY]`

Expand All @@ -119,9 +119,9 @@ Combat, procedural floors, loot rarity, the lift, containers — all shipped.

This was the empty layer, and the whole problem. Energy (5/day) is a
*limiter*, not a *reason* — you limit what people want more of, and there
was nothing they wanted more of once the vault was claimed. Three of the
four mechanics in §5 now fill it: Vault Fragments, Daily Contracts and the
surfaced craft timer. Only the login streak (§5.3) is still open.
was nothing they wanted more of once the vault was claimed. **All four
mechanics in §5 now fill it**: Vault Fragments, Daily Contracts, the surfaced
craft timer, and the login streak.

### Layer 3 — Weekly: "why care this week" — `[TODAY]`, needs §5.1

Expand Down Expand Up @@ -294,14 +294,59 @@ rail button, which until now was a `SOON` badge promising exactly this feature
`reportContract()` in `game.js`, `DailyStatusBar.tsx`. Locked down by
`npm run test:contracts`.

### 5.3 Login streak — `[TARGET]`
### 5.3 Login streak — `[TODAY]`

Seven escalating days; breaking it resets to day 1. Loss aversion is the
strongest retention force available and it costs nothing.

Half of this already exists: the Season Pass daily claim grants +1 energy
and +3 t1 shards per UTC day (`game-config.ts:50-51`) — a login reward that
was never framed as one.
Half of this already existed: the Season Pass daily claim grants +1 energy and
+3 t1 shards per UTC day — a login reward that was never framed as one, and
only for pass holders. This is the version everyone gets, guests included.

**Why it is separate from Daily Contracts.** Contracts answer *why play today*.
They do not answer *why open this at all today*, and those are different
questions. A player with ten spare minutes plays; a player with one spare
minute opens the app or does not — and if they do not, what breaks is the
habit, not the session.

| Day | Pays |
|---|---|
| 1 | 80 NullState Point |
| 2 | 2 Glitch Shards (t1) |
| 3 | +1 energy |
| 4 | 3 Glitch Shards (t1) |
| 5 | 150 NullState Point |
| 6 | 4 Glitch Shards (t1) |
| **7** | **8 Glitch Shards (t1)** |

**Why the ladder is shaped like this.** Every rung is t1 shards, energy or
Point on purpose. Shard *tier* is act-gated — `_shardTierForAct()` drops t1 on
acts 1–2, t2 on 3–4, t3 on act 5 — so paying a t2 shard would hand a new player
a currency they cannot spend and did not earn.

Day 7 is **8 t1 shards because `EVOLUTION_SHARD_COSTS[0]` is 8**: a full week is
worth exactly one weapon evolution. That is the ratchet §4 asks for in so many
words — *"the weapon is tier 3, so next week is faster"* — and it is a prize a
player can name, which a scattering of shards is not.

Sized against Daily Contracts (200–400 Point or 2–4 t1 for real work): a whole
week of merely opening the app is worth roughly **one day of playing it**. That
ordering is deliberate. Showing up should be rewarded; it must never out-earn
showing up *and playing*.

**No claim step**, matching the decision Daily Contracts already made — opening
the app *is* the event. The chip sits first on the daily bar because it is the
one number there the player can lose, and loss aversion only works when the
thing at risk is in front of them.

Trust: the day is the server's UTC day, the streak is derived from the stored
last day rather than sent, and the advance happens inside one RTDB transaction —
so two tabs cannot both advance it or both be paid. A grant that throws still
counts the day, because a re-claimable day is worse than one missed grant.

Locked down by `npm run test:streak` (31 assertions against a stubbed RTDB —
the day boundaries cannot be tested in a browser without waiting for midnight)
and `npm run test:streak-ui` (16 assertions in a real browser).

### 5.4 Surface the craft timer — `[TODAY]`

Expand Down Expand Up @@ -754,7 +799,7 @@ time.
5. ~~Delete the dead blocks in `game-config.ts`~~ — **shipped**

**After the listing**
6. §5.3 Login streak
6. ~~§5.3 Login streak~~ — **shipped**
7. §7 Bunker differentiation (time vs risk)
8. ~~§8 Seeded dungeon → fixes Save & Exit~~ — **shipped**
9. §9 Leaderboard consolidation + automated season payout
Expand Down
23 changes: 23 additions & 0 deletions docs/game-mechanics.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,29 @@ see today's list and how far along you are.

---

### 🔥 Login Streak

Just opening the game counts. Every UTC day you show up moves your streak up
one rung, and each rung pays more than the last:

| Day | You get |
|---|---|
| 1 | 80 NullState Point |
| 2 | 2 Glitch Shards |
| 3 | +1 energy |
| 4 | 3 Glitch Shards |
| 5 | 150 NullState Point |
| 6 | 4 Glitch Shards |
| **7** | **8 Glitch Shards** — exactly one weapon evolution |

No button to press: the reward lands the moment the map opens, and the **🔥**
chip shows how many days you are on. After day 7 the ladder starts again, and
your longest run is kept as a record.

**Miss a day and it goes back to 1.**

---

### 🧩 Vault Fragments — the guaranteed way to get them

You are never left to luck. Every **lockable container** you open — the ones
Expand Down
Loading
Loading