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
46 changes: 32 additions & 14 deletions lib/leaderboardService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { LeaderboardEntry } from './contract'
import { currentSeasonId } from './season'
// The arithmetic that decides who gets paid lives in its own Firebase-free
// module so it can be tested directly — see lib/seasonXp.ts.
import { seasonBaseline } from './seasonXp'
import { seasonXpGain } from './seasonXp'

// ─── THE SEASON BOARD, AND WHY IT HAD TO EXIST ───────────────────────────────
//
Expand Down Expand Up @@ -73,10 +73,16 @@ interface LeaderboardDoc {
// reset between deaths within the same continuous play session (a
// Revive keeps counting from where it left off) — see recordRunKills.
lastRecordedKills?: number
/** Which season `seasonBaseXp` / `seasonBaseKills` were taken for. */
seasonId?: string
/** Career xp at the moment this season started, for this wallet. */
seasonBaseXp?: number
/**
* The engine's raw xp as last reported — NOT the career high-water mark
* above. Season XP is the increase between reports, so this is the only
* field that makes a returning player's season score move. See seasonXp.ts.
*
* Replaces `seasonId` + `seasonBaseXp`, which are no longer written: a
* baseline that nothing computes against is a value that can only mislead
* the next reader. Old docs may still carry them; nothing reads them.
*/
lastRecordedXp?: number
updatedAt: number
}

