feat: port pre-start general deletion lifecycle
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
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 ?? 15144);
|
||||
const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15145);
|
||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||
const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'die_on_prestart_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.DIE_ON_PRESTART_LIVE_DATABASE_URL ?? '';
|
||||
const redisUrl = process.env.DIE_ON_PRESTART_LIVE_REDIS_URL ?? '';
|
||||
const gameSecret = process.env.DIE_ON_PRESTART_LIVE_GAME_SECRET ?? '';
|
||||
const databaseSchema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||
|
||||
if (databaseUrl && databaseSchema !== profileId) {
|
||||
throw new Error(
|
||||
`Die-on-prestart 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: ['dieOnPrestartLive.spec.ts'],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
reporter: [['list']],
|
||||
outputDir: resolve(repositoryRoot, 'test-results/die-on-prestart-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,
|
||||
DAEMON_REQUEST_TIMEOUT_MS: '10000',
|
||||
},
|
||||
},
|
||||
{
|
||||
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,321 @@
|
||||
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.DIE_ON_PRESTART_LIVE_DATABASE_URL;
|
||||
const redisUrl = process.env.DIE_ON_PRESTART_LIVE_REDIS_URL;
|
||||
const gameTokenSecret = process.env.DIE_ON_PRESTART_LIVE_GAME_SECRET;
|
||||
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'die_on_prestart_live_integration:2';
|
||||
const archiveServerId = 'die_prestart:2';
|
||||
const scenarioId = Number(process.env.PLAYWRIGHT_SCENARIO ?? '2');
|
||||
const userId = 'die-prestart-live-user';
|
||||
const generalId = 991_741;
|
||||
const memberId = 991_742;
|
||||
const hasLiveFixture = Boolean(databaseUrl && redisUrl && gameTokenSecret);
|
||||
|
||||
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: `die-prestart-live-${randomUUID()}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: 'die-prestart-live',
|
||||
displayName: '실삭제사용자',
|
||||
roles: ['user'],
|
||||
legacyMemberNo: 7_741,
|
||||
canUseGeneralPicture: false,
|
||||
},
|
||||
sanctions: {},
|
||||
identity: {
|
||||
kakaoVerified: true,
|
||||
canCreateGeneral: true,
|
||||
requiresKakaoVerification: false,
|
||||
graceEndsAt: null,
|
||||
},
|
||||
},
|
||||
gameTokenSecret!
|
||||
);
|
||||
await page.addInitScript(
|
||||
({ token, gameProfile }) => {
|
||||
if (location.pathname.startsWith('/che/')) {
|
||||
window.localStorage.setItem('sammo-game-token', token);
|
||||
window.localStorage.setItem('sammo-game-profile', gameProfile);
|
||||
}
|
||||
},
|
||||
{ token: gameToken, gameProfile: profile }
|
||||
);
|
||||
};
|
||||
|
||||
test.describe('pre-start deletion through live PostgreSQL, Redis, API, daemon, and Chromium', () => {
|
||||
test.skip(!hasLiveFixture, 'live pre-start deletion token, Redis, 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');
|
||||
if (!schema?.endsWith('die_on_prestart_live_integration')) {
|
||||
throw new Error(`Refusing non-dedicated schema: ${schema ?? '(missing)'}`);
|
||||
}
|
||||
const previousSeed = process.env.INTEGRATION_WORLD_SEED;
|
||||
process.env.INTEGRATION_WORLD_SEED = 'die-on-prestart-live-seed';
|
||||
try {
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId,
|
||||
databaseUrl: databaseUrl!,
|
||||
now: new Date('2099-07-31T12:00:00.000Z'),
|
||||
installOptions: {
|
||||
turnTermMinutes: 5,
|
||||
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.logEntry.deleteMany();
|
||||
const worldState = await db.worldState.findFirstOrThrow();
|
||||
await db.worldState.update({
|
||||
where: { id: worldState.id },
|
||||
data: {
|
||||
meta: {
|
||||
...(worldState.meta as Record<string, unknown>),
|
||||
serverId: archiveServerId,
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.oldGeneral.deleteMany({ where: { serverId: archiveServerId, generalNo: generalId } });
|
||||
await db.inheritanceResult.deleteMany({ where: { serverId: archiveServerId, owner: userId } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||
await db.selectPoolEntry.deleteMany({ where: { uniqueName: '실삭제풀' } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, memberId] } } });
|
||||
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
const availableAt = new Date(Date.now() - 60_000);
|
||||
await db.general.createMany({
|
||||
data: [
|
||||
{
|
||||
id: generalId,
|
||||
userId,
|
||||
name: '실삭제장수',
|
||||
nationId: 0,
|
||||
cityId: city.id,
|
||||
troopId: generalId,
|
||||
npcState: 0,
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intel: 50,
|
||||
turnTime: new Date('2099-07-31T12:05:00.000Z'),
|
||||
meta: {
|
||||
killturn: 6,
|
||||
prestart_delete_after: availableAt.toISOString(),
|
||||
inheritRandomUnique: true,
|
||||
inheritSpecificSpecialWar: true,
|
||||
},
|
||||
penalty: {},
|
||||
},
|
||||
{
|
||||
id: memberId,
|
||||
userId: null,
|
||||
name: '실삭제부대원',
|
||||
nationId: 0,
|
||||
cityId: city.id,
|
||||
troopId: generalId,
|
||||
npcState: 2,
|
||||
leadership: 50,
|
||||
strength: 50,
|
||||
intel: 50,
|
||||
turnTime: new Date('2099-07-31T12:05:00.000Z'),
|
||||
meta: { killturn: 6 },
|
||||
penalty: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
await db.troop.create({
|
||||
data: { troopLeaderId: generalId, nationId: 0, name: '실삭제부대' },
|
||||
});
|
||||
await db.generalAccessLog.create({
|
||||
data: { generalId, userId, lastRefresh: new Date(availableAt.getTime() - 10 * 60_000) },
|
||||
});
|
||||
await db.generalTurn.create({
|
||||
data: { generalId, turnIdx: 0, actionCode: '휴식', arg: {} },
|
||||
});
|
||||
await db.generalTurnRevision.create({
|
||||
data: { generalId, revision: 1 },
|
||||
});
|
||||
await db.rankData.create({
|
||||
data: { generalId, nationId: 0, type: 'warnum', value: 1 },
|
||||
});
|
||||
await db.selectPoolEntry.create({
|
||||
data: {
|
||||
uniqueName: '실삭제풀',
|
||||
ownerUserId: userId,
|
||||
generalId,
|
||||
reservedUntil: new Date(Date.now() + 3_600_000),
|
||||
info: {},
|
||||
},
|
||||
});
|
||||
await db.inheritancePoint.create({
|
||||
data: { userId, key: 'previous', value: 100 },
|
||||
});
|
||||
|
||||
runtime = await createTurnDaemonRuntime({
|
||||
profile,
|
||||
databaseUrl: databaseUrl!,
|
||||
enableDatabaseFlush: true,
|
||||
enableLeaseHeartbeat: false,
|
||||
leaseOwnerId: 'die-on-prestart-live-daemon',
|
||||
});
|
||||
daemonLoop = runtime.lifecycle.start();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (runtime) {
|
||||
await runtime.lifecycle.stop('pre-start deletion live complete');
|
||||
await daemonLoop;
|
||||
await runtime.close();
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
test('deletes the owned general and its lifecycle state from the actual UI', async ({ page }, testInfo) => {
|
||||
const dialogs: string[] = [];
|
||||
await installSession(page);
|
||||
await page.route('**/image/game/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
|
||||
);
|
||||
await page.route('**/gateway/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<!doctype html><html><body>gateway</body></html>',
|
||||
})
|
||||
);
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogs.push(`${dialog.type()}:${dialog.message()}`);
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await page.goto('my-page');
|
||||
const actionLine = page.locator('.action-line').filter({ hasText: '가오픈 기간 내 장수 삭제' });
|
||||
const deleteButton = actionLine.getByRole('button', { name: '장수 삭제' });
|
||||
await expect(actionLine).toContainText('가오픈 기간 내 장수 삭제');
|
||||
await expect(deleteButton).toBeVisible();
|
||||
await expect(deleteButton).toHaveCSS('width', '160px');
|
||||
await expect(deleteButton).toHaveCSS('height', '30px');
|
||||
await expect(deleteButton).toHaveCSS('background-color', 'rgb(34, 85, 0)');
|
||||
await deleteButton.click();
|
||||
|
||||
await page.waitForURL(/\/gateway\/$/u);
|
||||
await expect(page.locator('body')).toHaveText('gateway');
|
||||
expect(dialogs).toEqual(['confirm:정말로 삭제하시겠습니까?']);
|
||||
const event = await db.inputEvent.findFirstOrThrow({
|
||||
where: { actorUserId: userId, eventType: 'dieOnPrestart' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const requestPrefix = `general:dieOnPrestart:${userId}:`;
|
||||
expect(event.requestId.startsWith(requestPrefix)).toBe(true);
|
||||
const clientRequestId = event.requestId.slice(requestPrefix.length);
|
||||
expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/iu);
|
||||
|
||||
await expect(db.general.findUnique({ where: { id: generalId } })).resolves.toBeNull();
|
||||
await expect(db.general.findUniqueOrThrow({ where: { id: memberId } })).resolves.toMatchObject({
|
||||
troopId: 0,
|
||||
});
|
||||
await expect(db.troop.findUnique({ where: { troopLeaderId: generalId } })).resolves.toBeNull();
|
||||
await expect(db.generalAccessLog.findUnique({ where: { generalId } })).resolves.toBeNull();
|
||||
await expect(db.generalTurn.count({ where: { generalId } })).resolves.toBe(0);
|
||||
await expect(db.generalTurnRevision.findUnique({ where: { generalId } })).resolves.toBeNull();
|
||||
await expect(db.rankData.count({ where: { generalId } })).resolves.toBe(0);
|
||||
await expect(
|
||||
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: '실삭제풀' } })
|
||||
).resolves.toMatchObject({
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
});
|
||||
const archived = await db.oldGeneral.findUniqueOrThrow({
|
||||
where: { by_no: { serverId: archiveServerId, generalNo: generalId } },
|
||||
});
|
||||
const archivedData = archived.data as { troopId?: number; meta?: Record<string, unknown> };
|
||||
expect(archivedData.troopId).toBe(0);
|
||||
expect(archivedData.meta).not.toHaveProperty('inheritRandomUnique');
|
||||
expect(archivedData.meta).not.toHaveProperty('inheritSpecificSpecialWar');
|
||||
await expect(
|
||||
db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } })
|
||||
).resolves.toMatchObject({ value: 7_105 });
|
||||
const inheritanceResult = await db.inheritanceResult.findFirstOrThrow({
|
||||
where: { serverId: archiveServerId, owner: userId },
|
||||
});
|
||||
expect(inheritanceResult).toMatchObject({ generalId });
|
||||
expect(inheritanceResult.value).toMatchObject({
|
||||
previous: 100,
|
||||
refund: 7_000,
|
||||
combat: 5,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await db.inheritanceLog.findMany({
|
||||
where: { userId },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { text: true },
|
||||
})
|
||||
).map((entry: { text: string }) => entry.text)
|
||||
).toEqual([
|
||||
'사망으로 랜덤 유니크 구입 3000 포인트 반환',
|
||||
'사망으로 전투 특기 지정 4000 포인트 반환',
|
||||
'사망 정산: 7,105 포인트',
|
||||
]);
|
||||
await expect(
|
||||
db.logEntry.findFirstOrThrow({
|
||||
where: {
|
||||
scope: 'SYSTEM',
|
||||
category: 'SUMMARY',
|
||||
text: { contains: '<Y>실삭제장수</>가 홀연히 모습을 <R>감추었습니다</>' },
|
||||
},
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
expect(event).toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: userId,
|
||||
});
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
gameToken: localStorage.getItem('sammo-game-token'),
|
||||
gameProfile: localStorage.getItem('sammo-game-profile'),
|
||||
}))
|
||||
).toEqual({
|
||||
gameToken: null,
|
||||
gameProfile: profile,
|
||||
});
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('die-on-prestart-live-success.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ type FixtureState = {
|
||||
instantRetreatEnabled?: boolean;
|
||||
instantRetreatAttempts?: number;
|
||||
instantRetreatInputs?: Array<Record<string, unknown>>;
|
||||
dieOnPrestartShow?: boolean;
|
||||
dieOnPrestartAvailableAt?: string;
|
||||
dieOnPrestartAttempts?: number;
|
||||
dieOnPrestartInputs?: Array<Record<string, unknown>>;
|
||||
generalMeQueries?: number;
|
||||
generalLogQueries?: number;
|
||||
settingMutations: Array<Record<string, unknown>>;
|
||||
@@ -125,8 +129,10 @@ const battleCenter = (state: FixtureState) => ({
|
||||
|
||||
const install = async (page: Page, state: FixtureState) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_menu-token');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
if (location.pathname.startsWith('/che/')) {
|
||||
localStorage.setItem('sammo-game-token', 'ga_menu-token');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
}
|
||||
});
|
||||
await page.route('**/image/game/**', async (route) => {
|
||||
const filename = basename(new URL(route.request().url()).pathname);
|
||||
@@ -158,6 +164,12 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
|
||||
return response(myGeneral(state));
|
||||
}
|
||||
if (operation === 'general.ensureDieOnPrestartStatus')
|
||||
return response({
|
||||
show: state.dieOnPrestartShow ?? false,
|
||||
available: false,
|
||||
availableAt: state.dieOnPrestartAvailableAt ?? null,
|
||||
});
|
||||
if (operation === 'world.getState')
|
||||
return response({
|
||||
currentYear: 185,
|
||||
@@ -209,6 +221,20 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
}
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'general.dieOnPrestart') {
|
||||
state.dieOnPrestartInputs?.push(jsonInput);
|
||||
state.dieOnPrestartAttempts = (state.dieOnPrestartAttempts ?? 0) + 1;
|
||||
if (state.dieOnPrestartAttempts === 1) {
|
||||
return {
|
||||
error: {
|
||||
message: '요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다.',
|
||||
code: -32000,
|
||||
data: { code: 'TIMEOUT', httpStatus: 408, path: operation },
|
||||
},
|
||||
};
|
||||
}
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'general.setMySetting') {
|
||||
state.settingMutations.push(jsonInput);
|
||||
state.myset = Math.max(0, state.myset - 1);
|
||||
@@ -480,6 +506,124 @@ test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후
|
||||
expect(state.instantRetreatInputs?.every((input) => !('generalId' in input))).toBe(true);
|
||||
});
|
||||
|
||||
test('가오픈 장수 삭제는 레거시 표시와 확인을 보존하고 timeout을 같은 ID로 재시도한다', async ({ page }) => {
|
||||
const state: FixtureState = {
|
||||
permission: 'head',
|
||||
myset: 3,
|
||||
dieOnPrestartShow: true,
|
||||
dieOnPrestartAvailableAt: '2026-01-01T00:20:00.000Z',
|
||||
dieOnPrestartAttempts: 0,
|
||||
dieOnPrestartInputs: [],
|
||||
settingMutations: [],
|
||||
accessPages: [],
|
||||
};
|
||||
const dialogs: string[] = [];
|
||||
let confirmCount = 0;
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogs.push(`${dialog.type()}:${dialog.message()}`);
|
||||
if (dialog.type() === 'confirm') {
|
||||
confirmCount += 1;
|
||||
if (confirmCount === 1) {
|
||||
await dialog.dismiss();
|
||||
return;
|
||||
}
|
||||
}
|
||||
await dialog.accept();
|
||||
});
|
||||
await install(page, state);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('sammo-session-token', 'gateway-session-token');
|
||||
});
|
||||
await page.route(/^http:\/\/127\.0\.0\.1:\d+\/api\/trpc\/me(?:\?|$)/u, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(
|
||||
response({
|
||||
id: 'gateway-user',
|
||||
username: 'gateway-user',
|
||||
displayName: '게이트웨이 사용자',
|
||||
})
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route('**/gateway/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<!doctype html><html><body>gateway</body></html>',
|
||||
});
|
||||
});
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await page.goto('my-page');
|
||||
|
||||
const actionLine = page.locator('.action-line').filter({ hasText: '가오픈 기간 내 장수 삭제' });
|
||||
const deleteButton = actionLine.getByRole('button', { name: '장수 삭제' });
|
||||
await expect(actionLine).toContainText('가오픈 기간 내 장수 삭제 (2026-01-01 09:20:00 부터)');
|
||||
await expect(deleteButton).toBeVisible();
|
||||
await expect(deleteButton).toBeEnabled();
|
||||
const geometry = await deleteButton.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
fontSize: style.fontSize,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
cursor: style.cursor,
|
||||
};
|
||||
});
|
||||
expect(geometry).toEqual({
|
||||
width: 160,
|
||||
height: 30,
|
||||
fontSize: '14px',
|
||||
color: 'rgb(255, 255, 255)',
|
||||
backgroundColor: 'rgb(34, 85, 0)',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
await persistParityArtifact(page, 'core-my-page-die-on-prestart', geometry);
|
||||
|
||||
await deleteButton.click();
|
||||
await expect.poll(() => confirmCount).toBe(1);
|
||||
expect(dialogs[0]).toBe('confirm:정말로 삭제하시겠습니까?');
|
||||
expect(state.dieOnPrestartInputs).toHaveLength(0);
|
||||
|
||||
await deleteButton.click();
|
||||
await expect.poll(() => state.dieOnPrestartInputs?.length).toBe(1);
|
||||
await expect
|
||||
.poll(() =>
|
||||
dialogs.some((message) =>
|
||||
message.includes('alert:실패했습니다: 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다.')
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(deleteButton).toBeVisible();
|
||||
const pendingRequestId = await page.evaluate(() => sessionStorage.getItem('sam.pending.dieOnPrestart'));
|
||||
expect(pendingRequestId).toEqual(expect.stringMatching(/^[0-9a-f-]{36}$/i));
|
||||
|
||||
await deleteButton.click();
|
||||
await expect.poll(() => state.dieOnPrestartInputs?.length).toBe(2);
|
||||
await page.waitForURL(/\/gateway\/$/u);
|
||||
await expect(page.locator('body')).toHaveText('gateway');
|
||||
|
||||
const requestIds = state.dieOnPrestartInputs?.map((input) => input.clientRequestId);
|
||||
expect(requestIds).toEqual([pendingRequestId, pendingRequestId]);
|
||||
expect(state.dieOnPrestartInputs?.every((input) => !('generalId' in input) && !('userId' in input))).toBe(true);
|
||||
const storage = await page.evaluate(() => ({
|
||||
gameToken: localStorage.getItem('sammo-game-token'),
|
||||
gameProfile: localStorage.getItem('sammo-game-profile'),
|
||||
sessionToken: localStorage.getItem('sammo-session-token'),
|
||||
pendingRequestId: sessionStorage.getItem('sam.pending.dieOnPrestart'),
|
||||
}));
|
||||
expect(storage).toEqual({
|
||||
gameToken: null,
|
||||
gameProfile: 'che:default',
|
||||
sessionToken: 'gateway-session-token',
|
||||
pendingRequestId: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
|
||||
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||
await install(page, head);
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"./joinGeneralLive.spec.ts",
|
||||
"./joinGeneral.live.playwright.config.mjs",
|
||||
"./npcPossessionLive.spec.ts",
|
||||
"./npcPossession.live.playwright.config.mjs"
|
||||
"./npcPossession.live.playwright.config.mjs",
|
||||
"./dieOnPrestartLive.spec.ts",
|
||||
"./dieOnPrestart.live.playwright.config.mjs"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
||||
"test:e2e:npc-possession": "playwright test npcPossession.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",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node -e \"console.log('test not configured')\"",
|
||||
|
||||
@@ -99,6 +99,10 @@ export const useSessionStore = defineStore('session', {
|
||||
this.setGameToken(null);
|
||||
this.status = 'public';
|
||||
},
|
||||
leaveGame() {
|
||||
this.setGameToken(null);
|
||||
this.status = this.sessionToken ? 'authed' : 'public';
|
||||
},
|
||||
async refreshGeneralStatus() {
|
||||
if (!this.gameToken) {
|
||||
this.status = this.sessionToken ? 'authed' : 'public';
|
||||
|
||||
@@ -4,14 +4,17 @@ import { trpc } from '../utils/trpc';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
|
||||
const SCREEN_MODE_KEY = 'sam.screenMode';
|
||||
const CUSTOM_CSS_KEY = 'sam_customCSS';
|
||||
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
|
||||
type ScreenMode = 'auto' | '500px' | '1000px';
|
||||
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
|
||||
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
|
||||
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||
type SelectionPoolStatus = Awaited<ReturnType<typeof trpc.join.getConfig.query>>['selectionPool'];
|
||||
type DieOnPrestartStatus = Awaited<ReturnType<typeof trpc.general.ensureDieOnPrestartStatus.mutate>>;
|
||||
|
||||
type WorldSnapshot = {
|
||||
currentYear: number;
|
||||
@@ -31,13 +34,20 @@ type SettingForm = {
|
||||
const data = ref<MyGeneralResponse | null>(null);
|
||||
const world = ref<WorldSnapshot>(null);
|
||||
const selectionPoolStatus = ref<SelectionPoolStatus | null>(null);
|
||||
const dieOnPrestartStatus = ref<DieOnPrestartStatus | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const screenMode = ref<ScreenMode>('auto');
|
||||
const customCss = ref('');
|
||||
const cssSaving = ref(false);
|
||||
const session = useSessionStore();
|
||||
let cssTimer: number | null = null;
|
||||
const readPendingDieOnPrestartId = (): string => {
|
||||
const stored = window.sessionStorage.getItem(PENDING_DIE_ON_PRESTART_KEY);
|
||||
return stored && /^[0-9a-f-]{36}$/iu.test(stored) ? stored : crypto.randomUUID();
|
||||
};
|
||||
const immediateActionRequestIds = reactive({
|
||||
dieOnPrestart: readPendingDieOnPrestartId(),
|
||||
buildNationCandidate: crypto.randomUUID(),
|
||||
instantRetreat: crypto.randomUUID(),
|
||||
});
|
||||
@@ -136,12 +146,16 @@ const actionAvailability = computed(() => {
|
||||
const preopen = Boolean(turnTime && openTime && turnTime.getTime() <= openTime.getTime());
|
||||
const npcMode = numberValue(config.npcMode ?? config.npcmode, 0);
|
||||
return {
|
||||
dieOnPrestart: Boolean(preopen && general?.npcState === 0 && general.nationId === 0),
|
||||
dieOnPrestart: Boolean(dieOnPrestartStatus.value?.show),
|
||||
buildNationCandidate: Boolean(preopen && general?.nationId === 0),
|
||||
instantRetreat: Boolean(availableInstantAction.instantRetreat),
|
||||
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
|
||||
};
|
||||
});
|
||||
const formatDieOnPrestartAvailableAt = computed(() => {
|
||||
const value = dieOnPrestartStatus.value?.availableAt;
|
||||
return value ? formatSeoulDateTime(value) : '';
|
||||
});
|
||||
const formatSelectionAvailableAt = computed(() => {
|
||||
const value = selectionPoolStatus.value?.nextChangeAt;
|
||||
if (!value) return '';
|
||||
@@ -178,14 +192,16 @@ const loadPage = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const [general, state, joinConfig] = await Promise.all([
|
||||
const [general, state, joinConfig, prestartStatus] = await Promise.all([
|
||||
trpc.general.me.query(),
|
||||
trpc.world.getState.query() as Promise<WorldSnapshot>,
|
||||
trpc.join.getConfig.query(),
|
||||
trpc.general.ensureDieOnPrestartStatus.mutate(),
|
||||
]);
|
||||
data.value = general;
|
||||
world.value = state;
|
||||
selectionPoolStatus.value = joinConfig.selectionPool;
|
||||
dieOnPrestartStatus.value = prestartStatus;
|
||||
if (general) {
|
||||
Object.assign(form, general.settings);
|
||||
}
|
||||
@@ -218,6 +234,25 @@ const confirmMutation = async (message: string, mutation: () => Promise<unknown>
|
||||
}
|
||||
};
|
||||
|
||||
const dieOnPrestart = async () => {
|
||||
if (!confirm('정말로 삭제하시겠습니까?')) return;
|
||||
const clientRequestId = immediateActionRequestIds.dieOnPrestart;
|
||||
window.sessionStorage.setItem(PENDING_DIE_ON_PRESTART_KEY, clientRequestId);
|
||||
try {
|
||||
await trpc.general.dieOnPrestart.mutate({ clientRequestId });
|
||||
window.sessionStorage.removeItem(PENDING_DIE_ON_PRESTART_KEY);
|
||||
session.leaveGame();
|
||||
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
|
||||
} catch (cause) {
|
||||
const code = asRecord(asRecord(cause).data).code;
|
||||
if (code !== 'TIMEOUT') {
|
||||
window.sessionStorage.removeItem(PENDING_DIE_ON_PRESTART_KEY);
|
||||
}
|
||||
alert(`실패했습니다: ${errorText(cause)}`);
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) =>
|
||||
confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () =>
|
||||
trpc.general.dropItem.mutate({ itemType: item.key })
|
||||
@@ -387,13 +422,8 @@ onMounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
|
||||
가오픈 기간 내 장수 삭제<br />
|
||||
<button
|
||||
class="action-button"
|
||||
@click="confirmMutation('정말로 삭제하시겠습니까?', () => trpc.general.dieOnPrestart.mutate())"
|
||||
>
|
||||
장수 삭제
|
||||
</button>
|
||||
가오픈 기간 내 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)<br />
|
||||
<button class="action-button" @click="dieOnPrestart">장수 삭제</button>
|
||||
</div>
|
||||
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
|
||||
서버 개시 이전 거병(2턴부터 건국 가능)<br />
|
||||
|
||||
Reference in New Issue
Block a user