feat: port NPC possession through turn daemon
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
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 ?? 15134);
|
||||
const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15135);
|
||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'hwe').replace(/^\/+|\/+$/g, '')}`;
|
||||
const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'npc_possession_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.NPC_POSSESSION_LIVE_DATABASE_URL ?? '';
|
||||
const redisUrl = process.env.NPC_POSSESSION_LIVE_REDIS_URL ?? '';
|
||||
const gameSecret = process.env.NPC_POSSESSION_LIVE_GAME_SECRET ?? '';
|
||||
const databaseSchema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||
|
||||
if (databaseUrl && databaseSchema !== profileId) {
|
||||
throw new Error(
|
||||
`NPC possession live schema must exactly match PLAYWRIGHT_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: ['npcPossessionLive.spec.ts'],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
reporter: [['list']],
|
||||
outputDir: resolve(repositoryRoot, 'test-results/npc-possession-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_API_HOST: '127.0.0.1',
|
||||
GAME_API_PORT: String(apiPort),
|
||||
PROFILE: profileId,
|
||||
SCENARIO: scenario,
|
||||
GAME_PROFILE_NAME: gameProfile,
|
||||
},
|
||||
},
|
||||
{
|
||||
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,359 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||
const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
|
||||
type FixtureState = {
|
||||
reservationCalls: number;
|
||||
reservationInputs: Array<Record<string, unknown>>;
|
||||
rawBodies: unknown[];
|
||||
possessInputs: Array<Record<string, unknown>>;
|
||||
hasGeneral: boolean;
|
||||
injectTimeout: boolean;
|
||||
};
|
||||
|
||||
const candidates = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `빙의후보${index + 1}`,
|
||||
nation: { id: 0, name: '재야', color: '#aaaaaa' },
|
||||
stats: {
|
||||
leadership: 40 + index,
|
||||
strength: 50 + index,
|
||||
intelligence: 60 + index,
|
||||
},
|
||||
picture: 'default.jpg',
|
||||
imageServer: index === 0 ? 1 : 0,
|
||||
personality: { code: 'che_안전', name: '안전', info: '안전을 중시합니다.' },
|
||||
specialDomestic: { code: 'che_인덕', name: '인덕', info: '인덕 설명' },
|
||||
specialWar: { code: 'che_무쌍', name: '무쌍', info: '무쌍 설명' },
|
||||
keepCount: 3,
|
||||
}));
|
||||
const generalList = Array.from({ length: 55 }, (_, index) => {
|
||||
const candidate = candidates[index % candidates.length]!;
|
||||
return {
|
||||
id: index + 1,
|
||||
name: `전체장수${String(index + 1).padStart(2, '0')}`,
|
||||
picture: candidate.picture,
|
||||
imageServer: candidate.imageServer,
|
||||
npcState: index === 54 ? 1 : 2,
|
||||
ownerName: index === 54 ? '빙의자' : '',
|
||||
age: 25 + (index % 20),
|
||||
level: 3 + (index % 5),
|
||||
officerLevel: index % 5,
|
||||
killturn: index % 10,
|
||||
nationId: 0,
|
||||
nationName: '재야',
|
||||
nationLevel: 0,
|
||||
personality: { key: 'che_안전', name: '안전', info: '안전을 중시합니다.' },
|
||||
specialDomestic: { key: 'che_인덕', name: '인덕', info: '인덕 설명' },
|
||||
specialWar: { key: 'che_무쌍', name: '무쌍', info: '무쌍 설명' },
|
||||
statTotal: 150 + index,
|
||||
leadership: 40 + (index % 30),
|
||||
strength: 50 + (index % 20),
|
||||
intelligence: 60 + (index % 10),
|
||||
experience: 800 + index,
|
||||
experienceText: '무명',
|
||||
dedication: 700 + index,
|
||||
dedicationText: '28품관',
|
||||
};
|
||||
});
|
||||
generalList.push({
|
||||
...generalList[0]!,
|
||||
id: 56,
|
||||
name: '사용자장수',
|
||||
npcState: 0,
|
||||
ownerName: '',
|
||||
statTotal: 230,
|
||||
leadership: 80,
|
||||
strength: 70,
|
||||
intelligence: 80,
|
||||
experience: 16_000,
|
||||
experienceText: '지역적',
|
||||
dedication: 10_000,
|
||||
dedicationText: '21품관',
|
||||
});
|
||||
|
||||
const findInput = (value: unknown): Record<string, unknown> => {
|
||||
if (!value || typeof value !== 'object') return {};
|
||||
if ('json' in value && value.json && typeof value.json === 'object') {
|
||||
return value.json as Record<string, unknown>;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
['refresh', 'keepIds', 'generalId', 'tokenNonce', 'clientRequestId'].some((key) => Object.hasOwn(record, key))
|
||||
) {
|
||||
return record;
|
||||
}
|
||||
for (const nested of Object.values(value)) {
|
||||
const input = findInput(nested);
|
||||
if (Object.keys(input).length > 0) return input;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page, state: FixtureState): Promise<void> => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_npc_possession');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
await page.route('**/image/**', async (route) => {
|
||||
await 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>',
|
||||
});
|
||||
});
|
||||
await page.route('**/gateway/api/user-icons/default.jpg', async (route) => {
|
||||
await 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="#8855aa"/></svg>',
|
||||
});
|
||||
});
|
||||
await page.route('**/events**', async (route) => route.abort());
|
||||
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
|
||||
const operations = operationNames(route);
|
||||
const rawBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
|
||||
state.rawBodies.push(rawBody);
|
||||
const body = rawBody && typeof rawBody === 'object' ? (rawBody as Record<string, unknown>) : {};
|
||||
const results = operations.map((operation, index) => {
|
||||
const input = findInput(body[String(index)] ?? body);
|
||||
if (operation === 'lobby.info') {
|
||||
return response({
|
||||
myGeneral: state.hasGeneral ? { id: 1, name: '빙의후보1' } : null,
|
||||
year: 180,
|
||||
month: 1,
|
||||
turnTerm: 5,
|
||||
});
|
||||
}
|
||||
if (operation === 'join.getConfig') {
|
||||
return response({
|
||||
rules: {
|
||||
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
|
||||
allowCustomName: true,
|
||||
},
|
||||
user: { id: 'npc-user', displayName: '빙의사용자', canCreateGeneral: true },
|
||||
personalities: [{ key: 'Random', name: '???', info: '' }],
|
||||
warSpecials: [],
|
||||
nations: [],
|
||||
serverInfo: {
|
||||
currentYear: 180,
|
||||
currentMonth: 1,
|
||||
tickMinutes: 5,
|
||||
maxGeneral: 500,
|
||||
userGeneralCount: 0,
|
||||
npcGeneralCount: 12,
|
||||
},
|
||||
inherit: {
|
||||
totalPoint: 0,
|
||||
costs: {
|
||||
inheritBornSpecialPoint: 0,
|
||||
inheritBornTurntimePoint: 0,
|
||||
inheritBornCityPoint: 0,
|
||||
inheritBornStatPoint: 0,
|
||||
},
|
||||
availableCities: [],
|
||||
turnTimeZones: [],
|
||||
availableSpecialWar: [],
|
||||
},
|
||||
selectionPool: { enabled: false },
|
||||
npcPossession: { enabled: true },
|
||||
});
|
||||
}
|
||||
if (operation === 'join.listPossessCandidates') {
|
||||
state.reservationCalls += 1;
|
||||
state.reservationInputs.push(input);
|
||||
const refresh = input.refresh === true;
|
||||
const now = Date.now();
|
||||
const keepIds = Array.isArray(input.keepIds) ? input.keepIds : [];
|
||||
return response({
|
||||
tokenNonce: refresh ? 202 : 101,
|
||||
validUntil: new Date(now + 90_000).toISOString(),
|
||||
pickMoreFrom: new Date(refresh ? now + 1_200 : now - 1_000).toISOString(),
|
||||
pickMoreSeconds: refresh ? 2 : 0,
|
||||
candidates: candidates.map((candidate) => ({
|
||||
...candidate,
|
||||
keepCount: refresh && keepIds.includes(candidate.id) ? 2 : 3,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (operation === 'public.getNpcList') {
|
||||
return response({
|
||||
sort: 1,
|
||||
generals: generalList,
|
||||
tokenKeepCounts: { 1: 2, 2: 1 },
|
||||
});
|
||||
}
|
||||
if (operation === 'join.possessGeneral') {
|
||||
state.possessInputs.push(input);
|
||||
if (state.injectTimeout) {
|
||||
state.injectTimeout = false;
|
||||
return {
|
||||
error: {
|
||||
message:
|
||||
'NPC 빙의 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
|
||||
code: -32008,
|
||||
data: {
|
||||
code: 'TIMEOUT',
|
||||
httpStatus: 408,
|
||||
path: 'join.possessGeneral',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
state.hasGeneral = true;
|
||||
return response({ ok: true, generalId: Number(input.generalId) });
|
||||
}
|
||||
return response({});
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(operations.length === 1 ? results[0] : results),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
test('renders Ref-shaped token cards, preserves keep cooldown and retries possession with one ID', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const state: FixtureState = {
|
||||
reservationCalls: 0,
|
||||
reservationInputs: [],
|
||||
rawBodies: [],
|
||||
possessInputs: [],
|
||||
hasGeneral: false,
|
||||
injectTimeout: true,
|
||||
};
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1024, height: 900 });
|
||||
await page.goto('join?tab=possess');
|
||||
await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/);
|
||||
|
||||
await expect(page.locator('.npc-card')).toHaveCount(5);
|
||||
await expect(page.getByText(/까지 유효/)).toBeVisible();
|
||||
const geometry = await page.locator('.npc-possession-section').evaluate((section) => {
|
||||
const sectionRect = section.getBoundingClientRect();
|
||||
const cards = [...section.querySelectorAll<HTMLElement>('.npc-card')];
|
||||
const image = section.querySelector<HTMLImageElement>('.npc-card-image');
|
||||
return {
|
||||
sectionWidth: sectionRect.width,
|
||||
cardWidths: cards.map((card) => card.getBoundingClientRect().width),
|
||||
imageWidth: image?.getBoundingClientRect().width,
|
||||
imageHeight: image?.getBoundingClientRect().height,
|
||||
imageNaturalWidth: image?.naturalWidth,
|
||||
imageNaturalHeight: image?.naturalHeight,
|
||||
};
|
||||
});
|
||||
expect(geometry).toEqual({
|
||||
sectionWidth: 1000,
|
||||
cardWidths: [125, 125, 125, 125, 125],
|
||||
imageWidth: 64,
|
||||
imageHeight: 64,
|
||||
imageNaturalWidth: 64,
|
||||
imageNaturalHeight: 64,
|
||||
});
|
||||
const tooltip = page.locator('.npc-tooltip').first();
|
||||
const tooltipPopup = tooltip.getByRole('tooltip');
|
||||
await expect(tooltipPopup).toBeHidden();
|
||||
await tooltip.hover();
|
||||
await expect(tooltipPopup).toBeVisible();
|
||||
await tooltip.focus();
|
||||
await expect(tooltip).toBeFocused();
|
||||
await expect(tooltipPopup).toHaveText('안전을 중시합니다.');
|
||||
await expect(page.locator('.npc-card-image').first()).toHaveAttribute('src', '/gateway/api/user-icons/default.jpg');
|
||||
|
||||
await page.locator('#btn-load-general-list').click();
|
||||
await expect(page.locator('#tb-general-list')).toBeVisible();
|
||||
await expect(page.locator('#tb-general-list tbody tr')).toHaveCount(50);
|
||||
await expect(page.locator('#tb-general-list tbody tr').first()).toHaveAttribute('data-general-id', '56');
|
||||
await expect(page.locator('#tb-general-list tbody tr').first()).toHaveAttribute('data-reservation-state', '2');
|
||||
const selectedRow = page.locator('#tb-general-list tbody tr[data-general-id="1"]');
|
||||
await expect(selectedRow).toHaveAttribute('data-reservation-state', '1');
|
||||
await expect(selectedRow.locator('.npc-general-name')).toHaveCSS('color', 'rgb(238, 130, 238)');
|
||||
await expect(selectedRow.locator('.npc-general-name')).toContainText('(2회)');
|
||||
const listGeometry = await page.locator('#tb-general-list').evaluate((table) => {
|
||||
const rect = table.getBoundingClientRect();
|
||||
const icon = table.querySelector<HTMLImageElement>('.npc-general-icon');
|
||||
return {
|
||||
width: rect.width,
|
||||
iconWidth: icon?.getBoundingClientRect().width,
|
||||
iconHeight: icon?.getBoundingClientRect().height,
|
||||
iconNaturalWidth: icon?.naturalWidth,
|
||||
iconNaturalHeight: icon?.naturalHeight,
|
||||
};
|
||||
});
|
||||
expect(listGeometry).toEqual({
|
||||
width: 970,
|
||||
iconWidth: 64,
|
||||
iconHeight: 64,
|
||||
iconNaturalWidth: 64,
|
||||
iconNaturalHeight: 64,
|
||||
});
|
||||
await page.locator('#btn-print-more-generals').click();
|
||||
await expect(page.locator('#tb-general-list tbody tr')).toHaveCount(56);
|
||||
await expect(page.locator('#btn-print-more-generals')).toHaveCount(0);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('npc-possession-candidates-and-list.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await page.locator('.npc-keep input').first().check();
|
||||
await page.getByRole('button', { name: '다른 장수 보기' }).click();
|
||||
await expect.poll(() => state.reservationCalls).toBe(2);
|
||||
expect(state.reservationInputs.at(-1), JSON.stringify(state.rawBodies.at(-1))).toMatchObject({
|
||||
refresh: true,
|
||||
keepIds: [1],
|
||||
});
|
||||
await expect(page.locator('.npc-keep').first()).toContainText('보관(2회)');
|
||||
const refreshButton = page.locator('#btn-pick-more');
|
||||
await expect(refreshButton).toBeDisabled();
|
||||
await expect(refreshButton).toContainText(/다른 장수 보기\([12]초\)/);
|
||||
await expect(refreshButton).toBeEnabled({ timeout: 3_000 });
|
||||
|
||||
const dialogs: string[] = [];
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogs.push(dialog.message());
|
||||
if (
|
||||
dialog.type() === 'confirm' &&
|
||||
dialogs.filter((message) => message.startsWith('빙의할까요?')).length === 1
|
||||
) {
|
||||
await dialog.dismiss();
|
||||
return;
|
||||
}
|
||||
await dialog.accept();
|
||||
});
|
||||
const possessButton = page.locator('.npc-action').first();
|
||||
await possessButton.click();
|
||||
expect(state.possessInputs).toHaveLength(0);
|
||||
|
||||
await possessButton.click();
|
||||
await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.');
|
||||
expect(state.possessInputs).toHaveLength(1);
|
||||
const firstRequestId = state.possessInputs[0]?.clientRequestId;
|
||||
expect(firstRequestId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toContain(
|
||||
firstRequestId as string
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const expiredNow = Date.now() + 120_000;
|
||||
Date.now = () => expiredNow;
|
||||
});
|
||||
await expect(page.locator('.npc-token-expired')).toBeVisible();
|
||||
await expect(possessButton).toBeEnabled();
|
||||
await expect(refreshButton).toBeDisabled();
|
||||
await page.locator('#btn-retry-possession').click();
|
||||
await expect(page).toHaveURL(new RegExp(`${basePath}/$`));
|
||||
expect(state.possessInputs).toHaveLength(2);
|
||||
expect(state.possessInputs[1]?.clientRequestId).toBe(firstRequestId);
|
||||
expect(dialogs).toContain('빙의에 성공했습니다.');
|
||||
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull();
|
||||
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('npc-possession-success.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { expect, test, 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.NPC_POSSESSION_LIVE_DATABASE_URL;
|
||||
const redisUrl = process.env.NPC_POSSESSION_LIVE_REDIS_URL;
|
||||
const gameTokenSecret = process.env.NPC_POSSESSION_LIVE_GAME_SECRET;
|
||||
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'npc_possession_integration:2';
|
||||
const scenarioId = Number(process.env.PLAYWRIGHT_SCENARIO ?? '2');
|
||||
const userId = 'npc-possession-live-user';
|
||||
const hasLiveFixture = Boolean(databaseUrl && redisUrl && gameTokenSecret);
|
||||
|
||||
if (!Number.isSafeInteger(scenarioId) || scenarioId <= 0 || profile !== `${profile.split(':', 1)[0]}:${scenarioId}`) {
|
||||
throw new Error(`NPC possession live scenario/profile mismatch: ${profile} / ${scenarioId}`);
|
||||
}
|
||||
|
||||
const installSession = async (page: Page): Promise<void> => {
|
||||
const now = new Date();
|
||||
const gameToken = encryptGameSessionToken(
|
||||
{
|
||||
version: 1,
|
||||
profile,
|
||||
issuedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + 3_600_000).toISOString(),
|
||||
sessionId: `npc-possession-live-${randomUUID()}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: 'npc-possession-live-user',
|
||||
displayName: '브라우저빙의',
|
||||
roles: ['user'],
|
||||
legacyMemberNo: 7_710,
|
||||
canUseGeneralPicture: false,
|
||||
},
|
||||
sanctions: {},
|
||||
identity: {
|
||||
kakaoVerified: true,
|
||||
canCreateGeneral: true,
|
||||
requiresKakaoVerification: false,
|
||||
graceEndsAt: null,
|
||||
},
|
||||
},
|
||||
gameTokenSecret!
|
||||
);
|
||||
await page.addInitScript(
|
||||
({ token, gameProfile }) => {
|
||||
window.localStorage.setItem('sammo-game-token', token);
|
||||
window.localStorage.setItem('sammo-game-profile', gameProfile);
|
||||
},
|
||||
{ token: gameToken, gameProfile: profile }
|
||||
);
|
||||
};
|
||||
|
||||
test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and Chromium', () => {
|
||||
test.skip(!hasLiveFixture, 'live NPC possession token and database 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;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const schema = new URL(databaseUrl!).searchParams.get('schema');
|
||||
const profileId = profile.split(':', 1)[0] ?? '';
|
||||
if (schema !== profileId || !schema.endsWith('npc_possession_integration')) {
|
||||
throw new Error(
|
||||
`Refusing mismatched or non-dedicated schema: ${schema ?? '(missing)'} != ${profileId ?? '(missing)'}`
|
||||
);
|
||||
}
|
||||
const previousSeed = process.env.INTEGRATION_WORLD_SEED;
|
||||
process.env.INTEGRATION_WORLD_SEED = 'npc-possession-live-seed';
|
||||
try {
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId,
|
||||
databaseUrl: databaseUrl!,
|
||||
now: new Date('2099-07-31T12:00:00.000Z'),
|
||||
installOptions: {
|
||||
turnTermMinutes: 5,
|
||||
npcMode: 1,
|
||||
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.logEntry.deleteMany();
|
||||
await db.npcSelectionToken.deleteMany();
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
await db.general.createMany({
|
||||
data: Array.from({ length: 12 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
userId: null,
|
||||
name: `실브라우저후보${index + 1}`,
|
||||
nationId: 0,
|
||||
cityId: city.id,
|
||||
npcState: 2,
|
||||
leadership: 40 + index,
|
||||
strength: 50 + index,
|
||||
intel: 60 + index,
|
||||
turnTime: new Date('2099-07-31T12:05:00.000Z'),
|
||||
personalCode: 'che_안전',
|
||||
specialCode: 'che_인덕',
|
||||
special2Code: 'che_무쌍',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
meta: { killturn: 6 },
|
||||
penalty: {},
|
||||
})),
|
||||
});
|
||||
runtime = await createTurnDaemonRuntime({
|
||||
profile,
|
||||
databaseUrl: databaseUrl!,
|
||||
enableDatabaseFlush: true,
|
||||
enableLeaseHeartbeat: false,
|
||||
leaseOwnerId: 'npc-possession-live-daemon',
|
||||
});
|
||||
daemonLoop = runtime.lifecycle.start();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (runtime) {
|
||||
await runtime.lifecycle.stop('NPC possession live complete');
|
||||
await daemonLoop;
|
||||
await runtime.close();
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
test('replays one completed possession after the browser receives an indeterminate timeout', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const requestIds: string[] = [];
|
||||
let injectTimeout = true;
|
||||
await installSession(page);
|
||||
await page.route('**/image/icons/**', async (route) => {
|
||||
await 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>',
|
||||
});
|
||||
});
|
||||
await page.route('**/trpc/join.possessGeneral?batch=1', async (route) => {
|
||||
const findClientRequestId = (value: unknown): string | undefined => {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
if ('clientRequestId' in value && typeof value.clientRequestId === 'string') {
|
||||
return value.clientRequestId;
|
||||
}
|
||||
for (const nested of Object.values(value)) {
|
||||
const found = findClientRequestId(nested);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const clientRequestId = findClientRequestId(route.request().postDataJSON());
|
||||
expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
requestIds.push(clientRequestId!);
|
||||
if (!injectTimeout) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
injectTimeout = false;
|
||||
const accepted = await route.fetch();
|
||||
expect(accepted.ok()).toBe(true);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
error: {
|
||||
message:
|
||||
'NPC 빙의 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
|
||||
code: -32008,
|
||||
data: {
|
||||
code: 'TIMEOUT',
|
||||
httpStatus: 408,
|
||||
path: 'join.possessGeneral',
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1024, height: 900 });
|
||||
await page.goto('join?tab=possess');
|
||||
await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/);
|
||||
await expect(page.locator('.npc-card')).toHaveCount(5);
|
||||
const geometry = await page.locator('.npc-possession-section').evaluate((section) => ({
|
||||
width: section.getBoundingClientRect().width,
|
||||
cardWidths: [...section.querySelectorAll<HTMLElement>('.npc-card')].map(
|
||||
(card) => card.getBoundingClientRect().width
|
||||
),
|
||||
}));
|
||||
expect(geometry).toEqual({
|
||||
width: 1000,
|
||||
cardWidths: [125, 125, 125, 125, 125],
|
||||
});
|
||||
|
||||
const dialogs: string[] = [];
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogs.push(dialog.message());
|
||||
await dialog.accept();
|
||||
});
|
||||
const possessButton = page.locator('.npc-action').first();
|
||||
await possessButton.click();
|
||||
await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.');
|
||||
await expect.poll(() => db.general.count({ where: { userId } })).toBe(1);
|
||||
const pending = await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'));
|
||||
expect(pending).toContain(requestIds[0]);
|
||||
|
||||
await possessButton.click();
|
||||
await expect(page).toHaveURL(/\/hwe\/$/);
|
||||
expect(requestIds).toHaveLength(2);
|
||||
expect(requestIds[1]).toBe(requestIds[0]);
|
||||
expect(await db.general.count({ where: { userId } })).toBe(1);
|
||||
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull();
|
||||
expect(dialogs).toContain('빙의에 성공했습니다.');
|
||||
const event = await db.inputEvent.findFirstOrThrow({
|
||||
where: { actorUserId: userId, eventType: 'npcPossessGeneral' },
|
||||
});
|
||||
expect(event).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
|
||||
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('npc-possession-live-success.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ export default defineConfig({
|
||||
'commandArgumentsLive.spec.ts',
|
||||
'mainNavigation.spec.ts',
|
||||
'session-auth.spec.ts',
|
||||
'npcPossession.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"target": "ES2022",
|
||||
"types": ["node", "@playwright/test"]
|
||||
},
|
||||
"include": ["./joinGeneralLive.spec.ts", "./joinGeneral.live.playwright.config.mjs"]
|
||||
"include": [
|
||||
"./joinGeneralLive.spec.ts",
|
||||
"./joinGeneral.live.playwright.config.mjs",
|
||||
"./npcPossessionLive.spec.ts",
|
||||
"./npcPossession.live.playwright.config.mjs"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user