Expand Down Expand Up @@ -108,20 +114,24 @@ export async function updateLeaderboardEntry(
// reached", so a New Game (which legitimately restarts at 0 XP) can never
// erase it — and can never drive season xp negative either.
const careerXp = Math.max(data.xp ?? 0, xp)
const base = seasonBaseline(data, careerXp, seasonId)
// The season total ACCUMULATES — see seasonXpGain. Both writers use the
// same `lastRecordedXp`, so whichever runs first books the gain and the
// other adds nothing: re-reporting an unchanged figure is worth zero.
const gain = seasonXpGain(data, xp)
const seasonSnap = await tx.get(seasonPlayerRef(seasonId, normalizedAddr))
const seasonPrev = (seasonSnap.exists() ? seasonSnap.data() : {}) as { xp?: number }
tx.set(ref, {
walletAddress: normalizedAddr,
username,
xp: careerXp,
level: Math.max(data.level ?? 1, level),
seasonId,
seasonBaseXp: base,
lastRecordedXp: xp,
updatedAt: Date.now(),
}, { merge: true })
tx.set(seasonPlayerRef(seasonId, normalizedAddr), {
walletAddress: normalizedAddr,
username,
xp: Math.max(0, careerXp - base),
xp: (seasonPrev.xp ?? 0) + gain,
level: Math.max(data.level ?? 1, level),
updatedAt: Date.now(),
}, { merge: true })
Expand Down Expand Up @@ -238,20 +248,28 @@ export async function recordRunProgress(
const normalizedAddr = walletAddress.toLowerCase()
const ref = doc(db, 'leaderboard', normalizedAddr)
await runTransaction(db, async (tx) => {
// BOTH READS FIRST — Firestore rejects a read after a write, and that
// rule already cost this file every kill for a month. See
// scripts/test-leaderboard-writes.js.
const seasonId = seasonKey()
const snap = await tx.get(ref)
const seasonSnap = await tx.get(seasonPlayerRef(seasonId, normalizedAddr))
const data = (snap.exists() ? snap.data() : {}) as Partial<LeaderboardDoc>
const seasonPrev = (seasonSnap.exists() ? seasonSnap.data() : {}) as { xp?: number }
const nextXp = Math.max(data.xp ?? 0, xp)
const nextLevel = Math.max(data.level ?? 1, level)
const seasonId = seasonKey()
const base = seasonBaseline(data, nextXp, seasonId)
// Career xp stays a high-water mark, because that is what "career" means
// and a New Game must not erase it. The SEASON no longer derives from it:
// for a returning player the high-water mark never moves, which is
// exactly how a wallet with 30 kills ended up showing 0 season XP.
const gain = seasonXpGain(data, xp)
tx.set(
ref,
{
walletAddress: normalizedAddr,
xp: nextXp,
level: nextLevel,
seasonId,
seasonBaseXp: base,
lastRecordedXp: xp,
updatedAt: Date.now(),
},
{ merge: true }
Expand All @@ -263,7 +281,7 @@ export async function recordRunProgress(
seasonPlayerRef(seasonId, normalizedAddr),
{
walletAddress: normalizedAddr,
xp: Math.max(0, nextXp - base),
xp: (seasonPrev.xp ?? 0) + gain,
level: nextLevel,
updatedAt: Date.now(),
},
Expand Down
99 changes: 52 additions & 47 deletions lib/seasonXp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,60 +14,65 @@
// opened August already 5,926 XP ahead of second place — earned in a month that
// was over — and a player joining in September could never have won at all.
//
// A season score is therefore a DELTA: career XP now, minus career XP when the
// season started for that wallet.
// A season score is therefore a DELTA. The first version took that delta
// against a BASELINE — career XP now, minus career XP when the season started —
// and it was wrong for exactly the players who had been here longest.
//
// ── HOW THE BASELINE FAILED, AND WHY IT LOOKED FINE ─────────────────────────
//
// OWNER, Season 2, from the live board: *"pake wallet lainnya kills terhitung
// tp xp tidak."* His row read 30 kills and 0 XP.
//
// Career XP is stored as a high-water mark — Math.max(stored, incoming) — so a
// New Game, which legitimately restarts at 0, cannot erase it. Combine the two
// and a returning player is frozen:
//
// stored career xp (from Season 1) .... 8581
// baseline for Season 2 ............... 8581
// this run's xp ....................... 585
// max(8581, 585) - 8581 ............... 0
//
// They earn nothing for the season until ONE RUN beats their all-time best. A
// brand-new wallet is baselined at 0 and works perfectly, which is why some
// accounts looked right and others sat at zero.
//
// ── WHAT REPLACED IT ────────────────────────────────────────────────────────
//
// The same shape recordRunKills has used correctly all along: remember what was
// last reported, add the increase. It accumulates what the player actually
// earned, it survives a New Game (a lower figure is a fresh run and counts in
// full), and it never consults a high-water mark.

/** The fields the calculation reads off a stored leaderboard doc. */
export interface SeasonBaselineDoc {
/** Career XP as of the last write. */
xp?: number
/** Which season `seasonBaseXp` was taken for. */
seasonId?: string
/** Career XP at the moment that season started, for this wallet. */
seasonBaseXp?: number
export interface SeasonXpDoc {
/**
* The engine's raw xp figure as last reported, NOT the career high-water
* mark. The two differ the moment a player starts a New Game, and telling
* them apart is the whole fix.
*/
lastRecordedXp?: number
}

/**
* Where this wallet's season starts.
*
* Three cases, and the middle one is the whole point:
* How much XP to ADD to this season's total for a report of `xp`.
*
* 1. Baseline already taken for THIS season -> keep it. Re-deriving it on
* every write would reset the player's season score to zero each time.
* 2. Baseline is from a PREVIOUS season (or absent) -> take `doc.xp`, the
* career total as of the last write, which by definition was last season.
* NOT the incoming figure: that would silently discard whatever the player
* earned before their first sync of the new month.
* 3. No document at all -> the incoming career total. A wallet with no row is
* a new player, whose career total is near zero anyway.
* Three cases, and the first two are the ones the baseline got wrong:
*
* At rollout every existing doc lands in case 2 with no `seasonId`, so each
* player is baselined at their current career total and the season starts level
* for everybody. That is the desired migration, and it needs no backfill.
*/
export function seasonBaseline(
doc: SeasonBaselineDoc | null | undefined,
careerXp: number,
seasonId: string,
): number {
const d = doc || {}
if (d.seasonId === seasonId && typeof d.seasonBaseXp === 'number') return d.seasonBaseXp
return typeof d.xp === 'number' ? d.xp : careerXp
}

/**
* XP earned this season. Never negative.
* 1. Never reported before -> 0. There is nothing to compare against, so the
* player is anchored here and starts earning from their next report. This
* is also the migration: every existing doc lands here once, and the
* season carries on from whatever it had rather than jumping.
* 2. Higher than last time -> the increase. What they earned since.
* 3. LOWER than last time -> the whole figure. p.xp only goes down when a run
* restarts, so the new run's progress counts in full. The old code read
* this as "no progress" forever.
*
* The clamp is not defensive noise: "New Game" legitimately restarts a run at 0
* XP, and while the stored career total is kept as a high-water mark, a doc
* written before that rule existed could still hold a baseline above the
* current total. A negative score would sort a player to the very bottom of a
* board they might be winning.
* Never negative, and never inflated by re-reporting the same number: two
* writers calling this with an unchanged `xp` add nothing the second time.
*/
export function seasonXpFrom(
doc: SeasonBaselineDoc | null | undefined,
careerXp: number,
seasonId: string,
): number {
return Math.max(0, careerXp - seasonBaseline(doc, careerXp, seasonId))
export function seasonXpGain(doc: SeasonXpDoc | null | undefined, xp: number): number {
if (!Number.isFinite(xp) || xp < 0) return 0
const last = doc?.lastRecordedXp
if (typeof last !== 'number') return 0
return xp >= last ? xp - last : xp
}
Loading
Loading