Skip to content

Commit a570685

Browse files
cole-pierceclaude
andcommitted
Fix Hunt Week double-counting of pre-game activities
Activities logged before game start but with loggedDate on the start date were counted in both initialState.points and getPointsInPeriod, inflating scores unevenly across players and distorting predator/prey outcomes. Add startedAt to miniGames schema, store it on activation, and filter Hunt Week period activities by _creationTime > gameStartedAt to exclude activities already baked into the initial snapshot. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1f40e52 commit a570685

5 files changed

Lines changed: 100 additions & 1 deletion

File tree

packages/backend/lib/miniGameCalculations.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ export async function previewHuntWeekEnd(
266266
endsAt: number,
267267
config: any,
268268
participants: MiniGameParticipantData[],
269+
gameStartedAt: number,
269270
): Promise<{ outcomes: HuntWeekOutcome[]; totalBonusPoints: number }> {
270271
return calculateHuntWeekOutcomes(
271272
ctx,
@@ -274,6 +275,7 @@ export async function previewHuntWeekEnd(
274275
endsAt,
275276
config,
276277
participants,
278+
gameStartedAt,
277279
);
278280
}
279281

@@ -284,6 +286,7 @@ export async function calculateHuntWeekOutcomes(
284286
endsAt: number,
285287
config: any,
286288
participants: MiniGameParticipantData[],
289+
gameStartedAt: number,
287290
): Promise<{ outcomes: HuntWeekOutcome[]; totalBonusPoints: number }> {
288291
const catchBonus = config?.catchBonus ?? 75;
289292
const caughtPenalty = config?.caughtPenalty ?? 25;
@@ -294,6 +297,7 @@ export async function calculateHuntWeekOutcomes(
294297
startsAt,
295298
endsAt,
296299
participants,
300+
gameStartedAt,
297301
);
298302
const rankMap = new Map<string, number>();
299303
leaderboard.forEach((entry, index) => {
@@ -354,16 +358,18 @@ async function getHuntWeekLeaderboard(
354358
startsAt: number,
355359
endsAt: number,
356360
participants: MiniGameParticipantData[],
361+
gameStartedAt: number,
357362
): Promise<LeaderboardEntry[]> {
358363
const entries = await Promise.all(
359364
participants.map(async (participant) => {
360365
const initialPoints = participant.initialState?.points ?? 0;
361-
const periodPoints = await getPointsInPeriod(
366+
const periodPoints = await getHuntWeekPointsInPeriod(
362367
ctx,
363368
participant.userId,
364369
challengeId,
365370
startsAt,
366371
endsAt,
372+
gameStartedAt,
367373
);
368374

369375
return {
@@ -505,6 +511,42 @@ export async function getPointsInPeriod(
505511
.reduce((sum, a) => sum + a.pointsEarned, 0);
506512
}
507513

514+
/**
515+
* Get total points earned by a user during a Hunt Week period.
516+
*
517+
* Unlike `getPointsInPeriod`, this:
518+
* - Includes all non-mini-game activities (not just PR_ELIGIBLE_KINDS), since
519+
* Hunt Week rankings include bonus-kind activities.
520+
* - Filters by `_creationTime > gameStartedAt` so activities that existed before
521+
* the game started (already baked into `initialState.points`) are not double-counted.
522+
*/
523+
export async function getHuntWeekPointsInPeriod(
524+
ctx: ReadCtx,
525+
userId: Id<"users">,
526+
challengeId: Id<"challenges">,
527+
startDate: number,
528+
endDate: number,
529+
gameStartedAt: number,
530+
): Promise<number> {
531+
const activities = await ctx.db
532+
.query("activities")
533+
.withIndex("by_user_challenge_date", (q) =>
534+
q.eq("userId", userId).eq("challengeId", challengeId),
535+
)
536+
.filter(notDeleted)
537+
.collect();
538+
539+
return activities
540+
.filter(
541+
(a) =>
542+
a.loggedDate >= startDate &&
543+
a.loggedDate <= endDate &&
544+
a.source !== "mini_game" &&
545+
a._creationTime > gameStartedAt,
546+
)
547+
.reduce((sum, a) => sum + a.pointsEarned, 0);
548+
}
549+
508550
/**
509551
* Get the maximum single-day points total during a time period.
510552
* Only includes core, special, and penalty activities (excludes bonus kind).

packages/backend/mutations/miniGames.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ export const start = mutation({
252252
// Update game status
253253
await ctx.db.patch(args.miniGameId, {
254254
status: "active",
255+
startedAt: now,
255256
updatedAt: now,
256257
});
257258

@@ -663,6 +664,7 @@ async function calculateHuntWeekOutcomes(
663664
miniGame.endsAt,
664665
miniGame.config,
665666
participants,
667+
miniGame.startedAt ?? miniGame.updatedAt,
666668
);
667669

668670
for (const outcome of outcomes) {

packages/backend/queries/miniGames.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,7 @@ export const previewEnd = query({
609609
miniGame.endsAt,
610610
miniGame.config,
611611
participants,
612+
miniGame.startedAt ?? miniGame.updatedAt,
612613
);
613614
return {
614615
type: "hunt_week" as const,

packages/backend/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ export default defineSchema({
456456
v.literal("completed"),
457457
),
458458
config: v.any(), // Game-specific config (bonus percentages, point values, etc.)
459+
startedAt: v.optional(v.number()), // Timestamp when game was activated (used to exclude pre-game activities)
459460
createdAt: v.number(),
460461
updatedAt: v.number(),
461462
})
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Fix Hunt Week Double-Counting Bug
2+
3+
**Date:** 2026-03-27
4+
5+
## Root Cause
6+
7+
`getHuntWeekLeaderboard()` computes each player's score as:
8+
9+
```
10+
initialState.points + getPointsInPeriod(startsAt, endsAt)
11+
```
12+
13+
- `initialState.points` is captured at game start time (e.g. 18:05:35 UTC on Mar 15).
14+
- `getPointsInPeriod` counts activities where `loggedDate >= startsAt`, and `startsAt` is midnight UTC (00:00:00) on Mar 15.
15+
- Activities logged between 00:00 UTC and 18:05 UTC on Mar 15 are already included in `initialState.points` **and** matched by the `loggedDate >= startsAt` filter — they are **double-counted**.
16+
17+
The magnitude varies per player (depends on how many activities they logged that morning before the game started), which distorts the Hunt Week leaderboard and can flip predator/prey outcomes.
18+
19+
## Fix (this PR)
20+
21+
- [x] Add `startedAt` field to `miniGames` schema
22+
- [x] Store `startedAt: now` when a game transitions to `"active"`
23+
- [x] Add `getHuntWeekPointsInPeriod()` helper that filters by `_creationTime > gameStartedAt` to exclude pre-game activities
24+
- [x] Thread `gameStartedAt` through `getHuntWeekLeaderboard``calculateHuntWeekOutcomes``previewHuntWeekEnd`
25+
- [x] Pass `miniGame.startedAt ?? miniGame.updatedAt` from both mutation and query callers
26+
27+
## Affected Data
28+
29+
The Mar 15-21 Hunt Week game (the only Hunt Week run so far) has already been ended. Its bonus awards were calculated with the double-counted leaderboard. Specific impacts:
30+
31+
- Players who logged activities the morning of Mar 15 before game start had inflated Hunt Week scores.
32+
- This may have changed who "caught" their prey and who "was caught," affecting the +75 / -25 bonus distribution.
33+
34+
## Remediation Steps (Post-Merge)
35+
36+
After this PR is merged and deployed, a repo admin should remediate the existing Mar 15-21 game:
37+
38+
1. **Identify the game:** Find the Hunt Week miniGame document for Mar 15-21 in the Convex dashboard or via CLI:
39+
```bash
40+
npx convex run miniGames:list '{}' --prod
41+
```
42+
43+
2. **Revoke existing bonus awards:** Delete the `source: "mini_game"` bonus activities that were awarded when the game ended. These are the activities with descriptions like "Hunt Week Bonus" tied to participants of this game.
44+
45+
3. **Backfill `startedAt`:** Patch the miniGame document to set `startedAt` to the original activation timestamp. Check the `updatedAt` field or Convex audit log for the exact time the game went active (should be ~18:05 UTC on Mar 15).
46+
```bash
47+
npx convex run --prod miniGames:backfillStartedAt '{"miniGameId": "<id>", "startedAt": <timestamp>}'
48+
```
49+
(You may need to write a small one-off mutation for this.)
50+
51+
4. **Re-end the game:** Set the game status back to `"active"`, then call the `end` mutation again. The new code will use `startedAt` to correctly compute the leaderboard and award bonuses without double-counting.
52+
53+
5. **Verify:** Compare the new outcomes against the original to confirm the fix resolves the discrepancy. Check that affected players' total points are updated correctly.

0 commit comments

Comments
 (0)