diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index c39cb6e0..085ff572 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -810,7 +810,7 @@ export const generalRouter = router({ const [onlineAccess, ownNation, latestVote] = await Promise.all([ ctx.db.generalAccessLog.findMany({ where: { - lastRefresh: { + lastActionAt: { gte: scoreStartedAt, }, }, @@ -868,8 +868,9 @@ export const generalRouter = router({ onlineByNation.set(general.nationId, bucket); } const onlineNations = [...onlineByNation.entries()] + .filter(([nationId]) => nationId > 0) .sort((left, right) => right[1].length - left[1].length || left[0] - right[0]) - .map(([nationId]) => `【${nationId === 0 ? '재야' : (nationNames.get(nationId) ?? `세력 ${nationId}`)}】`) + .map(([nationId]) => `【${nationNames.get(nationId) ?? `세력 ${nationId}`}】`) .join(', '); const myOnlineGenerals = onlineGenerals .filter((general) => general.nationId === me.nationId) diff --git a/app/game-api/src/services/generalActivity.ts b/app/game-api/src/services/generalActivity.ts new file mode 100644 index 00000000..c1b72b16 --- /dev/null +++ b/app/game-api/src/services/generalActivity.ts @@ -0,0 +1,44 @@ +import { GamePrisma } from '@sammo-ts/infra'; + +import type { GameApiContext } from '../context.js'; + +const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']); + +/** + * Records a completed, authenticated user mutation without changing the Ref + * refresh counters. Page loads and read-model refreshes never call this path. + */ +export const recordGeneralActivity = async ( + ctx: Pick, + now = new Date() +): Promise => { + const user = ctx.auth?.user; + if (!user || user.roles.some((role) => adminRoles.has(role))) { + return false; + } + + const written = await ctx.db.$executeRaw( + GamePrisma.sql` + INSERT INTO general_access_log ( + general_id, + user_id, + last_action_at + ) + SELECT + id, + ${user.id}, + ${now} + FROM "general" + WHERE user_id = ${user.id} + ORDER BY id ASC + LIMIT 1 + ON CONFLICT (general_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + last_action_at = GREATEST( + general_access_log.last_action_at, + EXCLUDED.last_action_at + ) + ` + ); + return written > 0; +}; diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 476982d9..7867dfd6 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -16,6 +16,7 @@ import { type GeneralAccessEndpoint, } from './services/generalAccess.js'; import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js'; +import { recordGeneralActivity } from './services/generalActivity.js'; const t = initTRPC.context().create(); @@ -41,6 +42,21 @@ const requireAuthMiddleware = t.middleware(({ ctx, next }) => { }); }); +const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => { + const result = await next(); + if (type !== 'mutation' || !result.ok || ctx.generalAccessTracking !== true) { + return result; + } + + try { + await recordGeneralActivity(ctx); + } catch { + // 활동 표시는 업무 transaction보다 약한 보조 기록이다. 배포 중 schema + // 전환이나 일시 DB 오류가 이미 완료된 사용자 mutation을 실패시키지 않는다. + } + return result; +}); + const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { if (type !== 'mutation' || !ctx.db.$transaction) { return next(); @@ -146,7 +162,10 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next }) export const router = t.router; export const procedure = t.procedure.use(inputEventMiddleware); -export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware); +export const authedProcedure: typeof procedure = t.procedure + .use(requireAuthMiddleware) + .use(generalActivityMiddleware) + .use(inputEventMiddleware); // Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation // transaction보다 먼저 별도 저장된다. access middleware를 input-event보다 @@ -154,16 +173,20 @@ export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddle export const accessAuthedProcedure: typeof procedure = t.procedure .use(requireAuthMiddleware) .use(generalAccessEndpointMiddleware) + .use(generalActivityMiddleware) .use(inputEventMiddleware); // 턴 데몬이 ENGINE input_event와 world/DB 변경을 자체 transaction으로 // 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더 // 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다. -export const engineAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); +export const engineAuthedProcedure: typeof procedure = t.procedure + .use(requireAuthMiddleware) + .use(generalActivityMiddleware); export const engineProcedure: typeof procedure = t.procedure; export const accessEngineAuthedProcedure: typeof procedure = t.procedure .use(requireAuthMiddleware) - .use(generalAccessEndpointMiddleware); + .use(generalAccessEndpointMiddleware) + .use(generalActivityMiddleware); // 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조 // mutation에 사용한다. gameplay state 변경에는 사용하지 않는다. @@ -171,22 +194,43 @@ export const sessionActivityProcedure = t.procedure; // 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과 // 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다. -export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); +export const readOnlyAuthedProcedure: typeof procedure = t.procedure + .use(requireAuthMiddleware) + .use(generalActivityMiddleware); export const accessLimitAuthedProcedure: typeof procedure = t.procedure .use(requireAuthMiddleware) - .use(generalAccessLimitMiddleware); + .use(generalAccessLimitMiddleware) + .use(generalActivityMiddleware); export const deferredAccessLimitAuthedProcedure: typeof procedure = t.procedure .use(requireAuthMiddleware) - .use(deferredGeneralAccessLimitMiddleware); + .use(deferredGeneralAccessLimitMiddleware) + .use(generalActivityMiddleware); // 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를 // 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다. export const accessInputProcedure: typeof procedure.input = (input) => t.procedure.input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware); export const accessAuthedInputProcedure: typeof procedure.input = (input) => - t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware); + t.procedure + .use(requireAuthMiddleware) + .input(input) + .use(generalAccessEndpointMiddleware) + .use(generalActivityMiddleware) + .use(inputEventMiddleware); export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) => - t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware); + t.procedure + .use(requireAuthMiddleware) + .input(input) + .use(generalAccessEndpointMiddleware) + .use(generalActivityMiddleware); export const accessReadOnlyAuthedInputProcedure: typeof procedure.input = (input) => - t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware); + t.procedure + .use(requireAuthMiddleware) + .input(input) + .use(generalAccessEndpointMiddleware) + .use(generalActivityMiddleware); export const accessLimitAuthedInputProcedure: typeof procedure.input = (input) => - t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessLimitMiddleware); + t.procedure + .use(requireAuthMiddleware) + .input(input) + .use(generalAccessLimitMiddleware) + .use(generalActivityMiddleware); diff --git a/app/game-api/test/generalAccessTracking.integration.test.ts b/app/game-api/test/generalAccessTracking.integration.test.ts index f3821e3d..6e86c4d7 100644 --- a/app/game-api/test/generalAccessTracking.integration.test.ts +++ b/app/game-api/test/generalAccessTracking.integration.test.ts @@ -427,9 +427,7 @@ integration('general access tracking persistence', () => { if (!initialContext?.sourceRevision) { throw new Error('dashboard snapshot did not include its source revision'); } - await expect( - db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } }) - ).resolves.toBeNull(); + await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull(); expect(redisEval).toHaveBeenCalledTimes(1); await flushDeferredGeneralAccessBatch(db, deferredBatchId, [ @@ -482,15 +480,22 @@ integration('general access tracking persistence', () => { ).resolves.toMatchObject({ refresh: 2, refreshTotal: 2, + lastActionAt: null, }); await expect(boundaryCaller.general.setMySetting({ accepted: true })).resolves.toEqual({ ok: true }); - await expect( - db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } }) - ).resolves.toMatchObject({ + const completedActionAccess = await db.generalAccessLog.findUniqueOrThrow({ + where: { generalId: endpointGeneralId }, + }); + expect(completedActionAccess).toMatchObject({ refresh: 2, refreshTotal: 2, }); + expect(completedActionAccess.lastActionAt).toBeInstanceOf(Date); + await expect(dashboardCaller.general.getFrontStatus()).resolves.toMatchObject({ + onlineNations: expect.not.stringContaining('재야'), + onlineGenerals: expect.stringContaining('접속경계'), + }); await expect(boundaryCaller.board.writeArticle({ accepted: true })).rejects.toMatchObject({ code: 'BAD_REQUEST', @@ -501,6 +506,7 @@ integration('general access tracking persistence', () => { ).resolves.toMatchObject({ refresh: 3, refreshTotal: 3, + lastActionAt: completedActionAccess.lastActionAt, }); const adminCaller = endpointBoundaryRouter.createCaller({ diff --git a/app/game-api/test/generalActivityTracking.test.ts b/app/game-api/test/generalActivityTracking.test.ts new file mode 100644 index 00000000..2cb5dfc8 --- /dev/null +++ b/app/game-api/test/generalActivityTracking.test.ts @@ -0,0 +1,71 @@ +import { TRPCError } from '@trpc/server'; +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; + +import type { GameApiContext } from '../src/context.js'; +import { authedProcedure, router, sessionActivityProcedure } from '../src/trpc.js'; + +const auth = (roles: string[] = ['user']): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: '2026-08-19T00:00:00.000Z', + expiresAt: '2026-08-20T00:00:00.000Z', + sessionId: 'activity-session', + user: { + id: 'activity-user', + username: 'activity-user', + displayName: '활동 사용자', + roles, + }, + sanctions: {}, +}); + +const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token = auth()) => + ({ + auth: token, + db: { $executeRaw: executeRaw }, + generalAccessTracking: true, + profile: { id: 'che:default', name: 'che', scenario: 'default' }, + }) as unknown as GameApiContext; + +const activityRouter = router({ + read: authedProcedure.query(() => 'read'), + act: authedProcedure.mutation(() => 'acted'), + rejected: authedProcedure.mutation(() => { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'rejected' }); + }), + pageRefresh: sessionActivityProcedure.mutation(() => 'refreshed'), +}); + +describe('general action tracking', () => { + it('records only a completed authenticated mutation, not reads, page refreshes, or rejected actions', async () => { + const executeRaw = vi.fn(async (_query: unknown) => 1); + const caller = activityRouter.createCaller(buildContext(executeRaw)); + + await expect(caller.read()).resolves.toBe('read'); + await expect(caller.pageRefresh()).resolves.toBe('refreshed'); + await expect(caller.rejected()).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(executeRaw).not.toHaveBeenCalled(); + + await expect(caller.act()).resolves.toBe('acted'); + expect(executeRaw).toHaveBeenCalledTimes(1); + const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; + expect(statement.sql).toContain('INSERT INTO general_access_log'); + expect(statement.sql).toContain('last_action_at'); + expect(statement.sql).toContain('FROM "general"'); + expect(statement.values).toContain('activity-user'); + }); + + it('does not mark admin mutations and never overturns a completed action when presence persistence fails', async () => { + const adminWrite = vi.fn(async (_query: unknown) => 1); + await expect(activityRouter.createCaller(buildContext(adminWrite, auth(['admin']))).act()).resolves.toBe( + 'acted' + ); + expect(adminWrite).not.toHaveBeenCalled(); + + const failedWrite = vi.fn(async (_query: unknown) => Promise.reject(new Error('presence unavailable'))); + await expect(activityRouter.createCaller(buildContext(failedWrite)).act()).resolves.toBe('acted'); + expect(failedWrite).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/game-api/test/mainFrontStatusRouter.test.ts b/app/game-api/test/mainFrontStatusRouter.test.ts index b3b2afff..3833eee5 100644 --- a/app/game-api/test/mainFrontStatusRouter.test.ts +++ b/app/game-api/test/mainFrontStatusRouter.test.ts @@ -34,6 +34,7 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted { id: 7, name: '유비', nationId: 2 }, { id: 8, name: '관우', nationId: 2 }, { id: 9, name: '조조', nationId: 3 }, + { id: 10, name: '재야장수', nationId: 0 }, ]), }, worldState: { @@ -46,7 +47,7 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted })), }, generalAccessLog: { - findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }]), + findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }, { generalId: 10 }]), }, nation: { findUnique: vi.fn(async () => ({ @@ -81,7 +82,7 @@ describe('general.getFrontStatus', () => { vi.useRealTimers(); }); - it('returns ref-compatible current-turn online, nation notice, and new vote data', async () => { + it('returns action-based current-turn online data without listing the free nation', async () => { const context = buildContext(); const caller = appRouter.createCaller(context); @@ -89,7 +90,7 @@ describe('general.getFrontStatus', () => { expect(result).toEqual({ serverId: 'che_260819_front', - onlineUserCount: 3, + onlineUserCount: 4, onlineNations: '【촉】, 【위】', onlineGenerals: '유비, 관우', nationNotice: '

북벌 준비

', @@ -102,7 +103,7 @@ describe('general.getFrontStatus', () => { }); expect(context.db.generalAccessLog.findMany).toHaveBeenCalledWith({ where: { - lastRefresh: { + lastActionAt: { gte: new Date('2026-07-26T10:00:00.000Z'), }, }, diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 965fd950..54ab7cd7 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -63,6 +63,7 @@ type FixtureState = { joinConfig?: Record; createGeneralInputs?: Array>; mainTraits?: { personal: string; specialDomestic: string; specialWar: string }; + hiddenSeedLogText?: string; recentRecords?: { global: Array<{ id: number; text: string; createdAt?: string }>; general: Array<{ id: number; text: string; createdAt?: string }>; @@ -484,7 +485,10 @@ const install = async (page: Page, state: FixtureState) => { }); if (operation === 'general.getMyLog') { state.generalLogQueries = (state.generalLogQueries ?? 0) + 1; - return response({ type: 'generalAction', logs: [{ id: 1, text: '기록' }] }); + return response({ + type: 'generalAction', + logs: [{ id: 1, text: state.hiddenSeedLogText ?? '기록' }], + }); } if (operation === 'general.instantRetreat') { state.instantRetreatInputs?.push(jsonInput); @@ -560,7 +564,11 @@ const install = async (page: Page, state: FixtureState) => { ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'].includes(jsonInput.type) ? jsonInput.type : 'generalAction'; - return response({ type, generalId: 7, logs: [{ id: 1, text: `${type} 감찰 기록` }] }); + return response({ + type, + generalId: 7, + logs: [{ id: 1, text: state.hiddenSeedLogText ?? `${type} 감찰 기록` }], + }); } return response({ ok: true }); }); @@ -819,6 +827,80 @@ test('메인 장수 동향과 개인 전투 기록은 Ref 행 간격·색상· await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry); }); +test('전투시드는 메인·내 정보·감찰부에서 숨긴 채 선택할 수 있다', async ({ page }) => { + const seedText = '(전투시드: 0123456789abcdef)'; + const logText = + '검증장수가 낙양으로 ' + + `진격합니다.${seedText}`; + const state: FixtureState = { + permission: 'head', + myset: 3, + settingMutations: [], + accessPages: [], + hiddenSeedLogText: logText, + recentRecords: { + global: [{ id: 18611, text: logText }], + general: [], + history: [], + }, + }; + await install(page, state); + + const inspectHiddenSeed = async (selector: string) => { + const seed = page.locator(selector); + await expect(seed).toHaveCount(1); + return seed.evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + const result = { + text: element.textContent, + selectedText: selection?.toString(), + color: style.color, + fontSize: style.fontSize, + width: rect.width, + height: rect.height, + }; + selection?.removeAllRanges(); + return result; + }); + }; + const assertHiddenSeed = (result: Awaited>) => { + expect(result.text).toBe(seedText); + expect(result.selectedText).toBe(seedText); + expect(result.color).toBe('rgba(0, 0, 0, 0)'); + expect(result.fontSize).toBe('0px'); + expect(result.width).toBe(0); + expect(result.height).toBe(0); + }; + + for (const viewport of [ + { width: 1000, height: 900 }, + { width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + + await page.goto(''); + const mainSeed = await inspectHiddenSeed('[data-record-bucket="global"] .hidden_but_copyable'); + assertHiddenSeed(mainSeed); + await persistParityArtifact(page, `core-hidden-battle-seed-main-${viewport.width}`, mainSeed); + + await page.goto('my-page'); + const myPageSeed = await inspectHiddenSeed('.log-panel:first-child .hidden_but_copyable'); + assertHiddenSeed(myPageSeed); + await persistParityArtifact(page, `core-hidden-battle-seed-my-page-${viewport.width}`, myPageSeed); + + await page.goto('battle-center'); + const battleCenterSeed = await inspectHiddenSeed('[data-log-type="generalAction"] .hidden_but_copyable'); + assertHiddenSeed(battleCenterSeed); + await persistParityArtifact(page, `core-hidden-battle-seed-battle-center-${viewport.width}`, battleCenterSeed); + } +}); + test('메인 개인 기록의 공격·수비 시각은 Ref와 같은 90% 글자 크기로 표시한다', async ({ page }) => { const state: FixtureState = { permission: 'head', diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css index 2a589172..d33af421 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -88,6 +88,17 @@ textarea { display: inline-block; } +/* + * Ref's common stylesheet keeps diagnostic seeds in the DOM so they remain + * copyable, while removing them from every normal log rendering surface. + * This marker can arrive through main, personal, audit, and history log APIs, + * so the display contract belongs to the app-wide legacy markup boundary. + */ +.hidden_but_copyable { + color: rgba(0, 0, 0, 0) !important; + font-size: 0; +} + .small_war_log .war_type_attack { color: cyan; } diff --git a/app/game-frontend/src/components/main/GeneralRecordPanels.vue b/app/game-frontend/src/components/main/GeneralRecordPanels.vue index 7e756914..8d2bb70f 100644 --- a/app/game-frontend/src/components/main/GeneralRecordPanels.vue +++ b/app/game-frontend/src/components/main/GeneralRecordPanels.vue @@ -84,11 +84,6 @@ const unavailableText: Record = { border-bottom: 0; } -.log-line :deep(.hidden_but_copyable) { - color: transparent !important; - font-size: 0; -} - .empty { padding: 2px 8px; color: #999; diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 0e5fa475..45c72071 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -763,11 +763,6 @@ button { vertical-align: top; } -.record-line :deep(.hidden_but_copyable) { - color: transparent !important; - font-size: 0; -} - .record-empty { color: #aaa; } diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index e0d3bbf1..d324a5b8 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -38,8 +38,8 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, - gatewaySchemaHead: '20260818001000_add_game_cancellation_operation', - gameSchemaHead: '20260818010000_add_legacy_battle_result_logs', + gatewaySchemaHead: '20260819000000_backfill_profile_release_source', + gameSchemaHead: '20260819000000_add_general_last_action', }); }); diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index 4e88fbb1..b97c1bc5 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -137,6 +137,13 @@ REF_PERSONAL_WAR_LOG_ARTIFACT_DIR=/path/to/ignored/artifacts \ `MENU_PARITY_ARTIFACT_DIR`를 지정하면 1200×900·500×900 screenshot과 computed style JSON을 남깁니다. +같은 Ref collector는 “진격합니다.” 전투 seed marker도 함께 렌더링하여 +desktop/mobile의 투명색, 0px 글자 크기, 0×0 rect와 selection text 보존을 +수집합니다. Core의 대응 test는 `inGameMenus.spec.ts`의 “전투시드는 메인·내 +정보·감찰부에서 숨긴 채 선택할 수 있다”이며 `PLAYWRIGHT_FRONTEND_MODE=production` +과 `MENU_PARITY_ARTIFACT_DIR`를 함께 지정하면 세 화면의 1000×900·500×900 +screenshot과 computed JSON을 남깁니다. + 개인 전투 결과 요약의 Ref 색상·글자 크기는 실제 `small_war_log` class 구조와 빌드 CSS를 사용하는 별도 정적 Chromium fixture로 재현합니다. 방어 화살표, 장수명 괄호, 병력 수치와 병종·장수/병력 글자 크기를 desktop/mobile에서 diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index ca1d8b12..dfc815d8 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -271,12 +271,14 @@ model GeneralAccessLog { generalId Int @unique @map("general_id") userId String? @map("user_id") lastRefresh DateTime? @map("last_refresh") + lastActionAt DateTime? @map("last_action_at") refresh Int @default(0) refreshTotal Int @default(0) @map("refresh_total") refreshScore Int @default(0) @map("refresh_score") refreshScoreTotal Int @default(0) @map("refresh_score_total") @@index([userId]) + @@index([lastActionAt]) @@map("general_access_log") } diff --git a/packages/infra/prisma/migrations/20260819000000_add_general_last_action/migration.sql b/packages/infra/prisma/migrations/20260819000000_add_general_last_action/migration.sql new file mode 100644 index 00000000..2d4dce92 --- /dev/null +++ b/packages/infra/prisma/migrations/20260819000000_add_general_last_action/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "general_access_log" +ADD COLUMN "last_action_at" TIMESTAMP(3); + +CREATE INDEX "general_access_log_last_action_at_idx" +ON "general_access_log"("last_action_at"); diff --git a/release-manifest.json b/release-manifest.json index 4d239039..ed380510 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260819000000_backfill_profile_release_source", - "gameSchemaHead": "20260818010000_add_legacy_battle_result_logs", + "gameSchemaHead": "20260819000000_add_general_last_action", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] } diff --git a/tools/frontend-legacy-parity/main-front-status.spec.ts b/tools/frontend-legacy-parity/main-front-status.spec.ts index 40942971..54ccaeb5 100644 --- a/tools/frontend-legacy-parity/main-front-status.spec.ts +++ b/tools/frontend-legacy-parity/main-front-status.spec.ts @@ -16,7 +16,8 @@ const accessToken = `ga_${randomUUID()}`; let postgres: ReturnType; let redis: RedisConnector; let prisma: GamePrismaClient; -let fixture: { generalId: number; nationId: number; userId: string; voteId: number } | null = null; +let fixture: { generalId: number; freeGeneralId: number; nationId: number; userId: string; voteId: number } | null = + null; const accessKey = (token: string) => `sammo:game:access:che:default:${token}`; const response = (data: unknown) => ({ result: { data } }); @@ -49,7 +50,9 @@ test.beforeAll(async () => { ]); const nationId = (nationMax._max.id ?? 0) + 10_000; const generalId = (generalMax._max.id ?? 0) + 10_000; + const freeGeneralId = generalId + 1; const userId = `main-front-status-${randomUUID()}`; + const freeUserId = `main-front-status-free-${randomUUID()}`; await prisma.$executeRaw( GamePrisma.sql` INSERT INTO nation (id, name, color, meta) @@ -64,19 +67,35 @@ test.beforeAll(async () => { await prisma.$executeRaw( GamePrisma.sql` INSERT INTO general (id, user_id, name, nation_id, city_id, turn_time) - VALUES (${generalId}, ${userId}, ${'현황검증장수'}, ${nationId}, ${0}, ${new Date()}) + VALUES + (${generalId}, ${userId}, ${'현황검증장수'}, ${nationId}, ${0}, ${new Date()}), + (${freeGeneralId}, ${freeUserId}, ${'재야행동검증장수'}, ${0}, ${0}, ${new Date()}) ` ); - await prisma.generalAccessLog.create({ - data: { - generalId, - userId, - lastRefresh: new Date(), - refresh: 1, - refreshTotal: 1, - refreshScore: 1, - refreshScoreTotal: 1, - }, + const actionAt = new Date(); + await prisma.generalAccessLog.createMany({ + data: [ + { + generalId, + userId, + lastRefresh: actionAt, + lastActionAt: actionAt, + refresh: 1, + refreshTotal: 1, + refreshScore: 1, + refreshScoreTotal: 1, + }, + { + generalId: freeGeneralId, + userId: freeUserId, + lastRefresh: actionAt, + lastActionAt: actionAt, + refresh: 1, + refreshTotal: 1, + refreshScore: 1, + refreshScoreTotal: 1, + }, + ], }); const poll = await prisma.votePoll.create({ data: { @@ -92,7 +111,7 @@ test.beforeAll(async () => { }, select: { id: true }, }); - fixture = { generalId, nationId, userId, voteId: poll.id }; + fixture = { generalId, freeGeneralId, nationId, userId, voteId: poll.id }; const issuedAt = new Date(); await redis.client.set( @@ -118,14 +137,15 @@ test.beforeAll(async () => { test.afterAll(async () => { if (fixture) { await prisma.votePoll.deleteMany({ where: { id: fixture.voteId } }); - await prisma.generalAccessLog.deleteMany({ where: { generalId: fixture.generalId } }); - await prisma.$executeRaw(GamePrisma.sql`DELETE FROM general WHERE id = ${fixture.generalId}`); + const generalIds = [fixture.generalId, fixture.freeGeneralId]; + await prisma.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } }); + await prisma.general.deleteMany({ where: { id: { in: generalIds } } }); await prisma.$executeRaw(GamePrisma.sql`DELETE FROM nation WHERE id = ${fixture.nationId}`); const [generalCount, nationCount, voteCount, accessCount] = await Promise.all([ - prisma.general.count({ where: { id: fixture.generalId } }), + prisma.general.count({ where: { id: { in: generalIds } } }), prisma.nation.count({ where: { id: fixture.nationId } }), prisma.votePoll.count({ where: { id: fixture.voteId } }), - prisma.generalAccessLog.count({ where: { generalId: fixture.generalId } }), + prisma.generalAccessLog.count({ where: { generalId: { in: generalIds } } }), ]); expect([generalCount, nationCount, voteCount, accessCount]).toEqual([0, 0, 0, 0]); } @@ -146,6 +166,33 @@ const installMainFixture = async ( }, failStatus: () => boolean ) => { + const generalContext = { + general: { + id: fixture?.generalId ?? 1, + name: '현황검증장수', + npcState: 0, + nationId: fixture?.nationId ?? 1, + cityId: 1, + troopId: 0, + picture: null, + imageServer: 0, + officerLevel: 0, + stats: { leadership: 55, strength: 55, intelligence: 55 }, + gold: 1000, + rice: 1000, + crew: 0, + train: 0, + atmos: 0, + injury: 0, + experience: 0, + dedication: 0, + items: { horse: null, weapon: null, book: null, item: null }, + }, + city: null, + nation: null, + settings: {}, + penalties: {}, + }; await page.addInitScript( ({ token }) => { window.localStorage.setItem('sammo-game-token', token); @@ -155,35 +202,28 @@ const installMainFixture = async ( ); await page.route('**/che/api/trpc/**', async (route) => { const results = operationNames(route).map((operation) => { - if (operation === 'general.me') { + if (operation === 'dashboard.getContextBundleDelta') { return response({ - general: { - id: fixture?.generalId ?? 1, - name: '현황검증장수', - npcState: 0, - nationId: fixture?.nationId ?? 1, - cityId: 1, - troopId: 0, - picture: null, - imageServer: 0, - officerLevel: 0, - stats: { leadership: 55, strength: 55, intelligence: 55 }, - gold: 1000, - rice: 1000, - crew: 0, - train: 0, - atmos: 0, - injury: 0, - experience: 0, - dedication: 0, - items: { horse: null, weapon: null, book: null, item: null }, + context: { + kind: 'snapshot', + revision: 'AAAAAAAAAAAAAAAAAAAAAA', + data: generalContext, + }, + commandTable: { + kind: 'snapshot', + revision: 'BBBBBBBBBBBBBBBBBBBBBB', + data: { general: [], nation: [] }, + }, + boardAccess: { + kind: 'snapshot', + revision: 'CCCCCCCCCCCCCCCCCCCCCC', + data: { canMeeting: false, canSecret: false, permission: 0 }, }, - city: null, - nation: null, - settings: {}, - penalties: {}, }); } + if (operation === 'general.me') { + return response(generalContext); + } if (operation === 'general.getFrontStatus') { return failStatus() ? errorResponse(operation, '상단 현황을 불러오지 못했습니다.') @@ -214,6 +254,15 @@ const installMainFixture = async ( levelMap: { 7: '수도' }, }); } + if (operation === 'world.getState') { + return response({ + currentYear: 190, + currentMonth: 1, + tickSeconds: 60, + config: { npcMode: 0, const: {}, environment: {} }, + meta: {}, + }); + } if (operation === 'world.getMap') { return response({ year: 190, @@ -280,6 +329,9 @@ test('renders actual online, nation policy, and survey data with ref geometry an }; } ).result.data; + expect(liveStatus.onlineUserCount).toBe(2); + expect(liveStatus.onlineNations).toContain('【검증국】'); + expect(liveStatus.onlineNations).not.toContain('재야'); expect(liveStatus.onlineGenerals).toContain('현황검증장수'); expect(liveStatus.nationNotice).toContain(marker); expect(liveStatus.latestVote).toMatchObject({ title: '검증 설문', hasVoted: false }); @@ -337,7 +389,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an expect(desktop.voteRow.x).toBeCloseTo(666.67, 0); expect(desktop.voteRow.width).toBeCloseTo(333.33, 0); expect(desktop.voteRow.height).toBeCloseTo(36, 0); - expect(desktop.backgroundImage).toContain('/image/game/back_walnut.jpg'); + expect(desktop.backgroundImage).toContain('/game/back_walnut.jpg'); await page.setViewportSize({ width: 500, height: 900 }); await expect(status).toHaveCSS('width', '500px'); @@ -349,7 +401,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an for (const width of mobileActivity.widths) expect(width).toBeCloseTo(166.67, 0); failStatus = true; - await page.getByRole('button', { name: '새로고침', exact: true }).click(); + await page.getByRole('button', { name: '갱 신', exact: true }).click(); await expect(page.getByRole('alert').filter({ hasText: '상단 현황을 불러오지 못했습니다.' })).toBeVisible(); await expect(status).toContainText(marker); diff --git a/tools/frontend-legacy-parity/reference-personal-war-log-font.mjs b/tools/frontend-legacy-parity/reference-personal-war-log-font.mjs index 3d81d429..19f614c1 100644 --- a/tools/frontend-legacy-parity/reference-personal-war-log-font.mjs +++ b/tools/frontend-legacy-parity/reference-personal-war-log-font.mjs @@ -12,6 +12,8 @@ const css = await readFile(resolve(refRoot, 'dist_js/hwe_dynamic/vue/v_main.css' const records = [ '●10월:천귀병으로 ⓝ염행의 보병을 수비합니다. <1>12:54', '●10월:천귀병으로 ⓝ염행의 보병을 공격합니다. <1>12:55', + '검증장수가 낙양으로 진격합니다.' + + '(전투시드: 0123456789abcdef)', ]; const browser = await chromium.launch({ headless: true }); @@ -43,6 +45,29 @@ try { const time = spans.find((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? '')); const name = spans.find((span) => span.textContent === 'ⓝ염행'); const action = spans.find((span) => span.textContent === '수비' || span.textContent === '공격'); + const hiddenSeed = element.querySelector('.hidden_but_copyable'); + if (hiddenSeed instanceof HTMLElement) { + const range = document.createRange(); + range.selectNodeContents(hiddenSeed); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + const hiddenSeedRect = hiddenSeed.getBoundingClientRect(); + const hiddenSeedStyle = getComputedStyle(hiddenSeed); + const result = { + text: hiddenSeed.textContent, + selectedText: selection?.toString(), + color: hiddenSeedStyle.color, + fontSize: hiddenSeedStyle.fontSize, + width: hiddenSeedRect.width, + height: hiddenSeedRect.height, + }; + selection?.removeAllRanges(); + return { + text: element.textContent, + hiddenSeed: result, + }; + } if ( !(time instanceof HTMLElement) || !(name instanceof HTMLElement) || @@ -62,6 +87,7 @@ try { timeFontSize: getComputedStyle(time).fontSize, nameFontSize: getComputedStyle(name).fontSize, actionFontSize: getComputedStyle(action).fontSize, + hiddenSeed: null, }; }) );