From 6db5192a4d0fe557e8875e329e02837ed85373e8 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 01:33:02 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B8=B0=EB=B3=B8=20=EB=93=B1=EC=9A=A9?= =?UTF-8?q?=EA=B3=BC=20=EB=8C=80=EC=83=81=20=EC=9E=84=EA=B4=80=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=9D=84=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref 기본 일반장 명령 46개 exact-set 검사를 추가하고 등용 및 장수대상임관을 사용자 명령표에 복원한다. 실제 DB와 API, 데몬, Chromium을 연결해 등용 서신 수신과 수락 임관까지 검증하며 내부 등용수락 명령의 profile 로딩도 바로잡는다. --- app/game-api/test/commandTable.test.ts | 11 +- .../src/turn/reservedTurnHandler.ts | 11 +- .../test/myInformationCommands.test.ts | 78 +++- ...RecruitCommands.live.playwright.config.mjs | 81 ++++ .../e2e/defaultRecruitCommandsLive.spec.ts | 353 ++++++++++++++++++ app/game-frontend/package.json | 1 + resources/turn-commands/default.json | 2 + tools/compare-general-turn-contracts.mjs | 39 ++ 8 files changed, 573 insertions(+), 3 deletions(-) create mode 100644 app/game-frontend/e2e/defaultRecruitCommands.live.playwright.config.mjs create mode 100644 app/game-frontend/e2e/defaultRecruitCommandsLive.spec.ts diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 3958b1da..bd957195 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -145,7 +145,16 @@ describe('buildTurnCommandTable', () => { 'che_소집해제', 'che_첩보', ], - 인사: ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'], + 인사: [ + 'che_이동', + 'che_강행', + 'che_인재탐색', + 'che_등용', + 'che_귀환', + 'che_임관', + 'che_랜덤임관', + 'che_장수대상임관', + ], 계략: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'], 국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'], }); diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 8043a2a6..79a67641 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -2204,9 +2204,18 @@ export const createImmediateGeneralActionExecutor = async (options: { }): Promise => { const env = buildCommandEnv(options.world.getScenarioConfig(), options.world.getUnitSet()); const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE; + // 등용수락은 예약 화면에 노출되는 명령이 아니라 등용 서신의 응답이 + // 직접 실행하는 내부 명령이다. 선택 가능 명령 프로필에 없더라도 등용 + // 서신을 수락할 수 있도록 즉시 행동 정의에는 항상 포함한다. + const immediateCommandProfile: TurnCommandProfile = commandProfile.general.includes('che_등용수락') + ? commandProfile + : { + ...commandProfile, + general: [...commandProfile.general, 'che_등용수락'], + }; const { general: definitions } = await buildReservedTurnDefinitions({ env, - commandProfile, + commandProfile: immediateCommandProfile, defaultActionKey: DEFAULT_ACTION, }); const generalModuleLoader = new GeneralTurnCommandLoader(); diff --git a/app/game-engine/test/myInformationCommands.test.ts b/app/game-engine/test/myInformationCommands.test.ts index 9dedf035..e7f37371 100644 --- a/app/game-engine/test/myInformationCommands.test.ts +++ b/app/game-engine/test/myInformationCommands.test.ts @@ -133,6 +133,7 @@ const buildWorld = ( const buildImmediateActionWorld = (options: { general: TurnGeneral; + additionalGenerals?: TurnGeneral[]; cities: TurnWorldSnapshot['cities']; nations: TurnWorldSnapshot['nations']; map: MapDefinition; @@ -162,7 +163,7 @@ const buildImmediateActionWorld = (options: { ignoreDefaultEvents: false, }; const snapshot: TurnWorldSnapshot = { - generals: [options.general], + generals: [options.general, ...(options.additionalGenerals ?? [])], cities: options.cities, nations: options.nations, troops: [], @@ -480,6 +481,81 @@ describe('my information world commands', () => { expect(nextIntInclusive).not.toHaveBeenCalled(); }); + it('loads the internal recruitment acceptance action outside the selectable command profile', async () => { + const recipient = buildGeneral({ + id: 8, + userId: 'user-8', + name: '재야장수', + nationId: 0, + cityId: 1, + officerLevel: 0, + }); + const recruiter = buildGeneral({ + id: 9, + userId: 'user-9', + name: '등용장수', + nationId: 2, + cityId: 2, + }); + const map = { + id: 'test', + name: 'test', + cities: [buildMapCity(1, [2]), buildMapCity(2, [1])], + }; + const fixture = buildImmediateActionWorld({ + general: recipient, + additionalGenerals: [recruiter], + cities: [ + { id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} }, + { id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} }, + ] as TurnWorldSnapshot['cities'], + nations: [ + { + id: 2, + name: '등용국', + color: '#222222', + typeCode: 'che_중립', + level: 1, + capitalCityId: 2, + chiefGeneralId: recruiter.id, + gold: 0, + rice: 0, + power: 0, + meta: { gennum: 1 }, + }, + ] as TurnWorldSnapshot['nations'], + map, + }); + const executor = await createImmediateGeneralActionExecutor({ + world: fixture.world, + reservedTurns: fixture.reservedTurns, + scenarioMeta: fixture.scenarioMeta, + map, + commandProfile: { + general: ['che_등용'], + nation: [], + }, + }); + + await expect( + executor.execute({ + actionKey: 'che_등용수락', + generalId: recipient.id, + rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')), + args: { destNationId: 2, destGeneralId: recruiter.id }, + }) + ).resolves.toEqual({ ok: true }); + expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({ + nationId: 2, + cityId: 2, + officerLevel: 1, + }); + expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({ + experience: recruiter.experience + 100, + dedication: recruiter.dedication + 100, + }); + }); + it('preserves the Ref uprising precheck order and messages after the game starts', async () => { const general = buildGeneral({ nationId: 1, cityId: 1 }); const fixture = buildImmediateActionWorld({ diff --git a/app/game-frontend/e2e/defaultRecruitCommands.live.playwright.config.mjs b/app/game-frontend/e2e/defaultRecruitCommands.live.playwright.config.mjs new file mode 100644 index 00000000..a23492e1 --- /dev/null +++ b/app/game-frontend/e2e/defaultRecruitCommands.live.playwright.config.mjs @@ -0,0 +1,81 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const frontendPort = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15154); +const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15155); +const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`; +const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'default_recruit_commands_live_integration'; +const scenario = process.env.PLAYWRIGHT_SCENARIO ?? '2'; +const expectedGameProfile = `${profileId}:${scenario}`; +const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? expectedGameProfile; +const baseURL = `http://127.0.0.1:${frontendPort}${basePath}/`; +const gameApiUrl = `http://127.0.0.1:${apiPort}/trpc`; +const databaseUrl = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_DATABASE_URL ?? ''; +const redisUrl = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_REDIS_URL ?? ''; +const gameSecret = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_GAME_SECRET ?? ''; +const imageUploadSecretFile = process.env.GAME_IMAGE_UPLOAD_SECRET_FILE ?? ''; +const databaseSchema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null; + +if (databaseUrl && databaseSchema !== profileId) { + throw new Error( + `Default recruit commands live schema must match its profile ID: ${databaseSchema ?? '(missing)'} != ${profileId}` + ); +} +if (gameProfile !== expectedGameProfile) { + throw new Error( + `PLAYWRIGHT_GAME_PROFILE must match profile and scenario: ${gameProfile} != ${expectedGameProfile}` + ); +} + +export default defineConfig({ + testDir: '.', + testMatch: ['defaultRecruitCommandsLive.spec.ts'], + fullyParallel: false, + workers: 1, + timeout: 90_000, + expect: { + timeout: 15_000, + }, + reporter: [['list']], + outputDir: resolve(repositoryRoot, 'test-results/default-recruit-commands-live'), + use: { + baseURL, + ...devices['Desktop Chrome'], + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: [ + { + command: 'node app/game-api/dist/index.js', + cwd: repositoryRoot, + url: `http://127.0.0.1:${apiPort}/healthz`, + reuseExistingServer: false, + timeout: 120_000, + env: { + DATABASE_URL: databaseUrl, + REDIS_URL: redisUrl, + GAME_TOKEN_SECRET: gameSecret, + GAME_IMAGE_UPLOAD_SECRET_FILE: imageUploadSecretFile, + GAME_API_ROLE: 'server', + GAME_API_HOST: '127.0.0.1', + GAME_API_PORT: String(apiPort), + PROFILE: profileId, + SCENARIO: scenario, + GAME_PROFILE_NAME: gameProfile, + DAEMON_REQUEST_TIMEOUT_MS: '15000', + }, + }, + { + command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${frontendPort}`, + cwd: repositoryRoot, + url: baseURL, + reuseExistingServer: false, + timeout: 120_000, + }, + ], +}); diff --git a/app/game-frontend/e2e/defaultRecruitCommandsLive.spec.ts b/app/game-frontend/e2e/defaultRecruitCommandsLive.spec.ts new file mode 100644 index 00000000..60cdbab8 --- /dev/null +++ b/app/game-frontend/e2e/defaultRecruitCommandsLive.spec.ts @@ -0,0 +1,353 @@ +import { randomUUID } from 'node:crypto'; + +import { expect, test, type Browser, type Page } from '@playwright/test'; +import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js'; +import { createTurnDaemonRuntime, seedScenarioToDatabase } from '../../game-engine/dist/index.js'; +import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js'; + +const databaseUrl = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_DATABASE_URL; +const redisUrl = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_REDIS_URL; +const gameTokenSecret = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_GAME_SECRET; +const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'default_recruit_commands_live_integration:2'; +const scenarioId = Number(process.env.PLAYWRIGHT_SCENARIO ?? '2'); +const hasLiveFixture = Boolean(databaseUrl && redisUrl && gameTokenSecret); +const recruiterId = 7_751; +const recipientId = 7_752; +const followerId = 7_753; +const fixtureNationId = 9_917; +const recruiterUserId = 'default-recruit-live-recruiter'; +const recipientUserId = 'default-recruit-live-recipient'; +const followerUserId = 'default-recruit-live-follower'; +const fixtureNow = new Date('2099-08-01T00:00:00.000Z'); +const dueAt = new Date('2099-08-01T00:01:00.000Z'); +const runThrough = new Date('2099-08-01T00:02:00.000Z'); + +const installSession = async (page: Page, userId: string, displayName: string): Promise => { + const issuedAt = new Date(); + const token = encryptGameSessionToken( + { + version: 1, + profile, + issuedAt: issuedAt.toISOString(), + expiresAt: new Date(issuedAt.getTime() + 3_600_000).toISOString(), + sessionId: `default-recruit-live-${randomUUID()}`, + user: { + id: userId, + username: userId, + displayName, + roles: ['user'], + canUseGeneralPicture: false, + }, + sanctions: {}, + identity: { + kakaoVerified: true, + canCreateGeneral: true, + requiresKakaoVerification: false, + graceEndsAt: null, + }, + }, + gameTokenSecret! + ); + await page.addInitScript( + ({ gameToken, gameProfile }) => { + localStorage.setItem('sammo-game-token', gameToken); + localStorage.setItem('sammo-game-profile', gameProfile); + }, + { gameToken: token, gameProfile: profile } + ); +}; + +const newPage = async (browser: Browser, userId: string, displayName: string): Promise => { + const context = await browser.newContext({ + viewport: { width: 1365, height: 1000 }, + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + }); + const page = await context.newPage(); + await installSession(page, userId, displayName); + await page.route('**/image/**', (route) => + route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: '', + }) + ); + return page; +}; + +const reserveFirstTurn = async (page: Page, commandName: string, targetName: string): Promise => { + await page.goto('.'); + const commandPanel = page.locator('[data-main-target="commands"]'); + await expect(commandPanel).toBeVisible(); + await commandPanel.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await expect(picker).toBeVisible(); + await picker.getByRole('button', { name: '인사', exact: true }).click(); + await picker.getByRole('button', { name: commandName, exact: true }).click(); + const targetOption = picker.locator('select option').filter({ hasText: targetName }); + await expect(targetOption).toHaveCount(1); + const targetValue = await targetOption.getAttribute('value'); + if (!targetValue) throw new Error(`Missing target value for ${targetName}.`); + await picker.locator('select').selectOption(targetValue); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + await expect(picker).toBeHidden(); +}; + +test.describe('default recruitment commands through live PostgreSQL, Redis, API, daemon, and Chromium', () => { + test.skip(!hasLiveFixture, 'dedicated PostgreSQL, Redis, and a game token secret are required'); + + let db: ReturnType['prisma']; + let closeDb: (() => Promise) | undefined; + let runtime: Awaited> | undefined; + let daemonLoop: Promise | undefined; + let nationId = 0; + let nationName = ''; + let capitalCityId = 0; + + test.beforeAll(async () => { + const schema = new URL(databaseUrl!).searchParams.get('schema'); + const profileId = profile.split(':', 1)[0] ?? ''; + if (schema !== profileId || !schema.endsWith('default_recruit_commands_live_integration')) { + throw new Error(`Refusing mismatched or non-dedicated schema: ${schema ?? '(missing)'} != ${profileId}`); + } + const previousSeed = process.env.INTEGRATION_WORLD_SEED; + process.env.INTEGRATION_WORLD_SEED = 'default-recruit-commands-live-seed'; + try { + await seedScenarioToDatabase({ + scenarioId, + databaseUrl: databaseUrl!, + now: fixtureNow, + gameClockMode: 'manual', + installOptions: { + turnTermMinutes: 5, + joinMode: 'full', + npcMode: 0, + showImgLevel: 3, + serverId: profile, + season: 1, + }, + }); + } finally { + if (previousSeed === undefined) delete process.env.INTEGRATION_WORLD_SEED; + else process.env.INTEGRATION_WORLD_SEED = previousSeed; + } + + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.inputEvent.deleteMany(); + await db.message.deleteMany(); + await db.generalTurn.deleteMany({ where: { generalId: { in: [recruiterId, recipientId, followerId] } } }); + await db.generalTurnRevision.deleteMany({ + where: { generalId: { in: [recruiterId, recipientId, followerId] } }, + }); + await db.general.deleteMany({ where: { id: { in: [recruiterId, recipientId, followerId] } } }); + await db.general.updateMany({ + data: { turnTime: new Date('2199-01-01T00:00:00.000Z'), turnTick: null }, + }); + + const capitalCity = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } }); + nationId = fixtureNationId; + nationName = '실등용국'; + capitalCityId = capitalCity.id; + await db.nation.upsert({ + where: { id: fixtureNationId }, + create: { + id: fixtureNationId, + name: nationName, + color: '#225500', + capitalCityId, + level: 1, + meta: { gennum: 0, scout: 0 }, + }, + update: { name: nationName, capitalCityId, level: 1, meta: { gennum: 0, scout: 0 } }, + }); + await db.city.update({ + where: { id: capitalCityId }, + data: { nationId, supplyState: 1 }, + }); + + const baseGeneral = { + cityId: capitalCityId, + troopId: 0, + npcState: 0, + leadership: 70, + strength: 60, + intel: 50, + officerLevel: 0, + experience: 100, + dedication: 100, + gold: 10_000, + rice: 10_000, + turnTime: dueAt, + turnTick: null, + meta: { killturn: 960, belong: 1 }, + penalty: {}, + } as const; + await db.general.createMany({ + data: [ + { + ...baseGeneral, + id: recruiterId, + userId: recruiterUserId, + name: '실등용제안자', + nationId, + officerLevel: 1, + }, + { + ...baseGeneral, + id: recipientId, + userId: recipientUserId, + name: '실등용수신자', + nationId: 0, + }, + { + ...baseGeneral, + id: followerId, + userId: followerUserId, + name: '실대상임관자', + nationId: 0, + }, + ], + }); + await db.generalTurn.createMany({ + data: [recruiterId, recipientId, followerId].map((generalId) => ({ + generalId, + turnIdx: 0, + actionCode: '휴식', + arg: {}, + })), + }); + await db.generalTurnRevision.createMany({ + data: [recruiterId, recipientId, followerId].map((generalId) => ({ generalId, revision: 1 })), + }); + }); + + test.afterAll(async () => { + if (runtime) { + await runtime.lifecycle.stop('default recruit commands live complete'); + await daemonLoop; + await runtime.close(); + } + await closeDb?.(); + }); + + test('shows both commands, delivers the recruitment letter, and appoints on acceptance', async ({ + browser, + }, testInfo) => { + const recruiterPage = await newPage(browser, recruiterUserId, '실등용제안자'); + await reserveFirstTurn(recruiterPage, '등용', '실등용수신자'); + await expect( + db.generalTurn.findUniqueOrThrow({ + where: { generalId_turnIdx: { generalId: recruiterId, turnIdx: 0 } }, + }) + ).resolves.toMatchObject({ + actionCode: 'che_등용', + arg: { destGeneralId: recipientId }, + }); + + const followerPage = await newPage(browser, followerUserId, '실대상임관자'); + await reserveFirstTurn(followerPage, '장수를 따라 임관', '실등용제안자'); + await expect( + db.generalTurn.findUniqueOrThrow({ + where: { generalId_turnIdx: { generalId: followerId, turnIdx: 0 } }, + }) + ).resolves.toMatchObject({ + actionCode: 'che_장수대상임관', + arg: { destGeneralID: recruiterId }, + }); + + runtime = await createTurnDaemonRuntime({ + profile, + databaseUrl: databaseUrl!, + redisUrl: redisUrl!, + enableDatabaseFlush: true, + enableLeaseHeartbeat: false, + exclusiveFastForward: true, + leaseOwnerId: 'default-recruit-commands-live-daemon', + }); + daemonLoop = runtime.lifecycle.start(); + await expect.poll(() => runtime?.lifecycle.getStatus().lastTurnTime).toBeTruthy(); + runtime.lifecycle.requestRun('manual', runThrough); + await expect + .poll(() => { + const status = runtime?.lifecycle.getStatus(); + return status?.lastError ?? status?.lastRunAt ?? null; + }) + .not.toBeNull(); + expect(runtime.lifecycle.getStatus().lastError).toBeUndefined(); + + await expect + .poll(() => db.general.findUniqueOrThrow({ where: { id: followerId } })) + .toMatchObject({ + nationId, + cityId: capitalCityId, + officerLevel: 1, + }); + await expect + .poll(() => + db.message.findFirst({ + where: { mailbox: recipientId, type: 'private' }, + orderBy: { id: 'desc' }, + }) + ) + .not.toBeNull(); + const storedMessage = await db.message.findFirstOrThrow({ + where: { mailbox: recipientId, type: 'private' }, + orderBy: { id: 'desc' }, + }); + expect(storedMessage.message).toMatchObject({ + src: { generalId: recruiterId, nationId }, + dest: { generalId: recipientId, nationId: 0 }, + option: { action: 'scout' }, + }); + const recipientPage = await newPage(browser, recipientUserId, '실등용수신자'); + const dialogs: string[] = []; + recipientPage.on('dialog', async (dialog) => { + dialogs.push(dialog.message()); + await dialog.accept(); + }); + await recipientPage.goto('.'); + await expect(recipientPage.getByRole('heading', { name: '전장 현황' })).toBeVisible(); + const letter = recipientPage.locator('.PrivateTalk .msg-plate').filter({ hasText: '망명 권유 서신' }); + await expect(letter).toHaveCount(1); + await expect(letter).toHaveAttribute('data-id', String(storedMessage.id)); + const accept = letter.getByRole('button', { name: '수락', exact: true }); + await expect(accept).toBeEnabled(); + await accept.click(); + + await expect + .poll(() => db.general.findUniqueOrThrow({ where: { id: recipientId } })) + .toMatchObject({ + nationId, + cityId: capitalCityId, + officerLevel: 1, + }); + expect(dialogs).toEqual(['수락하시겠습니까?']); + const invalidated = await db.message.findUniqueOrThrow({ where: { id: storedMessage.id } }); + expect(invalidated.validUntil.getTime()).toBeLessThan(storedMessage.validUntil.getTime()); + await expect( + db.inputEvent.findFirstOrThrow({ + where: { actorUserId: recipientUserId, eventType: 'messageRespond' }, + orderBy: { createdAt: 'desc' }, + }) + ).resolves.toMatchObject({ status: 'SUCCEEDED', attempts: 1, target: 'ENGINE' }); + await expect( + db.message.findFirst({ + where: { + mailbox: recipientId, + id: { not: storedMessage.id }, + }, + orderBy: { id: 'desc' }, + }) + ).resolves.toMatchObject({ + message: expect.objectContaining({ text: `${nationName}으로 등용 제의 수락` }), + }); + + await recruiterPage.screenshot({ path: testInfo.outputPath('recruit-command-reserved.png'), fullPage: true }); + await followerPage.screenshot({ path: testInfo.outputPath('follow-appointment-complete.png'), fullPage: true }); + await recipientPage.screenshot({ path: testInfo.outputPath('recruitment-accepted.png'), fullPage: true }); + }); +}); diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 992a9a0f..9e9c26fb 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -24,6 +24,7 @@ "test:e2e:join-layout": "playwright test joinLayout.spec.ts --config e2e/playwright.config.mjs", "test:e2e:npc-possession-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/npcPossession.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:die-on-prestart-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/dieOnPrestart.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", + "test:e2e:default-recruit-commands-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/defaultRecruitCommands.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "lint": "eslint .", "lint:fix": "eslint . --fix", "typecheck": "vue-tsc --noEmit" diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json index 6553d1f8..0af86ff2 100644 --- a/resources/turn-commands/default.json +++ b/resources/turn-commands/default.json @@ -2,6 +2,7 @@ "general": [ "che_거병", "che_임관", + "che_장수대상임관", "che_랜덤임관", "che_귀환", "che_건국", @@ -30,6 +31,7 @@ "che_화계", "che_집합", "che_인재탐색", + "che_등용", "che_징병", "che_모병", "che_소집해제", diff --git a/tools/compare-general-turn-contracts.mjs b/tools/compare-general-turn-contracts.mjs index 1fb0417c..00e1f98e 100644 --- a/tools/compare-general-turn-contracts.mjs +++ b/tools/compare-general-turn-contracts.mjs @@ -6,6 +6,8 @@ const root = process.cwd(); const refRoot = resolveRefRoot(root); const phpDir = path.join(refRoot, 'hwe/sammo/Command/General'); const tsDir = path.join(root, 'packages/logic/src/actions/turn/general'); +const refGameConstPath = path.join(refRoot, 'hwe/sammo/GameConstBase.php'); +const defaultProfilePath = path.join(root, 'resources/turn-commands/default.json'); const check = process.argv.includes('--check'); const activeAction = new Map([ @@ -39,6 +41,31 @@ const readSources = async (dir, extension) => { const php = await readSources(phpDir, '.php'); const ts = await readSources(tsDir, '.ts'); const handlerSource = await fs.readFile(path.join(root, 'app/game-engine/src/turn/reservedTurnHandler.ts'), 'utf8'); +const refGameConstSource = await fs.readFile(refGameConstPath, 'utf8'); +const defaultProfile = JSON.parse(await fs.readFile(defaultProfilePath, 'utf8')); + +const generalProfileStart = refGameConstSource.indexOf('public static $availableGeneralCommand'); +const generalProfileEnd = refGameConstSource.indexOf('public static $availableChiefCommand', generalProfileStart); +if (generalProfileStart < 0 || generalProfileEnd < 0) { + throw new Error(`Unable to locate Ref availableGeneralCommand in ${refGameConstPath}`); +} +const refDefaultGeneralCommands = [ + ...refGameConstSource.slice(generalProfileStart, generalProfileEnd).matchAll(/^\s*'([^']+)',?\s*$/gm), +].map((match) => match[1]); +const coreDefaultGeneralCommands = Array.isArray(defaultProfile.general) + ? defaultProfile.general.filter((value) => typeof value === 'string') + : []; +const refDefaultGeneralCommandSet = new Set(refDefaultGeneralCommands); +const coreDefaultGeneralCommandSet = new Set(coreDefaultGeneralCommands); +const missingDefaultGeneralCommands = refDefaultGeneralCommands.filter( + (command) => !coreDefaultGeneralCommandSet.has(command) +); +const extraDefaultGeneralCommands = coreDefaultGeneralCommands.filter( + (command) => !refDefaultGeneralCommandSet.has(command) +); +const duplicateDefaultGeneralCommands = coreDefaultGeneralCommands.filter( + (command, index) => coreDefaultGeneralCommands.indexOf(command) !== index +); const phpParent = new Map(); for (const [key, source] of php) { @@ -127,6 +154,15 @@ const dynamicChecks = [ console.log(`General command inventory: PHP ${php.size}, TS ${ts.size}`); console.log(`Missing TS: ${missingTs.length}; Extra TS: ${extraTs.length}`); +console.log( + `Default general command inventory: Ref ${refDefaultGeneralCommands.length}, Core ${coreDefaultGeneralCommands.length}` +); +console.log( + `Default profile missing: ${missingDefaultGeneralCommands.length}; extra: ${extraDefaultGeneralCommands.length}; duplicate: ${duplicateDefaultGeneralCommands.length}` +); +for (const command of missingDefaultGeneralCommands) console.log(`default missing ${command}`); +for (const command of extraDefaultGeneralCommands) console.log(`default extra ${command}`); +for (const command of duplicateDefaultGeneralCommands) console.log(`default duplicate ${command}`); console.log(`Timing mismatches: ${timingMismatches.length}`); console.log(`Active-action mismatches: ${activeMismatches.length}`); for (const mismatch of timingMismatches) console.log('timing', JSON.stringify(mismatch)); @@ -137,6 +173,9 @@ if ( check && (missingTs.length > 0 || extraTs.length > 0 || + missingDefaultGeneralCommands.length > 0 || + extraDefaultGeneralCommands.length > 0 || + duplicateDefaultGeneralCommands.length > 0 || timingMismatches.length > 0 || activeMismatches.length > 0 || dynamicChecks.some((item) => !item.ok))