diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index a85677b..411407c 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -173,26 +173,6 @@ const readFiniteMetaNumber = (meta: Record, key: string): numbe return typeof value === 'number' && Number.isFinite(value) ? value : 0; }; -const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => { - if (!Array.isArray(value)) { - return []; - } - - const result: TrafficHistoryItem[] = []; - for (const item of value) { - const row = asRecord(item); - const year = readFiniteMetaNumber(row, 'year'); - const month = readFiniteMetaNumber(row, 'month'); - const refresh = readFiniteMetaNumber(row, 'refresh'); - const online = readFiniteMetaNumber(row, 'online'); - const date = typeof row.date === 'string' ? row.date : ''; - if (year > 0 && month > 0 && date) { - result.push({ year, month, refresh, online, date }); - } - } - return result; -}; - const compareString = (left: string, right: string): number => { if (left === right) { return 0; @@ -298,69 +278,120 @@ export const publicRouter = router({ } const meta = asRecord(worldState.meta); - const rawOnlineSince = meta.lastTurnTime ?? meta.turntime; - const parsedOnlineSince = - typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date - ? new Date(rawOnlineSince) - : null; - const onlineSince = - parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime()) - ? parsedOnlineSince - : new Date(Date.now() - worldState.tickSeconds * 1_000); - const [accessTotal, currentOnline, topAccess] = await Promise.all([ - ctx.db.generalAccessLog.aggregate({ - _sum: { - refresh: true, - refreshScoreTotal: true, - }, - }), - ctx.db.generalAccessLog.count({ + const [currentPeriod, previousPeriods, periodMaximums, accessTotal] = await Promise.all([ + ctx.db.trafficPeriod.findUnique({ where: { - lastRefresh: { - gte: onlineSince, + worldStateId_year_month: { + worldStateId: worldState.id, + year: worldState.currentYear, + month: worldState.currentMonth, + }, + }, + include: { + _count: { + select: { generals: true }, }, }, }), - ctx.db.generalAccessLog.findMany({ - orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }], + ctx.db.trafficPeriod.findMany({ + where: { + worldStateId: worldState.id, + NOT: { + year: worldState.currentYear, + month: worldState.currentMonth, + }, + }, + orderBy: [{ startedAt: 'desc' }, { id: 'desc' }], take: 5, - select: { - generalId: true, + include: { + _count: { + select: { generals: true }, + }, + }, + }), + ctx.db.trafficPeriod.aggregate({ + where: { worldStateId: worldState.id }, + _max: { refresh: true, + online: true, + }, + }), + ctx.db.generalAccessLog.aggregate({ + _sum: { refreshScoreTotal: true, }, }), ]); + const topAccess = currentPeriod + ? await ctx.db.trafficPeriodGeneral.findMany({ + where: { periodId: currentPeriod.id }, + orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }], + take: 5, + select: { + generalId: true, + refresh: true, + }, + }) + : []; const generalIds = topAccess.map((entry) => entry.generalId); - const generalRows = + const [generalRows, generalAccessRows] = generalIds.length > 0 - ? await ctx.db.general.findMany({ - where: { id: { in: generalIds } }, - select: { id: true, name: true }, - }) - : []; + ? await Promise.all([ + ctx.db.general.findMany({ + where: { id: { in: generalIds } }, + select: { id: true, name: true }, + }), + ctx.db.generalAccessLog.findMany({ + where: { generalId: { in: generalIds } }, + select: { generalId: true, refreshScoreTotal: true }, + }), + ]) + : [[], []]; const generalName = new Map(generalRows.map((general) => [general.id, general.name])); - const totalRefresh = accessTotal._sum.refresh ?? 0; + const accessScore = new Map(generalAccessRows.map((entry) => [entry.generalId, entry.refreshScoreTotal])); + const totalRefresh = currentPeriod?.refresh ?? 0; const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0; - const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh); - const history = parseTrafficHistory(meta.recentTraffic); - history.push({ - year: worldState.currentYear, - month: worldState.currentMonth, - refresh: currentRefresh, - online: currentOnline, - date: new Date().toISOString(), - }); + const currentOnline = currentPeriod ? Math.max(currentPeriod.online, currentPeriod._count.generals) : 0; + const history: TrafficHistoryItem[] = previousPeriods.reverse().map((period) => ({ + year: period.year, + month: period.month, + refresh: period.refresh, + online: Math.max(period.online, period._count.generals), + date: period.lastRefresh.toISOString(), + })); + history.push( + currentPeriod + ? { + year: currentPeriod.year, + month: currentPeriod.month, + refresh: currentPeriod.refresh, + online: currentOnline, + date: currentPeriod.lastRefresh.toISOString(), + } + : { + year: worldState.currentYear, + month: worldState.currentMonth, + refresh: 0, + online: 0, + date: new Date().toISOString(), + } + ); return { history, maxRefresh: Math.max( 1, readFiniteMetaNumber(meta, 'maxrefresh'), + periodMaximums._max.refresh ?? 0, ...history.map((entry) => entry.refresh) ), - maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)), + maxOnline: Math.max( + 1, + readFiniteMetaNumber(meta, 'maxonline'), + periodMaximums._max.online ?? 0, + ...history.map((entry) => entry.online) + ), suspects: [ { generalId: null, @@ -372,7 +403,7 @@ export const publicRouter = router({ generalId: entry.generalId, name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`, refresh: entry.refresh, - refreshScoreTotal: entry.refreshScoreTotal, + refreshScoreTotal: accessScore.get(entry.generalId) ?? 0, })), ], }; diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts index 79f36f1..3ce669f 100644 --- a/app/game-api/src/services/generalAccess.ts +++ b/app/game-api/src/services/generalAccess.ts @@ -76,8 +76,7 @@ export const resolveAccessWindows = ( now: Date, tickSeconds: number, worldMeta: unknown -): { dayStartedAt: Date; scoreStartedAt: Date } => { - const dayStartedAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); +): { periodStartedAt: Date; scoreStartedAt: Date } => { const meta = asRecord(worldMeta); const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime); const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000; @@ -85,60 +84,140 @@ export const resolveAccessWindows = ( tickStartedAt && tickStartedAt.getTime() <= now.getTime() ? tickStartedAt : new Date(now.getTime() - fallbackTickMs); - return { dayStartedAt, scoreStartedAt }; + return { periodStartedAt: scoreStartedAt, scoreStartedAt }; }; export const upsertGeneralAccess = async ( - db: Pick, + db: Pick, input: { + worldStateId: number; + year: number; + month: number; generalId: number; userId: string; weight: number; now: Date; - dayStartedAt: Date; + periodStartedAt: Date; scoreStartedAt: Date; } ): Promise => { - await db.$executeRaw( - GamePrisma.sql` - INSERT INTO general_access_log ( - general_id, - user_id, - last_refresh, - refresh, - refresh_total, - refresh_score, - refresh_score_total - ) - VALUES ( - ${input.generalId}, - ${input.userId}, - ${input.now}, - ${input.weight}, - ${input.weight}, - ${input.weight}, - ${input.weight} - ) - ON CONFLICT (general_id) DO UPDATE SET - user_id = EXCLUDED.user_id, - last_refresh = EXCLUDED.last_refresh, - refresh = CASE - WHEN general_access_log.last_refresh IS NULL - OR general_access_log.last_refresh < ${input.dayStartedAt} - THEN EXCLUDED.refresh - ELSE general_access_log.refresh + EXCLUDED.refresh - END, - refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total, - refresh_score = CASE - WHEN general_access_log.last_refresh IS NULL - OR general_access_log.last_refresh < ${input.scoreStartedAt} - THEN EXCLUDED.refresh_score - ELSE general_access_log.refresh_score + EXCLUDED.refresh_score - END, - refresh_score_total = - general_access_log.refresh_score_total + EXCLUDED.refresh_score_total - ` - ); + if (!db.$transaction) { + throw new Error('Traffic access persistence requires transaction support.'); + } + await db.$transaction(async (transaction) => { + const periodRows = await transaction.$queryRaw>( + GamePrisma.sql` + INSERT INTO traffic_period ( + world_state_id, + year, + month, + started_at, + last_refresh, + refresh + ) + VALUES ( + ${input.worldStateId}, + ${input.year}, + ${input.month}, + ${input.periodStartedAt}, + ${input.now}, + ${input.weight} + ) + ON CONFLICT (world_state_id, year, month) DO UPDATE SET + started_at = LEAST(traffic_period.started_at, EXCLUDED.started_at), + last_refresh = GREATEST(traffic_period.last_refresh, EXCLUDED.last_refresh), + refresh = traffic_period.refresh + EXCLUDED.refresh + RETURNING id + ` + ); + const periodId = periodRows[0]?.id; + if (periodId === undefined) { + throw new Error('Failed to resolve the traffic period.'); + } + + await transaction.$executeRaw( + GamePrisma.sql` + WITH inserted_general AS ( + INSERT INTO traffic_period_general ( + period_id, + general_id, + user_id, + refresh, + last_refresh + ) + VALUES ( + ${periodId}, + ${input.generalId}, + ${input.userId}, + ${input.weight}, + ${input.now} + ) + ON CONFLICT (period_id, general_id) DO NOTHING + RETURNING period_id + ), + updated_general AS ( + UPDATE traffic_period_general + SET + user_id = ${input.userId}, + refresh = traffic_period_general.refresh + ${input.weight}, + last_refresh = GREATEST( + traffic_period_general.last_refresh, + ${input.now} + ) + WHERE period_id = ${periodId} + AND general_id = ${input.generalId} + AND NOT EXISTS (SELECT 1 FROM inserted_general) + RETURNING period_id + ) + UPDATE traffic_period + SET online = traffic_period.online + ( + SELECT COUNT(*)::INTEGER FROM inserted_general + ) + WHERE id = ${periodId} + ` + ); + + await transaction.$executeRaw( + GamePrisma.sql` + INSERT INTO general_access_log ( + general_id, + user_id, + last_refresh, + refresh, + refresh_total, + refresh_score, + refresh_score_total + ) + VALUES ( + ${input.generalId}, + ${input.userId}, + ${input.now}, + ${input.weight}, + ${input.weight}, + ${input.weight}, + ${input.weight} + ) + ON CONFLICT (general_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + last_refresh = EXCLUDED.last_refresh, + refresh = CASE + WHEN general_access_log.last_refresh IS NULL + OR general_access_log.last_refresh < ${input.periodStartedAt} + THEN EXCLUDED.refresh + ELSE general_access_log.refresh + EXCLUDED.refresh + END, + refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total, + refresh_score = CASE + WHEN general_access_log.last_refresh IS NULL + OR general_access_log.last_refresh < ${input.scoreStartedAt} + THEN EXCLUDED.refresh_score + ELSE general_access_log.refresh_score + EXCLUDED.refresh_score + END, + refresh_score_total = + general_access_log.refresh_score_total + EXCLUDED.refresh_score_total + ` + ); + }); }; export const recordGeneralAccess = async ( @@ -159,7 +238,13 @@ export const recordGeneralAccess = async ( }), ctx.db.worldState.findFirst({ orderBy: { id: 'asc' }, - select: { tickSeconds: true, meta: true }, + select: { + id: true, + currentYear: true, + currentMonth: true, + tickSeconds: true, + meta: true, + }, }), ]); if (!general || !worldState) { @@ -174,14 +259,17 @@ export const recordGeneralAccess = async ( } const weight = accessPageWeights[page]; - const { dayStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta); + const { periodStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta); await upsertGeneralAccess(ctx.db, { + worldStateId: worldState.id, + year: worldState.currentYear, + month: worldState.currentMonth, generalId: general.id, userId: user.id, weight, now, - dayStartedAt, + periodStartedAt, scoreStartedAt, }); return true; diff --git a/app/game-api/test/generalAccessTracking.integration.test.ts b/app/game-api/test/generalAccessTracking.integration.test.ts index 989792f..8e240c5 100644 --- a/app/game-api/test/generalAccessTracking.integration.test.ts +++ b/app/game-api/test/generalAccessTracking.integration.test.ts @@ -6,30 +6,54 @@ import { upsertGeneralAccess } from '../src/services/generalAccess.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const generalId = 9_980_071; +const secondGeneralId = generalId + 1; +const rollbackGeneralId = generalId + 2; +const scenarioCode = `traffic-period-${generalId}`; integration('general access tracking persistence', () => { let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; + let worldStateId: number; beforeAll(async () => { const connector = createGamePostgresConnector({ url: databaseUrl! }); await connector.connect(); db = connector.prisma; closeDb = () => connector.disconnect(); - await db.generalAccessLog.deleteMany({ where: { generalId } }); + await db.generalAccessLog.deleteMany({ + where: { generalId: { in: [generalId, secondGeneralId, rollbackGeneralId] } }, + }); + await db.worldState.deleteMany({ where: { scenarioCode } }); + const world = await db.worldState.create({ + data: { + scenarioCode, + currentYear: 185, + currentMonth: 3, + tickSeconds: 600, + config: {}, + meta: {}, + }, + }); + worldStateId = world.id; }); afterAll(async () => { - await db.generalAccessLog.deleteMany({ where: { generalId } }); + await db.generalAccessLog.deleteMany({ + where: { generalId: { in: [generalId, secondGeneralId, rollbackGeneralId] } }, + }); + await db.worldState.deleteMany({ where: { id: worldStateId } }); await closeDb?.(); }); - it('atomically increments concurrent requests and resets only windowed counters', async () => { + it('atomically increments one game-month bucket and opens a new bucket at the next month', async () => { const firstWindow = { + worldStateId, + year: 185, + month: 3, generalId, userId: 'access-user-a', now: new Date('2026-07-26T03:05:00.000Z'), - dayStartedAt: new Date('2026-07-26T00:00:00.000Z'), + periodStartedAt: new Date('2026-07-26T03:00:00.000Z'), scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'), }; await upsertGeneralAccess(db, { ...firstWindow, weight: 2 }); @@ -50,13 +74,37 @@ integration('general access tracking persistence', () => { refreshScore: 22, refreshScoreTotal: 22, }); + const firstPeriod = await db.trafficPeriod.findUniqueOrThrow({ + where: { + worldStateId_year_month: { + worldStateId, + year: 185, + month: 3, + }, + }, + include: { generals: { orderBy: { generalId: 'asc' } } }, + }); + expect(firstPeriod).toMatchObject({ + refresh: 22, + online: 1, + generals: [ + { + generalId, + userId: 'access-user-a', + refresh: 22, + }, + ], + }); await upsertGeneralAccess(db, { + worldStateId, + year: 185, + month: 4, generalId, userId: 'access-user-b', - now: new Date('2026-07-27T00:05:00.000Z'), - dayStartedAt: new Date('2026-07-27T00:00:00.000Z'), - scoreStartedAt: new Date('2026-07-27T00:00:00.000Z'), + now: new Date('2026-07-26T03:15:00.000Z'), + periodStartedAt: new Date('2026-07-26T03:10:00.000Z'), + scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'), weight: 1, }); @@ -67,5 +115,89 @@ integration('general access tracking persistence', () => { refreshScore: 1, refreshScoreTotal: 23, }); + + await upsertGeneralAccess(db, { + worldStateId, + year: 185, + month: 4, + generalId: secondGeneralId, + userId: 'access-user-c', + now: new Date('2026-07-26T03:15:01.000Z'), + periodStartedAt: new Date('2026-07-26T03:10:00.000Z'), + scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'), + weight: 1, + }); + await expect( + db.trafficPeriod.findUniqueOrThrow({ + where: { + worldStateId_year_month: { + worldStateId, + year: 185, + month: 4, + }, + }, + include: { generals: { orderBy: { generalId: 'asc' } } }, + }) + ).resolves.toMatchObject({ + refresh: 2, + online: 2, + generals: [ + { + generalId, + userId: 'access-user-b', + refresh: 1, + }, + { + generalId: secondGeneralId, + userId: 'access-user-c', + refresh: 1, + }, + ], + }); + }); + + it('rolls back the period and member rows when the legacy access update fails', async () => { + await db.generalAccessLog.create({ + data: { + generalId: rollbackGeneralId, + userId: 'rollback-user', + lastRefresh: new Date('2026-07-26T03:19:00.000Z'), + refresh: 1, + refreshTotal: 2_147_483_647, + refreshScore: 1, + refreshScoreTotal: 1, + }, + }); + + await expect( + upsertGeneralAccess(db, { + worldStateId, + year: 186, + month: 1, + generalId: rollbackGeneralId, + userId: 'rollback-user', + now: new Date('2026-07-26T03:20:00.000Z'), + periodStartedAt: new Date('2026-07-26T03:20:00.000Z'), + scoreStartedAt: new Date('2026-07-26T03:20:00.000Z'), + weight: 1, + }) + ).rejects.toThrow(); + + await expect( + db.trafficPeriod.findUnique({ + where: { + worldStateId_year_month: { + worldStateId, + year: 186, + month: 1, + }, + }, + }) + ).resolves.toBeNull(); + await expect( + db.generalAccessLog.findUniqueOrThrow({ where: { generalId: rollbackGeneralId } }) + ).resolves.toMatchObject({ + refreshTotal: 2_147_483_647, + }); }); }); diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts index bd50047..305b022 100644 --- a/app/game-api/test/generalAccessTracking.test.ts +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -21,8 +21,17 @@ const auth = (roles = ['user']): GameSessionTokenPayload => ({ const buildDb = (meta: Record = {}) => { const executeRaw = vi.fn(async (_query: unknown) => 1); + const queryRaw = vi.fn(async (_query: unknown) => [{ id: 41 }]); + const transaction = vi.fn( + async ( + callback: (client: { $executeRaw: typeof executeRaw; $queryRaw: typeof queryRaw }) => Promise + ) => callback({ $executeRaw: executeRaw, $queryRaw: queryRaw }) + ); const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' })); const findWorld = vi.fn(async () => ({ + id: 3, + currentYear: 185, + currentMonth: 4, tickSeconds: 600, meta: { opentime: '2026-07-25T00:00:00.000Z', @@ -31,11 +40,11 @@ const buildDb = (meta: Record = {}) => { }, })); const db = { - $executeRaw: executeRaw, + $transaction: transaction, general: { findFirst: findGeneral }, worldState: { findFirst: findWorld }, } as unknown as DatabaseClient; - return { db, executeRaw, findGeneral, findWorld }; + return { db, executeRaw, queryRaw, transaction, findGeneral, findWorld }; }; describe('general access tracking', () => { @@ -44,19 +53,19 @@ describe('general access tracking', () => { expect(accessPageWeights['general-list']).toBe(2); }); - it('resolves the UTC day and latest processed turn windows', () => { + it('uses the latest processed game turn as the traffic period and score window', () => { expect( resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, { lastTurnTime: '2026-07-26T03:10:00.000Z', }) ).toEqual({ - dayStartedAt: new Date('2026-07-26T00:00:00.000Z'), + periodStartedAt: new Date('2026-07-26T03:10:00.000Z'), scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'), }); }); it('uses the session user actor and the legacy page weight in one atomic upsert', async () => { - const { db, executeRaw, findGeneral } = buildDb(); + const { db, executeRaw, queryRaw, transaction, findGeneral } = buildDb(); const now = new Date('2026-07-26T03:05:00.000Z'); await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true); @@ -65,22 +74,28 @@ describe('general access tracking', () => { orderBy: { id: 'asc' }, select: { id: true, userId: true }, }); - expect(executeRaw).toHaveBeenCalledTimes(1); + expect(transaction).toHaveBeenCalledTimes(1); + expect(queryRaw).toHaveBeenCalledTimes(1); + expect(executeRaw).toHaveBeenCalledTimes(2); - const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; - expect(statement.sql).toContain('ON CONFLICT (general_id) DO UPDATE'); - expect(statement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh'); - expect(statement.values).toEqual([ - 7, - 'user-7', - now, - 2, - 2, - 2, - 2, - new Date('2026-07-26T00:00:00.000Z'), - new Date('2026-07-26T03:00:00.000Z'), - ]); + const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; + expect(periodStatement.sql).toContain('INSERT INTO traffic_period'); + 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]); + + const memberStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; + expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general'); + expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING'); + expect(memberStatement.sql).toContain('SET online = traffic_period.online'); + + const accessStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] }; + expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE'); + expect(accessStatement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh'); + expect(accessStatement.values).toContain(7); + expect(accessStatement.values).toContain('user-7'); + expect(accessStatement.values).toContain(2); + expect(accessStatement.values).toContain(now); + expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z')); }); it('does not write for anonymous/admin users, a future opening, or a finished world', async () => { @@ -96,10 +111,10 @@ describe('general access tracking', () => { await expect( recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z')) ).resolves.toBe(false); - expect(future.executeRaw).not.toHaveBeenCalled(); + expect(future.transaction).not.toHaveBeenCalled(); const united = buildDb({ isUnited: 2 }); await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false); - expect(united.executeRaw).not.toHaveBeenCalled(); + expect(united.transaction).not.toHaveBeenCalled(); }); }); diff --git a/app/game-api/test/publicTraffic.test.ts b/app/game-api/test/publicTraffic.test.ts index 78008d8..4e98cc5 100644 --- a/app/game-api/test/publicTraffic.test.ts +++ b/app/game-api/test/publicTraffic.test.ts @@ -44,17 +44,50 @@ const buildContext = (): GameApiContext => { generalAccessLog: { aggregate: async () => ({ _sum: { - refresh: 12, refreshScoreTotal: 21, }, }), - count: async (args: { where: { lastRefresh: { gte: Date } } }) => { - expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z')); - return 2; - }, findMany: async () => [ - { generalId: 7, refresh: 9, refreshScoreTotal: 15 }, - { generalId: 8, refresh: 3, refreshScoreTotal: 6 }, + { generalId: 7, refreshScoreTotal: 15 }, + { generalId: 8, refreshScoreTotal: 6 }, + ], + }, + trafficPeriod: { + findUnique: async () => ({ + id: 12, + worldStateId: 1, + year: 185, + month: 3, + startedAt: new Date('2026-07-26T03:00:00.000Z'), + lastRefresh: new Date('2026-07-26T03:05:00.000Z'), + refresh: 12, + online: 0, + _count: { generals: 2 }, + }), + findMany: async () => [ + { + id: 11, + worldStateId: 1, + year: 185, + month: 2, + startedAt: new Date('2026-07-26T02:50:00.000Z'), + lastRefresh: new Date('2026-07-26T02:50:00.000Z'), + refresh: 30, + online: 5, + _count: { generals: 0 }, + }, + ], + aggregate: async () => ({ + _max: { + refresh: 30, + online: 5, + }, + }), + }, + trafficPeriodGeneral: { + findMany: async () => [ + { generalId: 7, refresh: 9 }, + { generalId: 8, refresh: 3 }, ], }, general: { @@ -95,13 +128,14 @@ describe('public.getTraffic', () => { month: 2, refresh: 30, online: 5, - date: '2026-07-26 02:50:00', + date: '2026-07-26T02:50:00.000Z', }); expect(result.history[1]).toMatchObject({ year: 185, month: 3, refresh: 12, online: 2, + date: '2026-07-26T03:05:00.000Z', }); expect(result.maxRefresh).toBe(30); expect(result.maxOnline).toBe(5); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index e4b3e4f..308c006 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -97,6 +97,8 @@ model WorldState { meta Json @default(dbgenerated("'{}'::jsonb")) updatedAt DateTime @updatedAt @map("updated_at") + trafficPeriods TrafficPeriod[] + @@map("world_state") } @@ -204,6 +206,38 @@ model GeneralAccessLog { @@map("general_access_log") } +model TrafficPeriod { + id Int @id @default(autoincrement()) + worldStateId Int @map("world_state_id") + year Int + month Int + startedAt DateTime @map("started_at") + lastRefresh DateTime @map("last_refresh") + refresh Int @default(0) + online Int @default(0) + + worldState WorldState @relation(fields: [worldStateId], references: [id], onDelete: Cascade) + generals TrafficPeriodGeneral[] + + @@unique([worldStateId, year, month]) + @@index([worldStateId, startedAt]) + @@map("traffic_period") +} + +model TrafficPeriodGeneral { + periodId Int @map("period_id") + generalId Int @map("general_id") + userId String? @map("user_id") + refresh Int @default(0) + lastRefresh DateTime @map("last_refresh") + + period TrafficPeriod @relation(fields: [periodId], references: [id], onDelete: Cascade) + + @@id([periodId, generalId]) + @@index([periodId, refresh, generalId]) + @@map("traffic_period_general") +} + model MessageReadState { generalId Int @id @map("general_id") latestPrivateMessage Int @default(0) @map("latest_private_message") diff --git a/packages/infra/prisma/migrations/20260727010000_add_traffic_period_projection/migration.sql b/packages/infra/prisma/migrations/20260727010000_add_traffic_period_projection/migration.sql new file mode 100644 index 0000000..9b26ecd --- /dev/null +++ b/packages/infra/prisma/migrations/20260727010000_add_traffic_period_projection/migration.sql @@ -0,0 +1,111 @@ +CREATE TABLE "traffic_period" ( + "id" SERIAL PRIMARY KEY, + "world_state_id" INTEGER NOT NULL, + "year" INTEGER NOT NULL, + "month" INTEGER NOT NULL, + "started_at" TIMESTAMP(3) NOT NULL, + "last_refresh" TIMESTAMP(3) NOT NULL, + "refresh" INTEGER NOT NULL DEFAULT 0, + "online" INTEGER NOT NULL DEFAULT 0, + CONSTRAINT "traffic_period_world_state_id_fkey" + FOREIGN KEY ("world_state_id") REFERENCES "world_state"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "traffic_period_month_check" CHECK ("month" BETWEEN 1 AND 12), + CONSTRAINT "traffic_period_refresh_check" CHECK ("refresh" >= 0), + CONSTRAINT "traffic_period_online_check" CHECK ("online" >= 0) +); + +CREATE UNIQUE INDEX "traffic_period_world_state_year_month_key" + ON "traffic_period"("world_state_id", "year", "month"); +CREATE INDEX "traffic_period_world_state_started_at_idx" + ON "traffic_period"("world_state_id", "started_at"); + +CREATE TABLE "traffic_period_general" ( + "period_id" INTEGER NOT NULL, + "general_id" INTEGER NOT NULL, + "user_id" TEXT, + "refresh" INTEGER NOT NULL DEFAULT 0, + "last_refresh" TIMESTAMP(3) NOT NULL, + CONSTRAINT "traffic_period_general_pkey" PRIMARY KEY ("period_id", "general_id"), + CONSTRAINT "traffic_period_general_period_id_fkey" + FOREIGN KEY ("period_id") REFERENCES "traffic_period"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "traffic_period_general_refresh_check" CHECK ("refresh" >= 0) +); + +CREATE INDEX "traffic_period_general_period_refresh_general_idx" + ON "traffic_period_general"("period_id", "refresh", "general_id"); + +-- Preserve legacy completed-period snapshots. Invalid or missing legacy dates use +-- the world row timestamp so one malformed JSON item cannot block deployment. +WITH legacy_rows AS ( + SELECT + world."id" AS "world_state_id", + item, + ordinality + FROM "world_state" AS world + CROSS JOIN LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(world."meta"->'recentTraffic') = 'array' + THEN world."meta"->'recentTraffic' + ELSE '[]'::jsonb + END + ) WITH ORDINALITY AS legacy(item, ordinality) +), +normalized AS ( + SELECT + legacy_rows."world_state_id", + CASE + WHEN pg_input_is_valid(legacy_rows.item->>'year', 'integer') + THEN (legacy_rows.item->>'year')::INTEGER + ELSE NULL + END AS "year", + CASE + WHEN pg_input_is_valid(legacy_rows.item->>'month', 'integer') + THEN (legacy_rows.item->>'month')::INTEGER + ELSE NULL + END AS "month", + CASE + WHEN pg_input_is_valid(legacy_rows.item->>'refresh', 'integer') + THEN (legacy_rows.item->>'refresh')::INTEGER + ELSE 0 + END AS "refresh", + CASE + WHEN pg_input_is_valid(legacy_rows.item->>'online', 'integer') + THEN (legacy_rows.item->>'online')::INTEGER + ELSE 0 + END AS "online", + CASE + WHEN pg_input_is_valid( + legacy_rows.item->>'date', + 'timestamp without time zone' + ) + THEN (legacy_rows.item->>'date')::TIMESTAMP + ELSE world."updated_at" + END AS "observed_at", + legacy_rows.ordinality + FROM legacy_rows + JOIN "world_state" AS world ON world."id" = legacy_rows."world_state_id" +) +INSERT INTO "traffic_period" ( + "world_state_id", + "year", + "month", + "started_at", + "last_refresh", + "refresh", + "online" +) +SELECT DISTINCT ON ("world_state_id", "year", "month") + "world_state_id", + "year", + "month", + "observed_at", + "observed_at", + "refresh", + "online" +FROM normalized +WHERE "year" IS NOT NULL + AND "month" BETWEEN 1 AND 12 +ORDER BY "world_state_id", "year", "month", ordinality DESC +ON CONFLICT ("world_state_id", "year", "month") DO NOTHING; diff --git a/packages/infra/prisma/migrations/README.md b/packages/infra/prisma/migrations/README.md index 0d2387b..ffc004c 100644 --- a/packages/infra/prisma/migrations/README.md +++ b/packages/infra/prisma/migrations/README.md @@ -33,6 +33,8 @@ pnpm --filter @sammo-ts/infra prisma:migrate:status:game - `nation.chief_general_id` 존재 - `city.trade` nullable, `city.trust` REAL - `auction_bid.meta` JSONB, NOT NULL +- `traffic_period`, `traffic_period_general` 존재 및 + `(world_state_id, year, month)`/`(period_id, general_id)` unique key 검증 뒤에는 이름을 확인한 임시 database와 role만 제거한다. 공유 개발 database나 Compose volume을 삭제하지 않는다. diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index 985a238..808fa02 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -7,6 +7,8 @@ export interface DatabaseClient { worldState: GamePrisma.WorldStateDelegate; general: GamePrisma.GeneralDelegate; generalAccessLog: GamePrisma.GeneralAccessLogDelegate; + trafficPeriod: GamePrisma.TrafficPeriodDelegate; + trafficPeriodGeneral: GamePrisma.TrafficPeriodGeneralDelegate; messageReadState: GamePrisma.MessageReadStateDelegate; message: GamePrisma.MessageDelegate; city: GamePrisma.CityDelegate;