Track traffic by game month

This commit is contained in:
2026-07-27 11:32:48 +00:00
parent 9f402a3d67
commit 2b15410c5c
9 changed files with 595 additions and 146 deletions
+92 -61
View File
@@ -173,26 +173,6 @@ const readFiniteMetaNumber = (meta: Record<string, unknown>, key: string): numbe
return typeof value === 'number' && Number.isFinite(value) ? value : 0; 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 => { const compareString = (left: string, right: string): number => {
if (left === right) { if (left === right) {
return 0; return 0;
@@ -298,69 +278,120 @@ export const publicRouter = router({
} }
const meta = asRecord(worldState.meta); const meta = asRecord(worldState.meta);
const rawOnlineSince = meta.lastTurnTime ?? meta.turntime; const [currentPeriod, previousPeriods, periodMaximums, accessTotal] = await Promise.all([
const parsedOnlineSince = ctx.db.trafficPeriod.findUnique({
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({
where: { where: {
lastRefresh: { worldStateId_year_month: {
gte: onlineSince, worldStateId: worldState.id,
year: worldState.currentYear,
month: worldState.currentMonth,
},
},
include: {
_count: {
select: { generals: true },
}, },
}, },
}), }),
ctx.db.generalAccessLog.findMany({ ctx.db.trafficPeriod.findMany({
orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }], where: {
worldStateId: worldState.id,
NOT: {
year: worldState.currentYear,
month: worldState.currentMonth,
},
},
orderBy: [{ startedAt: 'desc' }, { id: 'desc' }],
take: 5, take: 5,
select: { include: {
generalId: true, _count: {
select: { generals: true },
},
},
}),
ctx.db.trafficPeriod.aggregate({
where: { worldStateId: worldState.id },
_max: {
refresh: true, refresh: true,
online: true,
},
}),
ctx.db.generalAccessLog.aggregate({
_sum: {
refreshScoreTotal: true, 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 generalIds = topAccess.map((entry) => entry.generalId);
const generalRows = const [generalRows, generalAccessRows] =
generalIds.length > 0 generalIds.length > 0
? await ctx.db.general.findMany({ ? await Promise.all([
where: { id: { in: generalIds } }, ctx.db.general.findMany({
select: { id: true, name: true }, 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 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 totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0;
const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh); const currentOnline = currentPeriod ? Math.max(currentPeriod.online, currentPeriod._count.generals) : 0;
const history = parseTrafficHistory(meta.recentTraffic); const history: TrafficHistoryItem[] = previousPeriods.reverse().map((period) => ({
history.push({ year: period.year,
year: worldState.currentYear, month: period.month,
month: worldState.currentMonth, refresh: period.refresh,
refresh: currentRefresh, online: Math.max(period.online, period._count.generals),
online: currentOnline, date: period.lastRefresh.toISOString(),
date: new Date().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 { return {
history, history,
maxRefresh: Math.max( maxRefresh: Math.max(
1, 1,
readFiniteMetaNumber(meta, 'maxrefresh'), readFiniteMetaNumber(meta, 'maxrefresh'),
periodMaximums._max.refresh ?? 0,
...history.map((entry) => entry.refresh) ...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: [ suspects: [
{ {
generalId: null, generalId: null,
@@ -372,7 +403,7 @@ export const publicRouter = router({
generalId: entry.generalId, generalId: entry.generalId,
name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`, name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`,
refresh: entry.refresh, refresh: entry.refresh,
refreshScoreTotal: entry.refreshScoreTotal, refreshScoreTotal: accessScore.get(entry.generalId) ?? 0,
})), })),
], ],
}; };
+136 -48
View File
@@ -76,8 +76,7 @@ export const resolveAccessWindows = (
now: Date, now: Date,
tickSeconds: number, tickSeconds: number,
worldMeta: unknown worldMeta: unknown
): { dayStartedAt: Date; scoreStartedAt: Date } => { ): { periodStartedAt: Date; scoreStartedAt: Date } => {
const dayStartedAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const meta = asRecord(worldMeta); const meta = asRecord(worldMeta);
const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime); const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime);
const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000; const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000;
@@ -85,60 +84,140 @@ export const resolveAccessWindows = (
tickStartedAt && tickStartedAt.getTime() <= now.getTime() tickStartedAt && tickStartedAt.getTime() <= now.getTime()
? tickStartedAt ? tickStartedAt
: new Date(now.getTime() - fallbackTickMs); : new Date(now.getTime() - fallbackTickMs);
return { dayStartedAt, scoreStartedAt }; return { periodStartedAt: scoreStartedAt, scoreStartedAt };
}; };
export const upsertGeneralAccess = async ( export const upsertGeneralAccess = async (
db: Pick<GameApiContext['db'], '$executeRaw'>, db: Pick<GameApiContext['db'], '$transaction'>,
input: { input: {
worldStateId: number;
year: number;
month: number;
generalId: number; generalId: number;
userId: string; userId: string;
weight: number; weight: number;
now: Date; now: Date;
dayStartedAt: Date; periodStartedAt: Date;
scoreStartedAt: Date; scoreStartedAt: Date;
} }
): Promise<void> => { ): Promise<void> => {
await db.$executeRaw( if (!db.$transaction) {
GamePrisma.sql` throw new Error('Traffic access persistence requires transaction support.');
INSERT INTO general_access_log ( }
general_id, await db.$transaction(async (transaction) => {
user_id, const periodRows = await transaction.$queryRaw<Array<{ id: number }>>(
last_refresh, GamePrisma.sql`
refresh, INSERT INTO traffic_period (
refresh_total, world_state_id,
refresh_score, year,
refresh_score_total month,
) started_at,
VALUES ( last_refresh,
${input.generalId}, refresh
${input.userId}, )
${input.now}, VALUES (
${input.weight}, ${input.worldStateId},
${input.weight}, ${input.year},
${input.weight}, ${input.month},
${input.weight} ${input.periodStartedAt},
) ${input.now},
ON CONFLICT (general_id) DO UPDATE SET ${input.weight}
user_id = EXCLUDED.user_id, )
last_refresh = EXCLUDED.last_refresh, ON CONFLICT (world_state_id, year, month) DO UPDATE SET
refresh = CASE started_at = LEAST(traffic_period.started_at, EXCLUDED.started_at),
WHEN general_access_log.last_refresh IS NULL last_refresh = GREATEST(traffic_period.last_refresh, EXCLUDED.last_refresh),
OR general_access_log.last_refresh < ${input.dayStartedAt} refresh = traffic_period.refresh + EXCLUDED.refresh
THEN EXCLUDED.refresh RETURNING id
ELSE general_access_log.refresh + EXCLUDED.refresh `
END, );
refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total, const periodId = periodRows[0]?.id;
refresh_score = CASE if (periodId === undefined) {
WHEN general_access_log.last_refresh IS NULL throw new Error('Failed to resolve the traffic period.');
OR general_access_log.last_refresh < ${input.scoreStartedAt} }
THEN EXCLUDED.refresh_score
ELSE general_access_log.refresh_score + EXCLUDED.refresh_score await transaction.$executeRaw(
END, GamePrisma.sql`
refresh_score_total = WITH inserted_general AS (
general_access_log.refresh_score_total + EXCLUDED.refresh_score_total 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 ( export const recordGeneralAccess = async (
@@ -159,7 +238,13 @@ export const recordGeneralAccess = async (
}), }),
ctx.db.worldState.findFirst({ ctx.db.worldState.findFirst({
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { tickSeconds: true, meta: true }, select: {
id: true,
currentYear: true,
currentMonth: true,
tickSeconds: true,
meta: true,
},
}), }),
]); ]);
if (!general || !worldState) { if (!general || !worldState) {
@@ -174,14 +259,17 @@ export const recordGeneralAccess = async (
} }
const weight = accessPageWeights[page]; const weight = accessPageWeights[page];
const { dayStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta); const { periodStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
await upsertGeneralAccess(ctx.db, { await upsertGeneralAccess(ctx.db, {
worldStateId: worldState.id,
year: worldState.currentYear,
month: worldState.currentMonth,
generalId: general.id, generalId: general.id,
userId: user.id, userId: user.id,
weight, weight,
now, now,
dayStartedAt, periodStartedAt,
scoreStartedAt, scoreStartedAt,
}); });
return true; return true;
@@ -6,30 +6,54 @@ import { upsertGeneralAccess } from '../src/services/generalAccess.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl); const integration = describe.skipIf(!databaseUrl);
const generalId = 9_980_071; const generalId = 9_980_071;
const secondGeneralId = generalId + 1;
const rollbackGeneralId = generalId + 2;
const scenarioCode = `traffic-period-${generalId}`;
integration('general access tracking persistence', () => { integration('general access tracking persistence', () => {
let db: GamePrismaClient; let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined; let closeDb: (() => Promise<void>) | undefined;
let worldStateId: number;
beforeAll(async () => { beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! }); const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect(); await connector.connect();
db = connector.prisma; db = connector.prisma;
closeDb = () => connector.disconnect(); 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 () => { 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?.(); 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 = { const firstWindow = {
worldStateId,
year: 185,
month: 3,
generalId, generalId,
userId: 'access-user-a', userId: 'access-user-a',
now: new Date('2026-07-26T03:05:00.000Z'), 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'), scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
}; };
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 }); await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
@@ -50,13 +74,37 @@ integration('general access tracking persistence', () => {
refreshScore: 22, refreshScore: 22,
refreshScoreTotal: 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, { await upsertGeneralAccess(db, {
worldStateId,
year: 185,
month: 4,
generalId, generalId,
userId: 'access-user-b', userId: 'access-user-b',
now: new Date('2026-07-27T00:05:00.000Z'), now: new Date('2026-07-26T03:15:00.000Z'),
dayStartedAt: new Date('2026-07-27T00:00:00.000Z'), periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
scoreStartedAt: new Date('2026-07-27T00:00:00.000Z'), scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
weight: 1, weight: 1,
}); });
@@ -67,5 +115,89 @@ integration('general access tracking persistence', () => {
refreshScore: 1, refreshScore: 1,
refreshScoreTotal: 23, 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,
});
}); });
}); });
+37 -22
View File
@@ -21,8 +21,17 @@ const auth = (roles = ['user']): GameSessionTokenPayload => ({
const buildDb = (meta: Record<string, unknown> = {}) => { const buildDb = (meta: Record<string, unknown> = {}) => {
const executeRaw = vi.fn(async (_query: unknown) => 1); 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<unknown>
) => callback({ $executeRaw: executeRaw, $queryRaw: queryRaw })
);
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' })); const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
const findWorld = vi.fn(async () => ({ const findWorld = vi.fn(async () => ({
id: 3,
currentYear: 185,
currentMonth: 4,
tickSeconds: 600, tickSeconds: 600,
meta: { meta: {
opentime: '2026-07-25T00:00:00.000Z', opentime: '2026-07-25T00:00:00.000Z',
@@ -31,11 +40,11 @@ const buildDb = (meta: Record<string, unknown> = {}) => {
}, },
})); }));
const db = { const db = {
$executeRaw: executeRaw, $transaction: transaction,
general: { findFirst: findGeneral }, general: { findFirst: findGeneral },
worldState: { findFirst: findWorld }, worldState: { findFirst: findWorld },
} as unknown as DatabaseClient; } as unknown as DatabaseClient;
return { db, executeRaw, findGeneral, findWorld }; return { db, executeRaw, queryRaw, transaction, findGeneral, findWorld };
}; };
describe('general access tracking', () => { describe('general access tracking', () => {
@@ -44,19 +53,19 @@ describe('general access tracking', () => {
expect(accessPageWeights['general-list']).toBe(2); 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( expect(
resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, { resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, {
lastTurnTime: '2026-07-26T03:10:00.000Z', lastTurnTime: '2026-07-26T03:10:00.000Z',
}) })
).toEqual({ ).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'), 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 () => { 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'); const now = new Date('2026-07-26T03:05:00.000Z');
await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true); await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true);
@@ -65,22 +74,28 @@ describe('general access tracking', () => {
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { id: true, userId: true }, 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[] }; const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
expect(statement.sql).toContain('ON CONFLICT (general_id) DO UPDATE'); expect(periodStatement.sql).toContain('INSERT INTO traffic_period');
expect(statement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh'); expect(periodStatement.sql).toContain('ON CONFLICT (world_state_id, year, month) DO UPDATE');
expect(statement.values).toEqual([ expect(periodStatement.values).toEqual([3, 185, 4, new Date('2026-07-26T03:00:00.000Z'), now, 2]);
7,
'user-7', const memberStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
now, expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general');
2, expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING');
2, expect(memberStatement.sql).toContain('SET online = traffic_period.online');
2,
2, const accessStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
new Date('2026-07-26T00:00:00.000Z'), expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
new Date('2026-07-26T03:00:00.000Z'), 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 () => { 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( await expect(
recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z')) recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z'))
).resolves.toBe(false); ).resolves.toBe(false);
expect(future.executeRaw).not.toHaveBeenCalled(); expect(future.transaction).not.toHaveBeenCalled();
const united = buildDb({ isUnited: 2 }); const united = buildDb({ isUnited: 2 });
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false); await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
expect(united.executeRaw).not.toHaveBeenCalled(); expect(united.transaction).not.toHaveBeenCalled();
}); });
}); });
+42 -8
View File
@@ -44,17 +44,50 @@ const buildContext = (): GameApiContext => {
generalAccessLog: { generalAccessLog: {
aggregate: async () => ({ aggregate: async () => ({
_sum: { _sum: {
refresh: 12,
refreshScoreTotal: 21, 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 () => [ findMany: async () => [
{ generalId: 7, refresh: 9, refreshScoreTotal: 15 }, { generalId: 7, refreshScoreTotal: 15 },
{ generalId: 8, refresh: 3, refreshScoreTotal: 6 }, { 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: { general: {
@@ -95,13 +128,14 @@ describe('public.getTraffic', () => {
month: 2, month: 2,
refresh: 30, refresh: 30,
online: 5, online: 5,
date: '2026-07-26 02:50:00', date: '2026-07-26T02:50:00.000Z',
}); });
expect(result.history[1]).toMatchObject({ expect(result.history[1]).toMatchObject({
year: 185, year: 185,
month: 3, month: 3,
refresh: 12, refresh: 12,
online: 2, online: 2,
date: '2026-07-26T03:05:00.000Z',
}); });
expect(result.maxRefresh).toBe(30); expect(result.maxRefresh).toBe(30);
expect(result.maxOnline).toBe(5); expect(result.maxOnline).toBe(5);
+34
View File
@@ -97,6 +97,8 @@ model WorldState {
meta Json @default(dbgenerated("'{}'::jsonb")) meta Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
trafficPeriods TrafficPeriod[]
@@map("world_state") @@map("world_state")
} }
@@ -204,6 +206,38 @@ model GeneralAccessLog {
@@map("general_access_log") @@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 { model MessageReadState {
generalId Int @id @map("general_id") generalId Int @id @map("general_id")
latestPrivateMessage Int @default(0) @map("latest_private_message") latestPrivateMessage Int @default(0) @map("latest_private_message")
@@ -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;
@@ -33,6 +33,8 @@ pnpm --filter @sammo-ts/infra prisma:migrate:status:game
- `nation.chief_general_id` 존재 - `nation.chief_general_id` 존재
- `city.trade` nullable, `city.trust` REAL - `city.trade` nullable, `city.trust` REAL
- `auction_bid.meta` JSONB, NOT NULL - `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와 role만 제거한다. 공유 개발
database나 Compose volume을 삭제하지 않는다. database나 Compose volume을 삭제하지 않는다.
+2
View File
@@ -7,6 +7,8 @@ export interface DatabaseClient {
worldState: GamePrisma.WorldStateDelegate; worldState: GamePrisma.WorldStateDelegate;
general: GamePrisma.GeneralDelegate; general: GamePrisma.GeneralDelegate;
generalAccessLog: GamePrisma.GeneralAccessLogDelegate; generalAccessLog: GamePrisma.GeneralAccessLogDelegate;
trafficPeriod: GamePrisma.TrafficPeriodDelegate;
trafficPeriodGeneral: GamePrisma.TrafficPeriodGeneralDelegate;
messageReadState: GamePrisma.MessageReadStateDelegate; messageReadState: GamePrisma.MessageReadStateDelegate;
message: GamePrisma.MessageDelegate; message: GamePrisma.MessageDelegate;
city: GamePrisma.CityDelegate; city: GamePrisma.CityDelegate;