diff --git a/app/game-api/src/battleSim/worker.ts b/app/game-api/src/battleSim/worker.ts index 6c5a2ba..7338153 100644 --- a/app/game-api/src/battleSim/worker.ts +++ b/app/game-api/src/battleSim/worker.ts @@ -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 { diff --git a/app/game-api/src/router/diplomacy/index.ts b/app/game-api/src/router/diplomacy/index.ts index 4854dc0..1c9c907 100644 --- a/app/game-api/src/router/diplomacy/index.ts +++ b/app/game-api/src/router/diplomacy/index.ts @@ -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[0], nationId: number) => { const nation = await ctx.db.nation.findUnique({ where: { id: nationId }, @@ -22,7 +20,7 @@ const resolvePermissionLevel = async (ctx: Parameters[0], n return resolveNationPermission(general, nation.meta, true); }; -const mapLetterState = (state: string): z.infer => { +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' }; }), -}); \ No newline at end of file +}); diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index f4ef4d8..cfde31a 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -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 = ({ + recorded: await recordGeneralAccess(ctx, input.page), + })), getMapLayout: procedure.query(async ({ ctx }) => { return loadMapLayout(ctx.profile.scenario); }), diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts new file mode 100644 index 0000000..ecf03f7 --- /dev/null +++ b/app/game-api/src/services/generalAccess.ts @@ -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 = { + '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, + input: { + generalId: number; + userId: string; + weight: number; + now: Date; + dayStartedAt: 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 + ` + ); +}; + +export const recordGeneralAccess = async ( + ctx: Pick, + page: AccessPage, + now = new Date() +): Promise => { + 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; +}; diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 4a3e8dc..880fa42 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -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); diff --git a/app/game-api/test/generalAccessTracking.integration.test.ts b/app/game-api/test/generalAccessTracking.integration.test.ts new file mode 100644 index 0000000..989792f --- /dev/null +++ b/app/game-api/test/generalAccessTracking.integration.test.ts @@ -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) | 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, + }); + }); +}); diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts new file mode 100644 index 0000000..e0c32ae --- /dev/null +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -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 = {}) => { + 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(); + }); +}); diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index 71db076..bfcc84f 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -711,7 +711,7 @@ export class GeneralAI { const leadership = this.general.stats.leadership; const strength = Math.max(this.general.stats.strength, 1); const intel = Math.max(this.general.stats.intelligence, 1); - let genType = 0; + let genType: number; if (strength >= intel) { genType = t무장; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts b/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts index 90b00ad..d2a14dd 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts @@ -38,7 +38,7 @@ export const do부대전방발령 = (ai: GeneralAI) => { const force = ai.nationPolicy.combatForce[leader.id]; let [fromCityId, toCityId] = force; - let targetCityId: number | null = null; + let targetCityId: number | null; if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) { targetCityId = pickRandomCityId(ai, ai.frontCities); } else { diff --git a/app/game-engine/src/turn/incomeHandler.ts b/app/game-engine/src/turn/incomeHandler.ts index 171efe8..ce2cee5 100644 --- a/app/game-engine/src/turn/incomeHandler.ts +++ b/app/game-engine/src/turn/incomeHandler.ts @@ -108,7 +108,7 @@ const applyIncomeOutcome = ( originOutcome: number ): { next: number; ratio: number; realOutcome: number } => { let next = current + income; - let realOutcome = 0; + let realOutcome: number; if (next < baseResource) { realOutcome = 0; next = baseResource; @@ -139,14 +139,11 @@ const processIncomeForNation = ( const trait = traitMap.get(nation.typeCode) ?? null; const incomeContext = buildNationIncomeContext(nation, trait); - let income = 0; - if (type === 'gold') { - income = getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level); - } else { - income = - getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) + - getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level); - } + const income = + type === 'gold' + ? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) + : getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) + + getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level); const incomeValue = roundResource(income); const originOutcome = getOutcome(100, nationGenerals); diff --git a/app/game-engine/src/turn/unificationHandler.ts b/app/game-engine/src/turn/unificationHandler.ts index b0c3626..85a6afb 100644 --- a/app/game-engine/src/turn/unificationHandler.ts +++ b/app/game-engine/src/turn/unificationHandler.ts @@ -167,7 +167,8 @@ export const createUnificationHandler = (options: { sabotage, dex, unifier, - unifierAward: general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0, + unifierAward: + general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0, }, }, }); @@ -194,15 +195,11 @@ export const createUnificationHandler = (options: { const meta = asRecord(state.meta); const serverId = - typeof meta.serverId === 'string' && meta.serverId.trim() - ? meta.serverId.trim() - : options.profileName; + typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName; const season = readMetaNumberOrNull(meta, 'season') ?? 1; const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0; const scenarioName = - typeof asRecord(meta.scenarioMeta).title === 'string' - ? String(asRecord(meta.scenarioMeta).title) - : ''; + typeof asRecord(meta.scenarioMeta).title === 'string' ? String(asRecord(meta.scenarioMeta).title) : ''; const startTime = typeof meta.starttime === 'string' ? meta.starttime : null; const unitedTime = new Date().toISOString(); @@ -307,14 +304,16 @@ export const createUnificationHandler = (options: { }; for (const [typeName, valueType] of hallTypes) { - let value = 0; - if (valueType === 'natural') { - value = typeName === 'experience' ? general.experience : typeName === 'dedication' ? general.dedication : ranks[typeName] ?? 0; - } else if (valueType === 'rank') { - value = ranks[typeName] ?? 0; - } else { - value = calcValues[typeName] ?? 0; - } + const value = + valueType === 'natural' + ? typeName === 'experience' + ? general.experience + : typeName === 'dedication' + ? general.dedication + : (ranks[typeName] ?? 0) + : valueType === 'rank' + ? (ranks[typeName] ?? 0) + : (calcValues[typeName] ?? 0); if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) { continue; @@ -391,9 +390,7 @@ export const createUnificationHandler = (options: { const meta = asRecord(state.meta); const serverId = - typeof meta.serverId === 'string' && meta.serverId.trim() - ? meta.serverId.trim() - : options.profileName; + typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName; const serverName = typeof meta.serverName === 'string' && meta.serverName.trim() ? meta.serverName.trim() @@ -653,30 +650,30 @@ export const createUnificationHandler = (options: { await Promise.all( oldGeneralTargets.map((general) => ((snapshot) => - prisma.oldGeneral.upsert({ - where: { - by_no: { + prisma.oldGeneral.upsert({ + where: { + by_no: { + serverId, + generalNo: general.id, + }, + }, + update: { + owner: general.userId ?? null, + name: general.name, + lastYearMonth: state.currentYear * 100 + state.currentMonth, + turnTime: general.turnTime, + data: snapshot, + }, + create: { serverId, generalNo: general.id, + owner: general.userId ?? null, + name: general.name, + lastYearMonth: state.currentYear * 100 + state.currentMonth, + turnTime: general.turnTime, + data: snapshot, }, - }, - update: { - owner: general.userId ?? null, - name: general.name, - lastYearMonth: state.currentYear * 100 + state.currentMonth, - turnTime: general.turnTime, - data: snapshot, - }, - create: { - serverId, - generalNo: general.id, - owner: general.userId ?? null, - name: general.name, - lastYearMonth: state.currentYear * 100 + state.currentMonth, - turnTime: general.turnTime, - data: snapshot, - }, - }))( { + }))({ ...general, turnTime: general.turnTime.toISOString(), }) diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 42efbb9..8bffaf2 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -315,8 +315,8 @@ async function handleTournamentMatchResult( const attackerG = getRankNumber(attacker, rankKey('g')); const defenderG = getRankNumber(defender, rankKey('g')); - let attackerGDelta = 0; - let defenderGDelta = 0; + let attackerGDelta: number; + let defenderGDelta: number; let attackerW = 0; let attackerD = 0; let attackerL = 0; diff --git a/app/game-engine/test/databaseCommandQueue.integration.test.ts b/app/game-engine/test/databaseCommandQueue.integration.test.ts index 9912dd2..2510111 100644 --- a/app/game-engine/test/databaseCommandQueue.integration.test.ts +++ b/app/game-engine/test/databaseCommandQueue.integration.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; +import { createGamePostgresConnector } from '@sammo-ts/infra'; +import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra'; import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js'; diff --git a/app/game-engine/test/nationTurnCompatibility.test.ts b/app/game-engine/test/nationTurnCompatibility.test.ts index 5608d86..029e3af 100644 --- a/app/game-engine/test/nationTurnCompatibility.test.ts +++ b/app/game-engine/test/nationTurnCompatibility.test.ts @@ -164,7 +164,7 @@ describe('레거시 사령부 턴 실행 호환성', () => { ); }); - it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', () => { + it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', async () => { const updates: Array<{ id: number; patch: Record }> = []; const nations = [ { @@ -188,7 +188,7 @@ describe('레거시 사령부 턴 실행 호환성', () => { }) as never, }); - handler.beforeMonthChanged?.({} as never); + await handler.beforeMonthChanged?.({} as never); expect(updates).toEqual([ { diff --git a/app/game-engine/test/npcNationTechResearch.test.ts b/app/game-engine/test/npcNationTechResearch.test.ts index aa7fc56..a3fc3c3 100644 --- a/app/game-engine/test/npcNationTechResearch.test.ts +++ b/app/game-engine/test/npcNationTechResearch.test.ts @@ -327,5 +327,5 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => { // Nation awards can occur in the same tick and make the general's net // gold delta smaller than the recruitment price. Exact cost scaling is // covered by the unit-set/action contract tests rather than this smoke. - }, 60000); + }, 300_000); }); diff --git a/app/game-engine/test/npcNationUprisingUnification.test.ts b/app/game-engine/test/npcNationUprisingUnification.test.ts index dea8395..214f681 100644 --- a/app/game-engine/test/npcNationUprisingUnification.test.ts +++ b/app/game-engine/test/npcNationUprisingUnification.test.ts @@ -553,5 +553,5 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { } throw error; } - }, 180000); + }, 360_000); }); diff --git a/app/game-engine/vitest.config.ts b/app/game-engine/vitest.config.ts index 400c273..83852b4 100644 --- a/app/game-engine/vitest.config.ts +++ b/app/game-engine/vitest.config.ts @@ -13,5 +13,7 @@ export default defineConfig({ environment: 'node', globals: true, include: ['test/**/*.test.ts'], + maxWorkers: 4, + testTimeout: 10_000, }, }); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 8d7a3f2..6d76291 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -23,6 +23,12 @@ type FixtureState = { permission: 'head' | 'member'; myset: number; settingMutations: Array>; + accessPages: string[]; +}; + +type TrpcRequestPayload = { + json?: Record; + input?: { json?: Record }; }; const myGeneral = (state: FixtureState) => ({ @@ -113,7 +119,7 @@ const battleCenter = (state: FixtureState) => ({ const install = async (page: Page, state: FixtureState) => { await page.addInitScript(() => { - localStorage.setItem('sammo-game-token', 'menu-token'); + localStorage.setItem('sammo-game-token', 'ga_menu-token'); localStorage.setItem('sammo-game-profile', 'che:default'); }); await page.route('**/image/game/**', async (route) => { @@ -130,7 +136,16 @@ const install = async (page: Page, state: FixtureState) => { }); await page.route('**/che/api/trpc/**', async (route) => { const operations = operationNames(route); - const results = operations.map((operation) => { + const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {}; + const requestBody = + rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record) : {}; + const results = operations.map((operation, operationIndex) => { + const rawPayload = + requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined); + const payload = + rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined; + const jsonInput = + payload?.json ?? payload?.input?.json ?? (payload as Record | undefined) ?? {}; if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } }); if (operation === 'join.getConfig') return response({}); if (operation === 'general.me') return response(myGeneral(state)); @@ -162,11 +177,15 @@ const install = async (page: Page, state: FixtureState) => { if (operation === 'general.getMyLog') return response({ type: 'generalAction', logs: [{ id: 1, text: '기록' }] }); if (operation === 'general.setMySetting') { - const raw = route.request().postDataJSON() as { input?: { json?: Record } }; - state.settingMutations.push(raw.input?.json ?? {}); + state.settingMutations.push(jsonInput); state.myset = Math.max(0, state.myset - 1); return response({ ok: true }); } + if (operation === 'public.recordAccess') { + const pageName = typeof jsonInput.page === 'string' ? jsonInput.page : null; + if (pageName) state.accessPages.push(pageName); + return response({ recorded: true }); + } if (operation === 'nation.getBattleCenter') { if (state.permission === 'member') { return { @@ -196,11 +215,12 @@ const install = async (page: Page, state: FixtureState) => { }; test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => { - const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [] }; + const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] }; await install(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('traffic'); await expect(page.locator('.chart-title').first()).toHaveText('접 속 량'); + await expect.poll(() => state.accessPages).toContain('traffic'); const geometry = await page.locator('#traffic-container').evaluate((element) => { const rect = element.getBoundingClientRect(); @@ -244,12 +264,13 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p }); test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => { - const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('my-page'); await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('#set_my_setting')).toBeVisible(); + await expect.poll(() => state.accessPages).toContain('my-page'); const desktop = await page.locator('#container').evaluate((element) => { const rect = element.getBoundingClientRect(); @@ -322,7 +343,7 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac }); test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => { - const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; + const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(page, head); await page.setViewportSize({ width: 1000, height: 900 }); await page.goto('battle-center'); @@ -368,7 +389,7 @@ test('감찰부 keeps the selector interaction and shows the permission error pa await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry); await page.unrouteAll({ behavior: 'wait' }); - const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [] }; + const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [], accessPages: [] }; await install(page, member); await page.reload(); await expect(page.locator('.error')).toContainText('권한이 부족합니다.'); diff --git a/app/game-frontend/src/components/battle/BattleGeneralCard.vue b/app/game-frontend/src/components/battle/BattleGeneralCard.vue index 7a84f23..ed7eb05 100644 --- a/app/game-frontend/src/components/battle/BattleGeneralCard.vue +++ b/app/game-frontend/src/components/battle/BattleGeneralCard.vue @@ -3,7 +3,6 @@ import { computed, ref } from 'vue'; import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes'; interface Props { - general: GeneralDraft; options: BattleSimOptions; mode: 'attacker' | 'defender'; title: string; @@ -11,6 +10,7 @@ interface Props { } const props = defineProps(); +const general = defineModel('general', { required: true }); const emit = defineEmits<{ (event: 'import'): void; diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index c4dd81d..f7694a4 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -34,6 +34,34 @@ import NationBettingView from '../views/NationBettingView.vue'; import NpcListView from '../views/NpcListView.vue'; import TrafficView from '../views/TrafficView.vue'; import { useSessionStore } from '../stores/session'; +import { trpc } from '../utils/trpc'; + +const accessPageByRouteName = { + home: 'front-info', + 'nation-info': 'nation-info', + 'nation-cities': 'nation-cities', + 'global-info': 'global-info', + 'current-city': 'current-city', + diplomacy: 'diplomacy', + 'nation-generals': 'nation-generals', + 'nation-personnel': 'nation-personnel', + 'nation-finance': 'nation-finance', + 'battle-center': 'battle-center', + board: 'board', + 'board-secret': 'board', + 'best-general': 'best-general', + 'hall-of-fame': 'hall-of-fame', + 'dynasty-list': 'dynasty', + 'dynasty-detail': 'dynasty', + yearbook: 'yearbook', + 'nation-betting': 'nation-betting', + traffic: 'traffic', + 'npc-list': 'npc-list', + 'my-page': 'my-page', + 'npc-control': 'npc-control', + tournament: 'tournament', + betting: 'betting', +} as const; const routes = [ { @@ -370,4 +398,14 @@ router.beforeEach(async (to) => { return true; }); +router.afterEach((to) => { + const session = useSessionStore(); + const routeName = typeof to.name === 'string' ? to.name : ''; + const page = accessPageByRouteName[routeName as keyof typeof accessPageByRouteName]; + if (!page || !session.hasGeneral) { + return; + } + void trpc.public.recordAccess.mutate({ page }).catch(() => undefined); +}); + export default router; diff --git a/app/game-frontend/src/utils/formatLog.ts b/app/game-frontend/src/utils/formatLog.ts index 39ac1e5..f23dd9d 100644 --- a/app/game-frontend/src/utils/formatLog.ts +++ b/app/game-frontend/src/utils/formatLog.ts @@ -24,11 +24,10 @@ export const formatLog = (text?: string): string => { return ''; } - let match: RegExpExecArray | null = null; let lastIndex = 0; const result: string[] = []; - while ((match = logRegex.exec(text)) !== null) { + for (let match = logRegex.exec(text); match !== null; match = logRegex.exec(text)) { const partAll = match[0]; const subPart = match[1]; const index = match.index; @@ -40,9 +39,7 @@ export const formatLog = (text?: string): string => { if (subPart === '/') { result.push(''); } else if (subPart.length === 2) { - result.push( - `` - ); + result.push(``); } else { result.push(``); } diff --git a/app/game-frontend/src/views/BattleSimulatorView.vue b/app/game-frontend/src/views/BattleSimulatorView.vue index e8ced0d..a63e727 100644 --- a/app/game-frontend/src/views/BattleSimulatorView.vue +++ b/app/game-frontend/src/views/BattleSimulatorView.vue @@ -1125,7 +1125,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value); !loading.value && !!options.value); { } }; -const prevOptions = computed(() => - data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? [] -); +const prevOptions = computed(() => data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []); const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR'); @@ -216,12 +214,14 @@ const canRollback = (letter: DiplomacyLetter) => editable.value && data.value?.myNationId === letter.src.nationId && letter.state === 'PROPOSED'; const canDestroy = (letter: DiplomacyLetter) => - editable.value && letter.state === 'ACTIVATED' && (data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId); + editable.value && + letter.state === 'ACTIVATED' && + (data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId); const canRenew = (letter: DiplomacyLetter) => letter.state !== 'CANCELLED'; onMounted(() => { - loadLetters(); + void loadLetters(); }); onBeforeUnmount(() => { @@ -270,26 +270,84 @@ onBeforeUnmount(() => {
내용(국가 내 공개)
- - - + + + - - + +
내용(외교권자 전용)
- - - + + + - - + +
@@ -327,7 +385,10 @@ onBeforeUnmount(() => {

이전 문서를 찾을 수 없습니다.

@@ -335,11 +396,24 @@ onBeforeUnmount(() => {
- - + + - +
@@ -565,4 +639,4 @@ onBeforeUnmount(() => { .loading { color: #9aa3b8; } - \ No newline at end of file + diff --git a/app/game-frontend/src/views/NationAffairsView.vue b/app/game-frontend/src/views/NationAffairsView.vue index 34ccff6..4e3e57e 100644 --- a/app/game-frontend/src/views/NationAffairsView.vue +++ b/app/game-frontend/src/views/NationAffairsView.vue @@ -242,7 +242,7 @@ watch( ); onMounted(() => { - loadData(); + void loadData(); }); onBeforeUnmount(() => { @@ -278,19 +278,17 @@ onBeforeUnmount(() => {

국가 방침

- - - + + +
-
@@ -376,7 +416,9 @@ onBeforeUnmount(() => { /> 전쟁 금지 - 잔여 {{ data.warSettingCnt.remain }}회 (월 +{{ data.warSettingCnt.inc }}회) + 잔여 {{ data.warSettingCnt.remain }}회 (월 +{{ data.warSettingCnt.inc }}회)
@@ -574,4 +616,4 @@ onBeforeUnmount(() => { .loading { color: #9aa3b8; } - \ No newline at end of file + diff --git a/app/game-frontend/src/views/ScoutMessageView.vue b/app/game-frontend/src/views/ScoutMessageView.vue index 038604a..1f7fc5b 100644 --- a/app/game-frontend/src/views/ScoutMessageView.vue +++ b/app/game-frontend/src/views/ScoutMessageView.vue @@ -134,7 +134,7 @@ watch( ); onMounted(() => { - loadData(); + void loadData(); }); onBeforeUnmount(() => { @@ -168,7 +168,11 @@ onBeforeUnmount(() => {
-