fix: 기본 등용과 대상 임관 명령을 복구

Ref 기본 일반장 명령 46개 exact-set 검사를 추가하고 등용 및 장수대상임관을 사용자 명령표에 복원한다. 실제 DB와 API, 데몬, Chromium을 연결해 등용 서신 수신과 수락 임관까지 검증하며 내부 등용수락 명령의 profile 로딩도 바로잡는다.
This commit is contained in:
2026-08-21 01:33:02 +00:00
parent 7b14585f0d
commit 6db5192a4d
8 changed files with 573 additions and 3 deletions
@@ -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,
},
],
});
@@ -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<void> => {
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<Page> => {
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: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
})
);
return page;
};
const reserveFirstTurn = async (page: Page, commandName: string, targetName: string): Promise<void> => {
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<typeof createGamePostgresConnector>['prisma'];
let closeDb: (() => Promise<void>) | undefined;
let runtime: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | undefined;
let daemonLoop: Promise<void> | 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 });
});
});