Merge branch 'main' into feature/best-general-data-parity
This commit is contained in:
@@ -55,7 +55,7 @@ export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}):
|
||||
continue;
|
||||
}
|
||||
|
||||
let job: BattleSimJob | null = null;
|
||||
let job: BattleSimJob;
|
||||
try {
|
||||
job = JSON.parse(raw) as BattleSimJob;
|
||||
} catch {
|
||||
|
||||
@@ -2,14 +2,12 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||
|
||||
const zLetterState = z.enum(['PROPOSED', 'ACTIVATED', 'CANCELLED', 'REPLACED']);
|
||||
|
||||
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: nationId },
|
||||
@@ -22,7 +20,7 @@ const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], n
|
||||
return resolveNationPermission(general, nation.meta, true);
|
||||
};
|
||||
|
||||
const mapLetterState = (state: string): z.infer<typeof zLetterState> => {
|
||||
const mapLetterState = (state: string): 'PROPOSED' | 'ACTIVATED' | 'CANCELLED' | 'REPLACED' => {
|
||||
if (state === 'ACTIVATED') return 'ACTIVATED';
|
||||
if (state === 'CANCELLED') return 'CANCELLED';
|
||||
if (state === 'REPLACED') return 'REPLACED';
|
||||
@@ -153,7 +151,10 @@ export const diplomacyRouter = router({
|
||||
select: { id: true },
|
||||
});
|
||||
if (newer) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '해당 문서에 대한 새로운 문서가 이미 있습니다.' });
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '해당 문서에 대한 새로운 문서가 이미 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (prevLetter.state === 'PROPOSED') {
|
||||
@@ -169,7 +170,8 @@ export const diplomacyRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
||||
destNationId =
|
||||
prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
||||
}
|
||||
|
||||
const nations = await ctx.db.nation.findMany({
|
||||
@@ -372,4 +374,4 @@ export const diplomacyRouter = router({
|
||||
});
|
||||
return { state: 'ACTIVATED' };
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||
import { loadPublicMap } from '../../maps/worldMap.js';
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js';
|
||||
import { procedure, router, sessionActivityProcedure } from '../../trpc.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
type WorldTrendSnapshot = {
|
||||
year: number;
|
||||
@@ -231,6 +232,11 @@ const sortNpcList = <T extends {
|
||||
});
|
||||
|
||||
export const publicRouter = router({
|
||||
recordAccess: sessionActivityProcedure
|
||||
.input(z.object({ page: z.enum(accessPages) }))
|
||||
.mutation(async ({ ctx, input }) => ({
|
||||
recorded: await recordGeneralAccess(ctx, input.page),
|
||||
})),
|
||||
getMapLayout: procedure.query(async ({ ctx }) => {
|
||||
return loadMapLayout(ctx.profile.scenario);
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { GameApiContext } from '../context.js';
|
||||
|
||||
export const accessPages = [
|
||||
'front-info',
|
||||
'nation-info',
|
||||
'nation-cities',
|
||||
'global-info',
|
||||
'current-city',
|
||||
'diplomacy',
|
||||
'nation-generals',
|
||||
'nation-personnel',
|
||||
'nation-finance',
|
||||
'battle-center',
|
||||
'board',
|
||||
'best-general',
|
||||
'hall-of-fame',
|
||||
'dynasty',
|
||||
'yearbook',
|
||||
'nation-betting',
|
||||
'traffic',
|
||||
'npc-list',
|
||||
'my-page',
|
||||
'npc-control',
|
||||
'tournament',
|
||||
'betting',
|
||||
] as const;
|
||||
|
||||
export type AccessPage = (typeof accessPages)[number];
|
||||
|
||||
export const accessPageWeights: Record<AccessPage, number> = {
|
||||
'front-info': 1,
|
||||
'nation-info': 1,
|
||||
'nation-cities': 1,
|
||||
'global-info': 1,
|
||||
'current-city': 1,
|
||||
diplomacy: 1,
|
||||
'nation-generals': 1,
|
||||
'nation-personnel': 1,
|
||||
'nation-finance': 1,
|
||||
'battle-center': 1,
|
||||
board: 1,
|
||||
'best-general': 1,
|
||||
'hall-of-fame': 1,
|
||||
dynasty: 1,
|
||||
yearbook: 1,
|
||||
'nation-betting': 1,
|
||||
traffic: 1,
|
||||
'npc-list': 2,
|
||||
'my-page': 1,
|
||||
'npc-control': 1,
|
||||
tournament: 1,
|
||||
betting: 1,
|
||||
};
|
||||
|
||||
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
|
||||
|
||||
const readFiniteNumber = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const readDate = (value: unknown): Date | null => {
|
||||
if (typeof value !== 'string' && !(value instanceof Date)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
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()));
|
||||
const meta = asRecord(worldMeta);
|
||||
const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime);
|
||||
const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000;
|
||||
const scoreStartedAt =
|
||||
tickStartedAt && tickStartedAt.getTime() <= now.getTime()
|
||||
? tickStartedAt
|
||||
: new Date(now.getTime() - fallbackTickMs);
|
||||
return { dayStartedAt, scoreStartedAt };
|
||||
};
|
||||
|
||||
export const upsertGeneralAccess = async (
|
||||
db: Pick<GameApiContext['db'], '$executeRaw'>,
|
||||
input: {
|
||||
generalId: number;
|
||||
userId: string;
|
||||
weight: number;
|
||||
now: Date;
|
||||
dayStartedAt: Date;
|
||||
scoreStartedAt: Date;
|
||||
}
|
||||
): Promise<void> => {
|
||||
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
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
export const recordGeneralAccess = async (
|
||||
ctx: Pick<GameApiContext, 'auth' | 'db'>,
|
||||
page: AccessPage,
|
||||
now = new Date()
|
||||
): Promise<boolean> => {
|
||||
const user = ctx.auth?.user;
|
||||
if (!user || user.roles.some((role) => adminRoles.has(role))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [general, worldState] = await Promise.all([
|
||||
ctx.db.general.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, userId: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { tickSeconds: true, meta: true },
|
||||
}),
|
||||
]);
|
||||
if (!general || !worldState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const meta = asRecord(worldState.meta);
|
||||
const isUnited = readFiniteNumber(meta.isUnited) ?? readFiniteNumber(meta.isunited) ?? 0;
|
||||
const openTime = readDate(meta.opentime);
|
||||
if (isUnited === 2 || (openTime && openTime.getTime() > now.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const weight = accessPageWeights[page];
|
||||
const { dayStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
|
||||
|
||||
await upsertGeneralAccess(ctx.db, {
|
||||
generalId: general.id,
|
||||
userId: user.id,
|
||||
weight,
|
||||
now,
|
||||
dayStartedAt,
|
||||
scoreStartedAt,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
@@ -63,6 +63,10 @@ export const router = t.router;
|
||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
|
||||
|
||||
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
|
||||
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
|
||||
export const sessionActivityProcedure = t.procedure;
|
||||
|
||||
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
||||
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
||||
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
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;
|
||||
|
||||
integration('general access tracking persistence', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('atomically increments concurrent requests and resets only windowed counters', async () => {
|
||||
const firstWindow = {
|
||||
generalId,
|
||||
userId: 'access-user-a',
|
||||
now: new Date('2026-07-26T03:05:00.000Z'),
|
||||
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
|
||||
};
|
||||
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, (_, index) =>
|
||||
upsertGeneralAccess(db, {
|
||||
...firstWindow,
|
||||
now: new Date(firstWindow.now.getTime() + index + 1),
|
||||
weight: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||
userId: 'access-user-a',
|
||||
refresh: 22,
|
||||
refreshTotal: 22,
|
||||
refreshScore: 22,
|
||||
refreshScoreTotal: 22,
|
||||
});
|
||||
|
||||
await upsertGeneralAccess(db, {
|
||||
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'),
|
||||
weight: 1,
|
||||
});
|
||||
|
||||
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||
userId: 'access-user-b',
|
||||
refresh: 1,
|
||||
refreshTotal: 23,
|
||||
refreshScore: 1,
|
||||
refreshScoreTotal: 23,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
|
||||
import { recordGeneralAccess, resolveAccessWindows } from '../src/services/generalAccess.js';
|
||||
|
||||
const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: 'access-session',
|
||||
user: {
|
||||
id: 'user-7',
|
||||
username: 'user7',
|
||||
displayName: '사용자7',
|
||||
roles,
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildDb = (meta: Record<string, unknown> = {}) => {
|
||||
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
||||
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
|
||||
const findWorld = vi.fn(async () => ({
|
||||
tickSeconds: 600,
|
||||
meta: {
|
||||
opentime: '2026-07-25T00:00:00.000Z',
|
||||
lastTurnTime: '2026-07-26T03:00:00.000Z',
|
||||
...meta,
|
||||
},
|
||||
}));
|
||||
const db = {
|
||||
$executeRaw: executeRaw,
|
||||
general: { findFirst: findGeneral },
|
||||
worldState: { findFirst: findWorld },
|
||||
} as unknown as DatabaseClient;
|
||||
return { db, executeRaw, findGeneral, findWorld };
|
||||
};
|
||||
|
||||
describe('general access tracking', () => {
|
||||
it('resolves the UTC day and latest processed turn windows', () => {
|
||||
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'),
|
||||
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 now = new Date('2026-07-26T03:05:00.000Z');
|
||||
|
||||
await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true);
|
||||
expect(findGeneral).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-7' },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
expect(executeRaw).toHaveBeenCalledTimes(1);
|
||||
|
||||
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'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
|
||||
const anonymous = buildDb();
|
||||
await expect(recordGeneralAccess({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false);
|
||||
expect(anonymous.findGeneral).not.toHaveBeenCalled();
|
||||
|
||||
const admin = buildDb();
|
||||
await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, 'traffic')).resolves.toBe(false);
|
||||
expect(admin.findGeneral).not.toHaveBeenCalled();
|
||||
|
||||
const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' });
|
||||
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();
|
||||
|
||||
const united = buildDb({ isUnited: 2 });
|
||||
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
|
||||
expect(united.executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user