From d1110dd076ac8b49ca962178daf0ed22de5e93a5 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 27 Jul 2026 11:36:33 +0000 Subject: [PATCH] Preserve empty traffic periods --- app/game-api/src/services/generalAccess.ts | 39 +++++++++++++++++++ .../generalAccessTracking.integration.test.ts | 33 ++++++++++++++++ .../test/generalAccessTracking.test.ts | 9 ++++- 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts index 3ce669f..2dfdc97 100644 --- a/app/game-api/src/services/generalAccess.ts +++ b/app/game-api/src/services/generalAccess.ts @@ -93,6 +93,7 @@ export const upsertGeneralAccess = async ( worldStateId: number; year: number; month: number; + tickSeconds: number; generalId: number; userId: string; weight: number; @@ -105,8 +106,45 @@ export const upsertGeneralAccess = async ( throw new Error('Traffic access persistence requires transaction support.'); } await db.$transaction(async (transaction) => { + const periodKey = input.year * 12 + input.month - 1; const periodRows = await transaction.$queryRaw>( GamePrisma.sql` + WITH latest_period AS ( + SELECT MAX(year * 12 + month - 1)::INTEGER AS period_key + FROM traffic_period + WHERE world_state_id = ${input.worldStateId} + ), + missing_periods AS ( + INSERT INTO traffic_period ( + world_state_id, + year, + month, + started_at, + last_refresh, + refresh, + online + ) + SELECT + ${input.worldStateId}, + (missing_key / 12)::INTEGER, + (missing_key % 12 + 1)::INTEGER, + CAST(${input.periodStartedAt} AS TIMESTAMP) - ( + (${periodKey} - missing_key) * ${input.tickSeconds} + ) * INTERVAL '1 second', + CAST(${input.periodStartedAt} AS TIMESTAMP) - ( + (${periodKey} - missing_key - 1) * ${input.tickSeconds} + ) * INTERVAL '1 second', + 0, + 0 + FROM latest_period + CROSS JOIN LATERAL generate_series( + latest_period.period_key + 1, + ${periodKey} - 1 + ) AS missing_key + WHERE latest_period.period_key IS NOT NULL + ON CONFLICT (world_state_id, year, month) DO NOTHING + RETURNING id + ) INSERT INTO traffic_period ( world_state_id, year, @@ -265,6 +303,7 @@ export const recordGeneralAccess = async ( worldStateId: worldState.id, year: worldState.currentYear, month: worldState.currentMonth, + tickSeconds: worldState.tickSeconds, generalId: general.id, userId: user.id, weight, diff --git a/app/game-api/test/generalAccessTracking.integration.test.ts b/app/game-api/test/generalAccessTracking.integration.test.ts index 8e240c5..f3e2d5e 100644 --- a/app/game-api/test/generalAccessTracking.integration.test.ts +++ b/app/game-api/test/generalAccessTracking.integration.test.ts @@ -50,6 +50,7 @@ integration('general access tracking persistence', () => { worldStateId, year: 185, month: 3, + tickSeconds: 600, generalId, userId: 'access-user-a', now: new Date('2026-07-26T03:05:00.000Z'), @@ -100,6 +101,7 @@ integration('general access tracking persistence', () => { worldStateId, year: 185, month: 4, + tickSeconds: 600, generalId, userId: 'access-user-b', now: new Date('2026-07-26T03:15:00.000Z'), @@ -120,6 +122,7 @@ integration('general access tracking persistence', () => { worldStateId, year: 185, month: 4, + tickSeconds: 600, generalId: secondGeneralId, userId: 'access-user-c', now: new Date('2026-07-26T03:15:01.000Z'), @@ -154,6 +157,35 @@ integration('general access tracking persistence', () => { }, ], }); + + await upsertGeneralAccess(db, { + worldStateId, + year: 185, + month: 6, + tickSeconds: 600, + generalId, + userId: 'access-user-b', + now: new Date('2026-07-26T03:35:00.000Z'), + periodStartedAt: new Date('2026-07-26T03:30:00.000Z'), + scoreStartedAt: new Date('2026-07-26T03:30:00.000Z'), + weight: 1, + }); + await expect( + db.trafficPeriod.findUniqueOrThrow({ + where: { + worldStateId_year_month: { + worldStateId, + year: 185, + month: 5, + }, + }, + }) + ).resolves.toMatchObject({ + startedAt: new Date('2026-07-26T03:20:00.000Z'), + lastRefresh: new Date('2026-07-26T03:30:00.000Z'), + refresh: 0, + online: 0, + }); }); it('rolls back the period and member rows when the legacy access update fails', async () => { @@ -174,6 +206,7 @@ integration('general access tracking persistence', () => { worldStateId, year: 186, month: 1, + tickSeconds: 600, generalId: rollbackGeneralId, userId: 'rollback-user', now: new Date('2026-07-26T03:20:00.000Z'), diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts index 305b022..c6b75a0 100644 --- a/app/game-api/test/generalAccessTracking.test.ts +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -80,8 +80,15 @@ describe('general access tracking', () => { const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; expect(periodStatement.sql).toContain('INSERT INTO traffic_period'); + expect(periodStatement.sql).toContain('generate_series'); expect(periodStatement.sql).toContain('ON CONFLICT (world_state_id, year, month) DO UPDATE'); - expect(periodStatement.values).toEqual([3, 185, 4, new Date('2026-07-26T03:00:00.000Z'), now, 2]); + expect(periodStatement.values).toContain(3); + expect(periodStatement.values).toContain(185); + expect(periodStatement.values).toContain(4); + expect(periodStatement.values).toContain(600); + expect(periodStatement.values).toContain(2); + expect(periodStatement.values).toContain(now); + expect(periodStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z')); const memberStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general');