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
+10 -1
View File
@@ -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_해산'],
});
@@ -2204,9 +2204,18 @@ export const createImmediateGeneralActionExecutor = async (options: {
}): Promise<ImmediateGeneralActionExecutor> => {
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();
@@ -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({
@@ -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 });
});
});
+1
View File
@@ -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"