From 5c19d24254f7dabfb65333e2158c19a10119399f Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:33:33 +0000 Subject: [PATCH 1/6] feat: complete auction menu parity --- app/game-api/src/router/auction/index.ts | 2 +- app/game-api/test/auctionRouter.test.ts | 308 ++++++ app/game-frontend/e2e/auction.spec.ts | 371 ++++++++ app/game-frontend/e2e/playwright.config.mjs | 2 +- app/game-frontend/package.json | 1 + app/game-frontend/src/views/AuctionView.vue | 889 ++++++++++++++---- app/gateway-api/src/adminRouter.ts | 1 + .../src/lobby/profileStatusService.ts | 7 +- .../src/orchestrator/gatewayOrchestrator.ts | 39 +- .../test/orchestratorOperations.test.ts | 13 +- app/gateway-api/test/orchestratorPlan.test.ts | 22 + .../e2e/server-operations.spec.ts | 3 +- app/gateway-frontend/src/views/AdminView.vue | 4 +- .../src/views/ServerOperationsView.vue | 13 +- .../auction-ref-measure.mjs | 257 +++++ .../test/auctionFlow.test.ts | 4 + 16 files changed, 1721 insertions(+), 215 deletions(-) create mode 100644 app/game-api/test/auctionRouter.test.ts create mode 100644 app/game-frontend/e2e/auction.spec.ts create mode 100644 tools/frontend-legacy-parity/auction-ref-measure.mjs diff --git a/app/game-api/src/router/auction/index.ts b/app/game-api/src/router/auction/index.ts index 5b2f30d..8f1ac28 100644 --- a/app/game-api/src/router/auction/index.ts +++ b/app/game-api/src/router/auction/index.ts @@ -594,7 +594,7 @@ export const auctionRouter = router({ auctionId: auction.id, generalId: general.id, amount: input.amount, - tryExtendCloseDate: input.tryExtendCloseDate ?? true, + tryExtendCloseDate: input.tryExtendCloseDate ?? false, }); if (!result || result.type !== 'auctionBid') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); diff --git a/app/game-api/test/auctionRouter.test.ts b/app/game-api/test/auctionRouter.test.ts new file mode 100644 index 0000000..fb915be --- /dev/null +++ b/app/game-api/test/auctionRouter.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { GamePrisma, RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 7, + userId: 'user-1', + name: '유비', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 50, + strength: 50, + intel: 50, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 1, + gold: 10_000, + rice: 10_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-07-26T00:00:00Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-07-26T00:00:00Z'), + updatedAt: new Date('2026-07-26T00:00:00Z'), + ...overrides, +}); + +const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: `session-${userId}`, + user: { + id: userId, + username: userId, + displayName: userId, + roles: [], + }, + sanctions: {}, +}); + +const sqlText = (query: GamePrisma.Sql): string => query.strings.join(' '); + +const buildContext = (options: { + auth?: GameSessionTokenPayload | null; + general?: GeneralRow | null; + auctions?: Array>; + queryRaw?: (query: GamePrisma.Sql) => Promise; +}) => { + const auth = options.auth === undefined ? buildAuth() : options.auth; + const general = options.general === undefined ? buildGeneral() : options.general; + const requestCommand = vi.fn(async (command: { type: string }) => { + if (command.type === 'auctionOpen') { + return { + type: 'auctionOpen' as const, + ok: true as const, + auctionId: 91, + closeAt: '2026-07-27T00:00:00.000Z', + }; + } + return { + type: 'auctionBid' as const, + ok: true as const, + auctionId: 91, + closeAt: '2026-07-27T00:00:00.000Z', + }; + }); + const queryRaw = vi.fn(options.queryRaw ?? (async () => [])); + const worldState = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 3600, + config: { + const: { + auctionName: ['청룡', '백호', '주작', '현무'], + allItems: { weapon: { che_무기_12_칠성검: 1 } }, + }, + }, + meta: { hiddenSeed: 'auction-hidden-seed' }, + updatedAt: new Date('2026-07-26T00:00:00Z'), + }; + const db = { + $queryRaw: queryRaw, + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + general?.userId === where.userId ? general : null + ), + findMany: vi.fn(async ({ where }: { where: { id: { in: number[] } } }) => + where.id.in.map((id) => ({ id, name: id === 88 ? '관우' : '조조' })) + ), + }, + auction: { + findMany: vi.fn(async () => options.auctions ?? []), + findFirst: vi.fn(async () => null), + }, + worldState: { + findFirst: vi.fn(async () => worldState), + }, + inheritancePoint: { + findUnique: vi.fn(async () => ({ value: 10_000 })), + }, + logEntry: { + findMany: vi.fn(async () => []), + }, + }; + const redis = { + zAdd: vi.fn(async () => 1), + }; + const accessTokenStore = new RedisAccessTokenStore( + { + get: async () => null, + set: async () => null, + }, + 'che:default' + ); + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: redis as unknown as RedisConnector['client'], + turnDaemon: { requestCommand } as unknown as TurnDaemonTransport, + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, db, queryRaw, redis, requestCommand }; +}; + +describe('auction router actor and permission boundaries', () => { + it('rejects unauthenticated auction reads', async () => { + const fixture = buildContext({ auth: null }); + + await expect(appRouter.createCaller(fixture.context).auction.getOverview()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('rejects reads and mutations when the authenticated user owns no general', async () => { + const fixture = buildContext({ + auth: buildAuth('user-2'), + general: buildGeneral({ userId: 'user-1' }), + }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.auction.getOverview()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: 'General not found.', + }); + await expect( + caller.auction.openBuyRice({ + amount: 1000, + closeTurnCnt: 3, + startBidAmount: 500, + finishBidAmount: 2000, + }) + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: 'General not found.', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('derives the daemon actor from the session-owned general and ignores a forged generalId field', async () => { + const fixture = buildContext({ general: buildGeneral({ id: 7, userId: 'user-1' }) }); + const input = { + amount: 1000, + closeTurnCnt: 3, + startBidAmount: 500, + finishBidAmount: 2000, + generalId: 999, + }; + + await appRouter.createCaller(fixture.context).auction.openBuyRice(input); + + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'auctionOpen', + auctionType: 'BUY_RICE', + generalId: 7, + amount: 1000, + closeTurnCnt: 3, + startBidAmount: 500, + finishBidAmount: 2000, + }); + }); + + it('redacts real unique-auction identities while preserving caller markers', async () => { + const openedAt = new Date('2026-07-26T01:00:00Z'); + const fixture = buildContext({ + auctions: [ + { + id: 31, + type: 'UNIQUE_ITEM', + targetCode: 'che_무기_12_칠성검', + hostGeneralId: 7, + hostName: null, + detail: { title: '칠성검 경매', startBidAmount: 5000 }, + status: 'OPEN', + closeAt: new Date('2026-07-27T00:00:00Z'), + bids: [ + { + id: 41, + generalId: 88, + amount: 5500, + eventAt: openedAt, + }, + ], + }, + ], + }); + + const result = await appRouter.createCaller(fixture.context).auction.getOverview(); + const unique = result.uniqueAuctions[0]; + + expect(unique).toMatchObject({ + id: 31, + hostGeneralId: null, + isCallerHost: true, + highestBid: { amount: 5500, isCaller: false }, + }); + expect(unique?.hostName).not.toBe('유비'); + expect(unique?.highestBid?.bidderName).not.toBe('관우'); + expect(JSON.stringify(unique)).not.toContain('"generalId"'); + expect(JSON.stringify(unique)).not.toContain('"hostGeneralId":7'); + }); + + it('keeps the legacy default of no requested close extension for a unique bid', async () => { + const fixture = buildContext({ + queryRaw: async (query) => { + const text = sqlText(query); + if (text.includes('FROM auction') && text.includes('WHERE id =')) { + return [ + { + id: 31, + type: 'UNIQUE_ITEM', + targetCode: 'che_무기_12_칠성검', + hostGeneralId: 88, + detail: { startBidAmount: 100, isReverse: false }, + status: 'OPEN', + closeAt: new Date('2026-07-27T00:00:00Z'), + }, + ]; + } + if (text.includes('FROM auction_bid') && text.includes('general_id =')) { + return []; + } + if (text.includes('SELECT bid.auction_id')) { + return [{ auctionId: 31, generalId: 88, amount: 100 }]; + } + if (text.includes('FROM auction_bid')) { + return [{ id: 41, generalId: 88, amount: 100, meta: {} }]; + } + if (text.includes('SELECT id, target_code')) { + return [{ id: 31, targetCode: 'che_무기_12_칠성검' }]; + } + return []; + }, + }); + + await appRouter.createCaller(fixture.context).auction.bidUnique({ + auctionId: 31, + amount: 110, + }); + + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'auctionBid', + auctionId: 31, + generalId: 7, + amount: 110, + tryExtendCloseDate: false, + }); + }); +}); diff --git a/app/game-frontend/e2e/auction.spec.ts b/app/game-frontend/e2e/auction.spec.ts new file mode 100644 index 0000000..7267ece --- /dev/null +++ b/app/game-frontend/e2e/auction.spec.ts @@ -0,0 +1,371 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')]; + +type AuctionFixture = { + failResourceBid?: boolean; + resourceBidCount: number; + uniqueBidCount: number; +}; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'BAD_REQUEST', httpStatus: 400, path }, + }, +}); + +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const readReferenceImage = async (filename: string): Promise => { + for (const imageRoot of imageRoots) { + try { + return await readFile(resolve(imageRoot, filename)); + } catch { + // The main checkout and nested feature worktrees have different parents. + } + } + throw new Error(`Reference image not found: ${filename}`); +}; + +const overview = { + resourceAuctions: [ + { + id: 1, + type: 'BUY_RICE', + targetCode: '1000', + status: 'OPEN', + hostGeneralId: 11, + hostName: '조조', + isCallerHost: false, + closeAt: '2026-07-27T02:30:00.000Z', + detail: { + title: '쌀 1000 경매', + amount: 1000, + isReverse: false, + startBidAmount: 500, + finishBidAmount: 1800, + }, + highestBid: { + amount: 750, + bidderName: '관우', + isCaller: false, + eventAt: '2026-07-26T01:00:00.000Z', + }, + }, + { + id: 2, + type: 'SELL_RICE', + targetCode: '900', + status: 'OPEN', + hostGeneralId: 7, + hostName: '유비', + isCallerHost: true, + closeAt: '2026-07-27T03:00:00.000Z', + detail: { + title: '금 900 경매', + amount: 900, + isReverse: false, + startBidAmount: 600, + finishBidAmount: 1700, + }, + highestBid: null, + }, + ], + uniqueAuctions: [ + { + id: 10, + type: 'UNIQUE_ITEM', + targetCode: 'che_무기_12_칠성검', + status: 'OPEN', + hostGeneralId: null, + hostName: '청룡', + isCallerHost: false, + closeAt: '2026-07-27T04:00:00.000Z', + detail: { + title: '칠성검 경매', + startBidAmount: 5000, + remainCloseDateExtensionCnt: 1, + availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z', + }, + highestBid: { + amount: 5500, + bidderName: '백호', + isCaller: false, + eventAt: '2026-07-26T02:00:00.000Z', + }, + }, + { + id: 9, + type: 'UNIQUE_ITEM', + targetCode: 'che_서적_15_손자병법', + status: 'FINISHED', + hostGeneralId: null, + hostName: '현무', + isCallerHost: true, + closeAt: '2026-07-25T04:00:00.000Z', + detail: { + title: '손자병법 경매', + startBidAmount: 5000, + remainCloseDateExtensionCnt: 0, + availableLatestBidCloseDate: '2026-07-25T04:30:00.000Z', + }, + highestBid: { + amount: 6000, + bidderName: '현무', + isCaller: true, + eventAt: '2026-07-25T03:00:00.000Z', + }, + }, + ], + callerAlias: '현무', + remainPoint: 9000, + recentLogs: [ + { + id: 1, + text: '●경매 1번 거래가 성사되었습니다.', + createdAt: '2026-07-25T00:00:00.000Z', + }, + ], +}; + +const uniqueDetail = { + auction: { + id: 10, + targetCode: 'che_무기_12_칠성검', + status: 'OPEN', + hostName: '청룡', + isCallerHost: false, + closeAt: '2026-07-27T04:00:00.000Z', + detail: { + title: '칠성검 경매', + startBidAmount: 5000, + remainCloseDateExtensionCnt: 1, + availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z', + }, + }, + bids: [ + { + id: 101, + amount: 5500, + bidderName: '백호', + isCaller: false, + eventAt: '2026-07-26T02:00:00.000Z', + }, + { + id: 100, + amount: 5000, + bidderName: '현무', + isCaller: true, + eventAt: '2026-07-26T01:00:00.000Z', + }, + ], + callerAlias: '현무', + remainPoint: 9000, +}; + +const installFixture = async (page: Page, state: AuctionFixture) => { + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_auction_playwright'); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }); + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readReferenceImage(filename), + }); + }); + } + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'lobby.info') { + return response({ myGeneral: { id: 7, name: '유비' } }); + } + if (operation === 'join.getConfig') { + return response({}); + } + if (operation === 'auction.getOverview') { + return response(overview); + } + if (operation === 'auction.getUniqueDetail') { + return response(uniqueDetail); + } + if (operation === 'auction.bidBuyRice') { + if (state.failResourceBid) { + state.failResourceBid = false; + return errorResponse(operation, '금이 부족합니다.'); + } + state.resourceBidCount += 1; + return response({ ok: true }); + } + if (operation === 'auction.bidUnique') { + state.uniqueBidCount += 1; + return response({ ok: true }); + } + if (operation === 'auction.openBuyRice' || operation === 'auction.openSellRice') { + return response({ auctionId: 20, closeAt: '2026-07-28T00:00:00.000Z' }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +const gotoAuction = async (page: Page, suffix = 'auction') => { + const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info')); + await page.goto(suffix); + await lobbyResponse; + await expect(page.locator('#container')).toBeVisible(); +}; + +test('resource auction preserves the legacy desktop structure, geometry, and interaction states', async ({ page }) => { + const state = { failResourceBid: true, resourceBidCount: 0, uniqueBidCount: 0 }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 800 }); + await gotoAuction(page); + await expect(page.getByRole('heading', { name: '경매장', exact: true })).toBeVisible(); + await expect(page.getByText('쌀 구매', { exact: true })).toBeVisible(); + await expect(page.getByText('쌀 판매', { exact: true })).toBeVisible(); + await expect(page.getByText('단가', { exact: true }).first()).toBeVisible(); + + const geometry = await page.locator('#container').evaluate((container) => { + const box = (selector: string) => { + const rect = container.querySelector(selector)!.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }; + const containerRect = container.getBoundingClientRect(); + const row = container.querySelector('.resource-row')!; + const rowRect = row.getBoundingClientRect(); + const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width); + const button = container.querySelector('.tab-button')!; + const buttonStyle = getComputedStyle(button); + return { + container: { x: containerRect.x, width: containerRect.width }, + topBar: box('.top-back-bar'), + row: { width: rowRect.width, height: rowRect.height }, + cells, + button: { + height: button.getBoundingClientRect().height, + borderRadius: buttonStyle.borderRadius, + cursor: buttonStyle.cursor, + fontSize: buttonStyle.fontSize, + }, + }; + }); + expect(geometry.container).toEqual({ x: 0, width: 1000 }); + expect(geometry.topBar).toMatchObject({ x: 0, width: 1000, height: 32 }); + expect(geometry.row).toEqual({ width: 1000, height: 22 }); + expect(geometry.cells[0]).toBeCloseTo(66.66, 1); + expect(geometry.cells[1]).toBeCloseTo(133.34, 1); + expect(geometry.cells[6]).toBeCloseTo(200, 1); + expect(geometry.button).toEqual({ + height: 35.5, + borderRadius: '5.25px', + cursor: 'pointer', + fontSize: '14px', + }); + await page.screenshot({ path: 'test-results/auction/resource-desktop-initial.png', fullPage: true }); + + const firstRow = page.locator('.resource-row.clickable-row').first(); + await firstRow.click(); + const bidInput = page.getByRole('spinbutton', { name: '1번 경매 입찰가' }); + await bidInput.fill('800'); + await page.getByRole('button', { name: '입찰', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('금이 부족합니다.'); + await page.screenshot({ path: 'test-results/auction/resource-desktop-error.png', fullPage: true }); + expect(state.resourceBidCount).toBe(0); + + await page.getByRole('button', { name: '입찰', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('입찰했습니다.'); + expect(state.resourceBidCount).toBe(1); + + await firstRow.hover(); + expect(await firstRow.evaluate((row) => getComputedStyle(row).cursor)).toBe('pointer'); + await page.screenshot({ path: 'test-results/auction/resource-desktop.png', fullPage: true }); +}); + +test('resource auction keeps the legacy 500px two-row grid', async ({ page }) => { + await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 }); + await page.setViewportSize({ width: 500, height: 800 }); + await gotoAuction(page); + + const geometry = await page + .locator('.resource-row') + .first() + .evaluate((row) => { + const origin = row.getBoundingClientRect(); + const relative = (selector: string) => { + const rect = row.querySelector(selector)!.getBoundingClientRect(); + return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height }; + }; + return { + row: { width: origin.width, height: origin.height }, + idx: relative('.idx'), + host: relative('.host'), + amount: relative('.amount'), + close: relative('.close-date'), + }; + }); + expect(geometry.row).toEqual({ width: 500, height: 43 }); + expect(geometry.idx).toEqual({ x: 0, y: 10.5, width: 41.65625, height: 21 }); + expect(geometry.host).toEqual({ x: 41.65625, y: 0, width: 125, height: 21 }); + expect(geometry.amount).toEqual({ x: 41.65625, y: 21, width: 125, height: 21 }); + expect(geometry.close).toEqual({ x: 416.65625, y: 10.5, width: 83.34375, height: 21 }); + await page.screenshot({ path: 'test-results/auction/resource-mobile.png', fullPage: true }); +}); + +test('unique auction separates ongoing and finished lists and auto-loads the legacy detail', async ({ page }) => { + const state = { resourceBidCount: 0, uniqueBidCount: 0 }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 800 }); + await gotoAuction(page, 'auction?type=unique'); + + await expect(page.getByRole('heading', { name: '유니크 경매장', exact: true })).toBeVisible(); + await expect(page.locator('.caller-alias')).toContainText('내 가명: 현무'); + await expect(page.getByRole('heading', { name: '경매 10번 상세' })).toBeVisible(); + await expect(page.getByText('최대지연', { exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: '진행중인 경매 목록' })).toBeVisible(); + await expect(page.getByRole('heading', { name: '종료된 경매 목록' })).toBeVisible(); + await expect(page.getByText('남음', { exact: true })).toBeVisible(); + await expect(page.getByText('소진', { exact: true })).toBeVisible(); + + const aliasStyle = await page.locator('.caller-alias strong').evaluate((element) => { + const style = getComputedStyle(element); + return { color: style.color, fontWeight: style.fontWeight }; + }); + expect(aliasStyle).toEqual({ color: 'rgb(0, 255, 255)', fontWeight: '700' }); + + const input = page.getByRole('spinbutton', { name: '유산포인트' }); + await input.fill('5600'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('칠성검 경매에 5600유산포인트를 입찰하시겠습니까?'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '입찰', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('입찰이 완료되었습니다.'); + expect(state.uniqueBidCount).toBe(1); + await page.screenshot({ path: 'test-results/auction/unique-desktop.png', fullPage: true }); +}); + +test('resource host cannot bid on the auction opened by its own general', async ({ page }) => { + await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 }); + await gotoAuction(page); + + await page.locator('.resource-row.clickable-row').filter({ hasText: '유비' }).click(); + await expect(page.getByRole('button', { name: '입찰', exact: true })).toBeDisabled(); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index ddee57a..16d70d0 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -8,7 +8,7 @@ const baseURL = `http://127.0.0.1:${port}/che/`; export default defineConfig({ testDir: '.', - testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts'], + testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts', 'auction.spec.ts'], fullyParallel: false, workers: 1, timeout: 30_000, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 48884db..7f55886 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -10,6 +10,7 @@ "test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs", "test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs", "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "node -e \"console.log('test not configured')\"", diff --git a/app/game-frontend/src/views/AuctionView.vue b/app/game-frontend/src/views/AuctionView.vue index ca80e55..7bcf928 100644 --- a/app/game-frontend/src/views/AuctionView.vue +++ b/app/game-frontend/src/views/AuctionView.vue @@ -1,8 +1,7 @@ diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index e469052..c35cdcf 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -817,6 +817,7 @@ export const adminRouter = router({ profileName: profile.profileName, apiRunning: false, daemonRunning: false, + auctionRunning: false, tournamentRunning: false, }, })); diff --git a/app/gateway-api/src/lobby/profileStatusService.ts b/app/gateway-api/src/lobby/profileStatusService.ts index b65c2f5..362d060 100644 --- a/app/gateway-api/src/lobby/profileStatusService.ts +++ b/app/gateway-api/src/lobby/profileStatusService.ts @@ -26,6 +26,7 @@ export type LobbyProfileStatus = { runtime: { apiRunning: boolean; daemonRunning: boolean; + auctionRunning: boolean; tournamentRunning: boolean; }; korName: string; @@ -69,7 +70,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi private mapProfile( row: GatewayProfileRecord, - runtimeMap: Map + runtimeMap: Map< + string, + { apiRunning: boolean; daemonRunning: boolean; auctionRunning: boolean; tournamentRunning: boolean } + > ): LobbyProfileStatus { const meta = row.meta; return { @@ -81,6 +85,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi runtime: runtimeMap.get(row.profileName) ?? { apiRunning: false, daemonRunning: false, + auctionRunning: false, tournamentRunning: false, }, korName: (meta.korName as string | undefined) ?? row.profile, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 273c083..7e90eed 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions { export interface ProfileRuntimeState { apiRunning: boolean; daemonRunning: boolean; + auctionRunning: boolean; tournamentRunning: boolean; } @@ -66,13 +67,18 @@ export const planProfileReconcile = ( ): { shouldStart: boolean; shouldStop: boolean } => { if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') { return { - shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning), + shouldStart: !( + runtime.apiRunning && + runtime.daemonRunning && + runtime.auctionRunning && + runtime.tournamentRunning + ), shouldStop: false, }; } return { shouldStart: false, - shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning, + shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.auctionRunning || runtime.tournamentRunning, }; }; @@ -273,8 +279,16 @@ const parseInstallOptions = ( }; }; -const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string => - `sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`; +const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'auction' | 'tournament'): string => + `sammo:${profileName}:${ + role === 'api' + ? 'game-api' + : role === 'daemon' + ? 'turn-daemon' + : role === 'auction' + ? 'auction-worker' + : 'tournament-worker' + }`; const isMissingProcessError = (error: unknown): boolean => error instanceof Error && /process or namespace not found/i.test(error.message); @@ -285,11 +299,13 @@ export const buildProcessDefinitions = ( ): { api: { name: string; script: string; cwd: string; env: Record }; daemon: { name: string; script: string; cwd: string; env: Record }; + auction: { name: string; script: string; cwd: string; env: Record }; tournament: { name: string; script: string; cwd: string; env: Record }; } => { const baseEnv = { ...(config.baseEnv ?? {}) }; const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const auctionName = buildProcessName(profile.profileName, 'auction'); const tournamentName = buildProcessName(profile.profileName, 'tournament'); const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot; const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api'); @@ -327,6 +343,15 @@ export const buildProcessDefinitions = ( cwd: daemonCwd, env: daemonEnv, }, + auction: { + name: auctionName, + script: apiScript, + cwd: apiCwd, + env: { + ...apiEnv, + GAME_API_ROLE: 'auction-worker', + }, + }, tournament: { name: tournamentName, script: apiScript, @@ -376,11 +401,13 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map { const apiName = buildProcessName(profileName, 'api'); const daemonName = buildProcessName(profileName, 'daemon'); + const auctionName = buildProcessName(profileName, 'auction'); const tournamentName = buildProcessName(profileName, 'tournament'); return { profileName, apiRunning: processNames.get(apiName) ?? false, daemonRunning: processNames.get(daemonName) ?? false, + auctionRunning: processNames.get(auctionName) ?? false, tournamentRunning: processNames.get(tournamentName) ?? false, }; }); @@ -981,6 +1008,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { try { await this.processManager.start(definitions.api); await this.processManager.start(definitions.daemon); + await this.processManager.start(definitions.auction); await this.processManager.start(definitions.tournament); await this.repository.updateLastError(profile.profileName, null); return true; @@ -996,10 +1024,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async stopProfile(profile: GatewayProfileRecord): Promise { const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const auctionName = buildProcessName(profile.profileName, 'auction'); const tournamentName = buildProcessName(profile.profileName, 'tournament'); const existingNames = new Set((await this.processManager.list()).map((process) => process.name)); const failures: string[] = []; - for (const name of [apiName, daemonName, tournamentName]) { + for (const name of [apiName, daemonName, auctionName, tournamentName]) { if (!existingNames.has(name)) { continue; } diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index 7a47e10..71a7dd8 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -88,6 +88,7 @@ const createHarness = ( ? [ { name: 'sammo:che:2:game-api', status: 'online' }, { name: 'sammo:che:2:turn-daemon', status: 'online' }, + { name: 'sammo:che:2:auction-worker', status: 'online' }, { name: 'sammo:che:2:tournament-worker', status: 'online' }, ] : [], @@ -138,7 +139,7 @@ const createHarness = ( }; describe('GatewayOrchestrator first-class operations', () => { - it('starts both profile processes and records success', async () => { + it('starts all profile processes and records success', async () => { const harness = createHarness(buildOperation('START')); await harness.orchestrator.runOperationsNow(); @@ -147,12 +148,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.started.map((definition) => definition.name)).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); }); - it('stops both profile processes and records success', async () => { + it('stops all profile processes and records success', async () => { const harness = createHarness(buildOperation('STOP')); await harness.orchestrator.runOperationsNow(); @@ -161,11 +163,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.stopped).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); @@ -190,6 +194,7 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); @@ -203,7 +208,7 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.completions).toEqual(['FAILED']); }); - it('attempts to stop both roles before reporting a partial PM2 failure', async () => { + it('attempts to stop every role before reporting a partial PM2 failure', async () => { const harness = createHarness(buildOperation('STOP'), false, true); await harness.orchestrator.runOperationsNow(); @@ -211,11 +216,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.stopped).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:auction-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['FAILED']); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index ae92d15..cb7323f 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -29,6 +29,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: false, + auctionRunning: true, tournamentRunning: true, }) ).toEqual({ shouldStart: true, shouldStop: false }); @@ -39,6 +40,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('PREOPEN', { apiRunning: false, daemonRunning: false, + auctionRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: true, shouldStop: false }); @@ -49,16 +51,29 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: true, + auctionRunning: true, tournamentRunning: true, }) ).toEqual({ shouldStart: false, shouldStop: false }); }); + it('restarts a running profile when only the auction worker is missing', () => { + expect( + planProfileReconcile('RUNNING', { + apiRunning: true, + daemonRunning: true, + auctionRunning: false, + tournamentRunning: true, + }) + ).toEqual({ shouldStart: true, shouldStop: false }); + }); + it('stops processes for non-running profiles', () => { expect( planProfileReconcile('STOPPED', { apiRunning: false, daemonRunning: true, + auctionRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: false, shouldStop: true }); @@ -69,6 +84,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RESERVED', { apiRunning: false, daemonRunning: false, + auctionRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: false, shouldStop: false }); @@ -95,6 +111,11 @@ describe('buildProcessDefinitions', () => { }); expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine')); expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js')); + expect(definitions.auction).toMatchObject({ + cwd: path.join(buildWorkspace, 'app', 'game-api'), + script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), + env: { GAME_API_ROLE: 'auction-worker' }, + }); expect(definitions.tournament).toMatchObject({ cwd: path.join(buildWorkspace, 'app', 'game-api'), script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), @@ -107,6 +128,7 @@ describe('buildProcessDefinitions', () => { expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine')); + expect(definitions.auction.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); }); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index ba61af7..444793f 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({ profileName: 'che:2', apiRunning: runtimeRunning, daemonRunning: runtimeRunning, + auctionRunning: runtimeRunning, tournamentRunning: runtimeRunning, }, }); @@ -212,7 +213,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true }); }); -test('starts and stops both runtime roles through the operation controls', async ({ page }) => { +test('starts and stops all runtime roles through the operation controls', async ({ page }) => { const state: FixtureState = { operations: [], runtimeRunning: false, requestBodies: [] }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 565317c..3509c84 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -70,6 +70,7 @@ type AdminProfile = { runtime: { apiRunning: boolean; daemonRunning: boolean; + auctionRunning: boolean; tournamentRunning: boolean; }; buildCommitSha?: string; @@ -1352,7 +1353,8 @@ onMounted(() => {
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} / - DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / TOURNAMENT: + DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / AUCTION: + {{ profile.runtime.auctionRunning ? 'ON' : 'OFF' }} / TOURNAMENT: {{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 9908d48..bfe0515 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -16,7 +16,12 @@ type Profile = { buildWorkspace?: string; buildError?: string; lastError?: string; - runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }; + runtime: { + apiRunning: boolean; + daemonRunning: boolean; + auctionRunning: boolean; + tournamentRunning: boolean; + }; }; type Scenario = { @@ -377,6 +382,12 @@ onBeforeUnmount(() => { {{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }} +
+
Auction worker
+
+ {{ selectedProfile.runtime.auctionRunning ? 'RUNNING' : 'STOPPED' }} +
+
Tournament worker
{ + const page = await context.newPage(); + await page.goto(targetRoot, { waitUntil: 'networkidle', timeout: 60_000 }); + const globalSalt = await page.locator('#global_salt').inputValue(); + const clientPasswordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const response = await context.request.post(new URL('api.php?path=Login/LoginByID', targetRoot).toString(), { + data: { username, password: clientPasswordHash }, + timeout: 60_000, + }); + const result = await response.json(); + if (!response.ok() || result.result !== true) { + throw new Error('Reference login failed.'); + } + if (allowGeneralCreate) { + const joinUrl = new URL('hwe/v_join.php', targetRoot).toString(); + await page.goto(joinUrl, { waitUntil: 'networkidle', timeout: 60_000 }); + if (page.url().includes('v_join.php')) { + const createButton = page.getByRole('button', { name: '장수 생성', exact: true }); + try { + await createButton.waitFor({ state: 'visible', timeout: 30_000 }); + } catch { + const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300); + throw new Error(`Reference general form did not render: ${page.url()} | ${pageText}`); + } + page.on('dialog', async (dialog) => dialog.accept()); + await createButton.click(); + await page.waitForURL((url) => !url.pathname.endsWith('/v_join.php'), { timeout: 60_000 }); + } + } + await page.close(); +}; + +const roundedRect = (rect) => ({ + x: Math.round(rect.x * 100) / 100, + y: Math.round(rect.y * 100) / 100, + width: Math.round(rect.width * 100) / 100, + height: Math.round(rect.height * 100) / 100, +}); + +const measurePage = async (page, type) => { + const diagnostics = []; + const onPageError = (error) => diagnostics.push(`pageerror: ${error.message}`); + const onConsole = (message) => { + if (message.type() === 'error') { + diagnostics.push(`console: ${message.text()}`); + } + }; + const onResponse = (response) => { + if (response.status() >= 400) { + diagnostics.push(`http ${response.status()}: ${response.url()}`); + } + }; + page.on('pageerror', onPageError); + page.on('console', onConsole); + page.on('response', onResponse); + const relative = type === 'unique' ? 'hwe/v_auction.php?type=unique' : 'hwe/v_auction.php'; + await page.goto(new URL(relative, targetRoot).toString(), { waitUntil: 'networkidle', timeout: 60_000 }); + try { + await page.locator('#container').waitFor({ state: 'visible', timeout: 30_000 }); + } catch { + const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300); + const scripts = await page.locator('script').evaluateAll((elements) => + elements.map((element) => ({ + src: element.getAttribute('src'), + type: element.getAttribute('type'), + length: element.textContent?.length ?? 0, + })) + ); + throw new Error( + `Reference auction did not render: ${page.url()} | ${await page.title()} | ${pageText} | ${JSON.stringify(scripts)} | ${diagnostics.slice(0, 8).join(' | ')}` + ); + } finally { + page.off('pageerror', onPageError); + page.off('console', onConsole); + page.off('response', onResponse); + } + await page.locator('button').first().waitFor({ state: 'visible' }); + await page.evaluate(() => document.fonts.ready); + + const measurement = await page.evaluate(() => { + const rect = (element) => { + if (!(element instanceof HTMLElement)) { + return null; + } + const value = element.getBoundingClientRect(); + return { x: value.x, y: value.y, width: value.width, height: value.height }; + }; + const style = (element) => { + if (!(element instanceof HTMLElement)) { + return null; + } + const value = getComputedStyle(element); + return { + color: value.color, + backgroundColor: value.backgroundColor, + backgroundImage: value.backgroundImage, + borderColor: value.borderColor, + borderWidth: value.borderWidth, + borderRadius: value.borderRadius, + fontFamily: value.fontFamily, + fontSize: value.fontSize, + fontWeight: value.fontWeight, + lineHeight: value.lineHeight, + padding: value.padding, + cursor: value.cursor, + }; + }; + const container = document.querySelector('#container'); + const topBar = container?.firstElementChild ?? null; + const topBarButtons = [...(topBar?.querySelectorAll('button') ?? [])].map((element) => ({ + text: element.textContent?.trim() ?? '', + rect: rect(element), + style: style(element), + })); + const firstButton = document.querySelector('button'); + const firstInput = [...document.querySelectorAll('input')].find( + (element) => element.getBoundingClientRect().width > 0 + ); + const firstAuctionRow = document.querySelector('.auctionItem'); + const firstAuctionRowChildren = [...(firstAuctionRow?.children ?? [])].map((element) => ({ + className: element.className, + rect: rect(element), + style: style(element), + })); + const firstSection = [...document.querySelectorAll('#container > div')].find((element) => + ['쌀 구매', '쌀 판매'].includes(element.textContent?.trim() ?? '') + ); + const directChildren = [...(container?.children ?? [])].slice(0, 12).map((element) => ({ + tag: element.tagName, + className: element.className, + text: element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80) ?? '', + rect: rect(element), + })); + return { + viewport: { width: window.innerWidth, height: window.innerHeight }, + document: { + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + }, + body: { rect: rect(document.body), style: style(document.body) }, + container: { rect: rect(container), style: style(container) }, + topBar: { rect: rect(topBar), style: style(topBar) }, + topBarButtons, + firstButton: { rect: rect(firstButton), style: style(firstButton) }, + firstInput: { rect: rect(firstInput), style: style(firstInput) }, + firstAuctionRow: { rect: rect(firstAuctionRow), style: style(firstAuctionRow) }, + firstAuctionRowChildren, + firstSection: { rect: rect(firstSection), style: style(firstSection) }, + directChildren, + }; + }); + + return { + ...measurement, + body: { + ...measurement.body, + rect: measurement.body.rect ? roundedRect(measurement.body.rect) : null, + }, + container: { + ...measurement.container, + rect: measurement.container.rect ? roundedRect(measurement.container.rect) : null, + }, + topBar: { + ...measurement.topBar, + rect: measurement.topBar.rect ? roundedRect(measurement.topBar.rect) : null, + }, + topBarButtons: measurement.topBarButtons.map((button) => ({ + ...button, + rect: button.rect ? roundedRect(button.rect) : null, + })), + firstButton: { + ...measurement.firstButton, + rect: measurement.firstButton.rect ? roundedRect(measurement.firstButton.rect) : null, + }, + firstInput: { + ...measurement.firstInput, + rect: measurement.firstInput.rect ? roundedRect(measurement.firstInput.rect) : null, + }, + firstAuctionRow: { + ...measurement.firstAuctionRow, + rect: measurement.firstAuctionRow.rect ? roundedRect(measurement.firstAuctionRow.rect) : null, + }, + firstAuctionRowChildren: measurement.firstAuctionRowChildren.map((child) => ({ + ...child, + rect: child.rect ? roundedRect(child.rect) : null, + })), + firstSection: { + ...measurement.firstSection, + rect: measurement.firstSection.rect ? roundedRect(measurement.firstSection.rect) : null, + }, + directChildren: measurement.directChildren.map((child) => ({ + ...child, + rect: child.rect ? roundedRect(child.rect) : null, + })), + }; +}; + +await mkdir(outputRoot, { recursive: true }); +const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); +const results = {}; +try { + for (const viewport of viewports) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + colorScheme: 'dark', + }); + try { + await login(context); + const page = await context.newPage(); + for (const type of ['resource', 'unique']) { + results[`${viewport.name}-${type}`] = await measurePage(page, type); + await page.screenshot({ + path: resolve(outputRoot, `${viewport.name}-${type}.png`), + fullPage: true, + }); + } + } finally { + await context.close(); + } + } +} finally { + await browser.close(); +} + +const outputPath = resolve(outputRoot, 'computed-dom.json'); +await writeFile(outputPath, `${JSON.stringify(results, null, 2)}\n`); +console.log(JSON.stringify({ outputPath, views: Object.keys(results) })); diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index 763154e..782070c 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -451,6 +451,10 @@ describe('auction integration flow', () => { const initialScore = await redis.zScore(keys.timerKey, String(auction.id)); expect(Number(initialScore)).toBe(initialCloseAt.getTime()); + await expect(hostClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow( + '자신이 연 경매에 입찰할 수 없습니다.' + ); + const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidder1.accessToken, }); From f017d78e035ac56119c2bd735a73cd77363eb001 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:49:45 +0000 Subject: [PATCH 2/6] Track legacy page refresh activity --- app/game-api/src/router/public/index.ts | 10 +- app/game-api/src/services/generalAccess.ts | 184 ++++++++++++++++++ app/game-api/src/trpc.ts | 4 + .../generalAccessTracking.integration.test.ts | 71 +++++++ .../test/generalAccessTracking.test.ts | 100 ++++++++++ app/game-frontend/e2e/inGameMenus.spec.ts | 37 +++- app/game-frontend/src/router/index.ts | 38 ++++ 7 files changed, 434 insertions(+), 10 deletions(-) create mode 100644 app/game-api/src/services/generalAccess.ts create mode 100644 app/game-api/test/generalAccessTracking.integration.test.ts create mode 100644 app/game-api/test/generalAccessTracking.test.ts 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-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/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; From fe45b9b7a5bb4ecbb3747866b5f3b46a364fca95 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:49:52 +0000 Subject: [PATCH 3/6] fix: restore lint and test baseline --- app/game-api/src/battleSim/worker.ts | 2 +- app/game-api/src/router/diplomacy/index.ts | 16 +-- app/game-api/src/router/general/index.ts | 10 +- app/game-engine/src/turn/ai/generalAi/core.ts | 2 +- .../nation/assignments/troopAssignments.ts | 2 +- app/game-engine/src/turn/incomeHandler.ts | 15 +-- .../src/turn/unificationHandler.ts | 75 ++++++------ .../src/turn/worldCommandHandler.ts | 4 +- .../databaseCommandQueue.integration.test.ts | 3 +- .../test/nationTurnCompatibility.test.ts | 4 +- .../test/npcNationTechResearch.test.ts | 2 +- .../test/npcNationUprisingUnification.test.ts | 2 +- app/game-engine/vitest.config.ts | 2 + .../components/battle/BattleGeneralCard.vue | 37 +----- app/game-frontend/src/utils/formatLog.ts | 7 +- .../src/views/BattleSimulatorView.vue | 36 +++--- app/game-frontend/src/views/DiplomacyView.vue | 114 +++++++++++++++--- .../src/views/NationAffairsView.vue | 90 ++++++++++---- .../src/views/ScoutMessageView.vue | 8 +- packages/logic/src/constraints/helpers.ts | 7 +- packages/logic/src/war/engine.ts | 8 +- .../scenarios/general_commands_new.test.ts | 3 +- 22 files changed, 269 insertions(+), 180 deletions(-) diff --git a/app/game-api/src/battleSim/worker.ts b/app/game-api/src/battleSim/worker.ts index 6829051..920339e 100644 --- a/app/game-api/src/battleSim/worker.ts +++ b/app/game-api/src/battleSim/worker.ts @@ -43,7 +43,7 @@ export const runBattleSimWorker = async (): Promise => { 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/general/index.ts b/app/game-api/src/router/general/index.ts index 97a0bf9..7c5328f 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { LogCategory, LogScope } from '@sammo-ts/infra'; +import type { GamePrisma } from '@sammo-ts/infra'; import { asRecord } from '@sammo-ts/common'; import { authedProcedure, router } from '../../trpc.js'; @@ -264,9 +265,8 @@ export const generalRouter = router({ const metaRecord = asRecord(general.meta); const prevSettings = asRecord(metaRecord.userSettings); - const prevMyset = typeof prevSettings.myset === 'number' && Number.isFinite(prevSettings.myset) - ? prevSettings.myset - : null; + const prevMyset = + typeof prevSettings.myset === 'number' && Number.isFinite(prevSettings.myset) ? prevSettings.myset : null; const nextSettings = { ...prevSettings, ...input, @@ -281,8 +281,8 @@ export const generalRouter = router({ meta: { ...metaRecord, userSettings: nextSettings, - }, - } as any, + } as GamePrisma.InputJsonValue, + }, }); return { ok: true }; 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 e358955..fd32464 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/src/components/battle/BattleGeneralCard.vue b/app/game-frontend/src/components/battle/BattleGeneralCard.vue index 8f60d54..1d8c95c 100644 --- a/app/game-frontend/src/components/battle/BattleGeneralCard.vue +++ b/app/game-frontend/src/components/battle/BattleGeneralCard.vue @@ -3,13 +3,13 @@ import { computed, ref } from 'vue'; import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes'; interface Props { - general: GeneralDraft; options: BattleSimOptions; mode: 'attacker' | 'defender'; title: string; } const props = defineProps(); +const general = defineModel('general', { required: true }); const emit = defineEmits<{ (event: 'import'): void; @@ -187,21 +187,11 @@ const officerLevelOptions = [
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 9b6dec7..6d91ce9 100644 --- a/app/game-frontend/src/views/BattleSimulatorView.vue +++ b/app/game-frontend/src/views/BattleSimulatorView.vue @@ -542,7 +542,9 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => { const payload = buildBattlePayload(action); const response = await trpc.battle.simulate.mutate(payload); const result = - 'payload' in response && response.payload ? response.payload : await waitForSimulationResult(response.jobId); + 'payload' in response && response.payload + ? response.payload + : await waitForSimulationResult(response.jobId); if (!result.result) { error.value = result.reason || 'battle_failed'; @@ -882,19 +884,21 @@ const summaryRows = computed(() => { { label: '전투 페이즈', value: formatNumber(battleResult.value.phase) }, { label: '준 피해', - value: battleResult.value.minKilled !== battleResult.value.maxKilled - ? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber( - battleResult.value.maxKilled - )})` - : formatNumber(battleResult.value.killed), + value: + battleResult.value.minKilled !== battleResult.value.maxKilled + ? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber( + battleResult.value.maxKilled + )})` + : formatNumber(battleResult.value.killed), }, { label: '받은 피해', - value: battleResult.value.minDead !== battleResult.value.maxDead - ? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber( - battleResult.value.maxDead - )})` - : formatNumber(battleResult.value.dead), + value: + battleResult.value.minDead !== battleResult.value.maxDead + ? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber( + battleResult.value.maxDead + )})` + : formatNumber(battleResult.value.dead), }, { label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) }, { label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) }, @@ -1028,7 +1032,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value); !loading.value && !!options.value); !loading.value && !!options.value);
+
@@ -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(() => {
-