Merge branch 'main' into feature/main-signup-kakao-gate
# Conflicts: # app/gateway-frontend/src/views/LobbyView.vue
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
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 = 15112;
|
||||
const apiPort = 15113;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: ['main-front-status.spec.ts'],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
reporter: [['list']],
|
||||
outputDir: process.env.SAMMO_TEST_OUTPUT_DIR ?? resolve(repositoryRoot, 'test-results/main-front-status-live'),
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
baseURL: `http://127.0.0.1:${frontendPort}/che/`,
|
||||
colorScheme: 'dark',
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'UTC',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command:
|
||||
`GAME_API_HOST=127.0.0.1 GAME_API_PORT=${apiPort} ` +
|
||||
'GAME_TRPC_PATH=/che/api/trpc GAME_API_EVENTS_PATH=/che/api/events ' +
|
||||
'PROFILE=che SCENARIO=default GAME_PROFILE_NAME=che:default node app/game-api/dist/index.js',
|
||||
cwd: repositoryRoot,
|
||||
url: `http://127.0.0.1:${apiPort}/che/api/trpc/health.ping?input=%7B%22json%22%3Anull%7D`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
{
|
||||
command:
|
||||
`VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=http://127.0.0.1:${apiPort}/che/api/trpc ` +
|
||||
`VITE_GAME_SSE_URL=http://127.0.0.1:${apiPort}/che/api/events ` +
|
||||
'pnpm --filter @sammo-ts/game-frontend build && ' +
|
||||
`VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=http://127.0.0.1:${apiPort}/che/api/trpc ` +
|
||||
`VITE_GAME_SSE_URL=http://127.0.0.1:${apiPort}/che/api/events ` +
|
||||
`pnpm --filter @sammo-ts/game-frontend preview --host 127.0.0.1 --port ${frontendPort}`,
|
||||
cwd: repositoryRoot,
|
||||
url: `http://127.0.0.1:${frontendPort}/che/`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
createRedisConnector,
|
||||
GamePrisma,
|
||||
resolvePostgresConfigFromEnv,
|
||||
resolveRedisConfigFromEnv,
|
||||
type GamePrismaClient,
|
||||
type RedisConnector,
|
||||
} from '../../packages/infra/src/index.js';
|
||||
|
||||
const marker = `main-front-status-live-${randomUUID()}`;
|
||||
const accessToken = `ga_${randomUUID()}`;
|
||||
let postgres: ReturnType<typeof createGamePostgresConnector>;
|
||||
let redis: RedisConnector;
|
||||
let prisma: GamePrismaClient;
|
||||
let fixture: { generalId: number; nationId: number; userId: string; voteId: number } | null = null;
|
||||
|
||||
const accessKey = (token: string) => `sammo:game:access:che:default:${token}`;
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, message: string) => ({
|
||||
error: {
|
||||
message,
|
||||
code: -32000,
|
||||
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path },
|
||||
},
|
||||
});
|
||||
const operationNames = (route: Route): string[] => {
|
||||
const pathname = decodeURIComponent(new URL(route.request().url()).pathname);
|
||||
return pathname.slice(pathname.lastIndexOf('/trpc/') + 6).split(',');
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: 'che' }));
|
||||
redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
await postgres.connect();
|
||||
await redis.connect();
|
||||
prisma = postgres.prisma;
|
||||
|
||||
const world = await prisma.worldState.findFirst({ orderBy: { id: 'asc' }, select: { id: true } });
|
||||
if (!world) {
|
||||
throw new Error('The live che profile needs a world state.');
|
||||
}
|
||||
const [generalMax, nationMax] = await Promise.all([
|
||||
prisma.general.aggregate({ _max: { id: true } }),
|
||||
prisma.nation.aggregate({ _max: { id: true } }),
|
||||
]);
|
||||
const nationId = (nationMax._max.id ?? 0) + 10_000;
|
||||
const generalId = (generalMax._max.id ?? 0) + 10_000;
|
||||
const userId = `main-front-status-${randomUUID()}`;
|
||||
await prisma.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO nation (id, name, color, meta)
|
||||
VALUES (
|
||||
${nationId},
|
||||
${'검증국'},
|
||||
${'#336699'},
|
||||
${JSON.stringify({ notice: `<p>${marker} 국가 방침</p>` })}::jsonb
|
||||
)
|
||||
`
|
||||
);
|
||||
await prisma.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO general (id, user_id, name, nation_id, city_id, turn_time)
|
||||
VALUES (${generalId}, ${userId}, ${'현황검증장수'}, ${nationId}, ${0}, ${new Date()})
|
||||
`
|
||||
);
|
||||
await prisma.generalAccessLog.create({
|
||||
data: {
|
||||
generalId,
|
||||
userId,
|
||||
lastRefresh: new Date(),
|
||||
refresh: 1,
|
||||
refreshTotal: 1,
|
||||
refreshScore: 1,
|
||||
refreshScoreTotal: 1,
|
||||
},
|
||||
});
|
||||
const poll = await prisma.votePoll.create({
|
||||
data: {
|
||||
title: '검증 설문',
|
||||
body: '',
|
||||
options: ['찬성', '반대'],
|
||||
multipleOptions: 1,
|
||||
revealMode: 'after_vote',
|
||||
openerGeneralId: generalId,
|
||||
openerName: '현황검증장수',
|
||||
startAt: new Date(Date.now() - 60_000),
|
||||
endAt: new Date(Date.now() + 3_600_000),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
fixture = { generalId, nationId, userId, voteId: poll.id };
|
||||
|
||||
const issuedAt = new Date();
|
||||
await redis.client.set(
|
||||
accessKey(accessToken),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: issuedAt.toISOString(),
|
||||
expiresAt: new Date(issuedAt.getTime() + 30 * 60 * 1000).toISOString(),
|
||||
sessionId: `main-front-status-${randomUUID()}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: 'main-front-status-live',
|
||||
displayName: '현황검증장수',
|
||||
roles: [],
|
||||
},
|
||||
sanctions: {},
|
||||
}),
|
||||
{ EX: 1800 }
|
||||
);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (fixture) {
|
||||
await prisma.votePoll.deleteMany({ where: { id: fixture.voteId } });
|
||||
await prisma.generalAccessLog.deleteMany({ where: { generalId: fixture.generalId } });
|
||||
await prisma.$executeRaw(GamePrisma.sql`DELETE FROM general WHERE id = ${fixture.generalId}`);
|
||||
await prisma.$executeRaw(GamePrisma.sql`DELETE FROM nation WHERE id = ${fixture.nationId}`);
|
||||
const [generalCount, nationCount, voteCount, accessCount] = await Promise.all([
|
||||
prisma.general.count({ where: { id: fixture.generalId } }),
|
||||
prisma.nation.count({ where: { id: fixture.nationId } }),
|
||||
prisma.votePoll.count({ where: { id: fixture.voteId } }),
|
||||
prisma.generalAccessLog.count({ where: { generalId: fixture.generalId } }),
|
||||
]);
|
||||
expect([generalCount, nationCount, voteCount, accessCount]).toEqual([0, 0, 0, 0]);
|
||||
}
|
||||
await redis.client.del(accessKey(accessToken));
|
||||
await redis.disconnect();
|
||||
await postgres.disconnect();
|
||||
});
|
||||
|
||||
const installMainFixture = async (
|
||||
page: Page,
|
||||
liveStatus: {
|
||||
onlineUserCount: number;
|
||||
onlineNations: string;
|
||||
onlineGenerals: string;
|
||||
nationNotice: string;
|
||||
lastExecuted: string | null;
|
||||
latestVote: { id: number; title: string; hasVoted: boolean } | null;
|
||||
},
|
||||
failStatus: () => boolean
|
||||
) => {
|
||||
await page.addInitScript(
|
||||
({ token }) => {
|
||||
window.localStorage.setItem('sammo-game-token', token);
|
||||
window.localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
},
|
||||
{ token: accessToken }
|
||||
);
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'general.me') {
|
||||
return response({
|
||||
general: {
|
||||
id: fixture?.generalId ?? 1,
|
||||
name: '현황검증장수',
|
||||
npcState: 0,
|
||||
nationId: fixture?.nationId ?? 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
officerLevel: 0,
|
||||
stats: { leadership: 55, strength: 55, intelligence: 55 },
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
injury: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
city: null,
|
||||
nation: null,
|
||||
settings: {},
|
||||
penalties: {},
|
||||
});
|
||||
}
|
||||
if (operation === 'general.getFrontStatus') {
|
||||
return failStatus()
|
||||
? errorResponse(operation, '상단 현황을 불러오지 못했습니다.')
|
||||
: response(liveStatus);
|
||||
}
|
||||
if (operation === 'general.getRecentRecords') return response({ global: [], general: [], history: [] });
|
||||
if (operation === 'lobby.info') {
|
||||
return response({
|
||||
year: 190,
|
||||
month: 1,
|
||||
userCnt: 1,
|
||||
npcCnt: 0,
|
||||
nationCnt: 1,
|
||||
maxUserCnt: 50,
|
||||
turnTerm: 60,
|
||||
fictionMode: '가상',
|
||||
otherTextInfo: '',
|
||||
starttime: '0190-01-01T00:00:00.000Z',
|
||||
myGeneral: { name: '현황검증장수', picture: null },
|
||||
});
|
||||
}
|
||||
if (operation === 'world.getMapLayout') {
|
||||
return response({
|
||||
mapName: 'che',
|
||||
cityList: [{ id: 1, name: '낙양', level: 7, region: 1, x: 350, y: 245, path: [] }],
|
||||
regionMap: { 1: '사예' },
|
||||
levelMap: { 7: '수도' },
|
||||
});
|
||||
}
|
||||
if (operation === 'world.getMap') {
|
||||
return response({
|
||||
year: 190,
|
||||
month: 1,
|
||||
startYear: 190,
|
||||
cityList: [[1, 7, 0, 0, 1, 1]],
|
||||
nationList: [],
|
||||
spyList: {},
|
||||
shownByGeneralList: [],
|
||||
myCity: 1,
|
||||
myNation: fixture?.nationId ?? 1,
|
||||
});
|
||||
}
|
||||
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
|
||||
return response([]);
|
||||
}
|
||||
if (operation === 'messages.getRecent') {
|
||||
return response({
|
||||
private: [],
|
||||
public: [],
|
||||
national: [],
|
||||
diplomacy: [],
|
||||
permission: 0,
|
||||
latestRead: { private: 0, diplomacy: 0 },
|
||||
canRespondDiplomacy: false,
|
||||
});
|
||||
}
|
||||
if (operation === 'messages.getContacts') return response([]);
|
||||
if (operation === 'board.getAccess') {
|
||||
return response({ canMeeting: false, canSecret: false, permission: 0 });
|
||||
}
|
||||
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||
if (operation === 'public.recordAccess') return response({ recorded: true });
|
||||
throw new Error(`Unhandled main front status operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
test('renders actual online, nation policy, and survey data with ref geometry and preserves it on error', async ({
|
||||
page,
|
||||
}) => {
|
||||
const liveResponse = await page.request.get(
|
||||
'http://127.0.0.1:15113/che/api/trpc/general.getFrontStatus?input=%7B%22json%22%3Anull%7D',
|
||||
{ headers: { authorization: `Bearer ${accessToken}` } }
|
||||
);
|
||||
expect(liveResponse.ok()).toBe(true);
|
||||
const liveStatus = (
|
||||
(await liveResponse.json()) as {
|
||||
result: {
|
||||
data: {
|
||||
onlineUserCount: number;
|
||||
onlineNations: string;
|
||||
onlineGenerals: string;
|
||||
nationNotice: string;
|
||||
lastExecuted: string | null;
|
||||
latestVote: { id: number; title: string; hasVoted: boolean } | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
).result.data;
|
||||
expect(liveStatus.onlineGenerals).toContain('현황검증장수');
|
||||
expect(liveStatus.nationNotice).toContain(marker);
|
||||
expect(liveStatus.latestVote).toMatchObject({ title: '검증 설문', hasVoted: false });
|
||||
|
||||
let failStatus = false;
|
||||
await installMainFixture(page, liveStatus, () => failStatus);
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
|
||||
const status = page.locator('.front-status');
|
||||
await expect(status).toContainText(marker);
|
||||
await expect(page.locator('.online-users')).toContainText('현황검증장수');
|
||||
await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.');
|
||||
const desktop = await status.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const onlineRow = element.querySelector<HTMLElement>('.online-nations');
|
||||
const voteRow = element.querySelector<HTMLElement>('.vote-status');
|
||||
if (!onlineRow || !voteRow) throw new Error('status row missing');
|
||||
return {
|
||||
rect: element.getBoundingClientRect().toJSON(),
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
backgroundImage: style.backgroundImage,
|
||||
onlineRow: {
|
||||
rect: onlineRow.getBoundingClientRect().toJSON(),
|
||||
borderTop: getComputedStyle(onlineRow).borderTop,
|
||||
padding: getComputedStyle(onlineRow).padding,
|
||||
},
|
||||
voteRow: voteRow.getBoundingClientRect().toJSON(),
|
||||
};
|
||||
});
|
||||
expect(desktop.rect).toMatchObject({ x: 0, width: 1000 });
|
||||
expect(desktop).toMatchObject({
|
||||
fontSize: '14px',
|
||||
lineHeight: '21px',
|
||||
onlineRow: {
|
||||
borderTop: '1px solid rgb(128, 128, 128)',
|
||||
padding: '7px',
|
||||
},
|
||||
});
|
||||
expect(desktop.onlineRow.rect.height).toBeCloseTo(36, 0);
|
||||
expect(desktop.voteRow.x).toBeCloseTo(666.67, 0);
|
||||
expect(desktop.voteRow.width).toBeCloseTo(333.33, 0);
|
||||
expect(desktop.voteRow.height).toBeCloseTo(36, 0);
|
||||
expect(desktop.backgroundImage).toContain('/image/game/back_walnut.jpg');
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await expect(status).toHaveCSS('width', '500px');
|
||||
await expect(page.locator('.vote-status')).toHaveCSS('width', '250px');
|
||||
|
||||
failStatus = true;
|
||||
await page.getByRole('button', { name: '새로고침', exact: true }).click();
|
||||
await expect(page.getByRole('alert').filter({ hasText: '상단 현황을 불러오지 못했습니다.' })).toBeVisible();
|
||||
await expect(status).toContainText(marker);
|
||||
|
||||
failStatus = false;
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
await expect(page.locator('.front-status')).toContainText(marker);
|
||||
await expect(page.locator('.survey-notice')).toHaveCount(0);
|
||||
});
|
||||
@@ -188,6 +188,16 @@ const installMainFixture = async (page: Page, failRecords = false) => {
|
||||
? errorResponse(operation, '동향 정보를 불러오지 못했습니다.')
|
||||
: response(frontRecords);
|
||||
}
|
||||
if (operation === 'general.getFrontStatus') {
|
||||
return response({
|
||||
onlineUserCount: 1,
|
||||
onlineNations: '【재야】',
|
||||
onlineGenerals: '지도 장수',
|
||||
nationNotice: '',
|
||||
lastExecuted: '2026-07-26T00:00:00.000Z',
|
||||
latestVote: null,
|
||||
});
|
||||
}
|
||||
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||
if (operation === 'public.recordAccess') return response({ recorded: true });
|
||||
return errorResponse(operation, `Unhandled main operation: ${operation}`);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const baseUrl = process.env.REF_GATEWAY_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||
const username = process.env.REF_USER_ID ?? 'refuser1';
|
||||
const passwordFile =
|
||||
process.env.REF_USER_PASSWORD_FILE ??
|
||||
'/home/letrhee/sam_rebuild/docker_compose_files/reference/secrets/user1_password';
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: 'dark',
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'UTC',
|
||||
viewport: { width: 1000, height: 900 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
if (!login.ok()) {
|
||||
throw new Error(`reference login failed: HTTP ${login.status()}`);
|
||||
}
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const logout = page.locator('#btn_logout');
|
||||
await logout.waitFor({ state: 'visible' });
|
||||
const inspect = async () =>
|
||||
logout.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: rect.toJSON(),
|
||||
backgroundColor: style.backgroundColor,
|
||||
border: style.border,
|
||||
borderRadius: style.borderRadius,
|
||||
color: style.color,
|
||||
cursor: style.cursor,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
fontWeight: style.fontWeight,
|
||||
lineHeight: style.lineHeight,
|
||||
padding: style.padding,
|
||||
};
|
||||
});
|
||||
const normal = await inspect();
|
||||
await logout.hover();
|
||||
const hover = await inspect();
|
||||
await logout.focus();
|
||||
const focus = await inspect();
|
||||
console.log(JSON.stringify({ viewport: page.viewportSize(), normal, hover, focus }));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const baseUrl = process.env.REF_MAIN_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||
const username = process.env.REF_USER_ID ?? 'refuser1';
|
||||
const passwordFile =
|
||||
process.env.REF_USER_PASSWORD_FILE ??
|
||||
'/home/letrhee/sam_rebuild/docker_compose_files/reference/secrets/user1_password';
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: 'dark',
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
if (!login.ok()) {
|
||||
throw new Error(`reference login failed: HTTP ${login.status()}`);
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1000, height: 900 },
|
||||
{ name: 'mobile', width: 500, height: 900 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
await page.locator('.onlineNations').waitFor({ state: 'visible' });
|
||||
const measurement = await page.evaluate(() => {
|
||||
const inspect = (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error(`missing ${selector}`);
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: rect.toJSON(),
|
||||
backgroundColor: style.backgroundColor,
|
||||
backgroundImage: style.backgroundImage,
|
||||
borderTop: style.borderTop,
|
||||
color: style.color,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
fontWeight: style.fontWeight,
|
||||
lineHeight: style.lineHeight,
|
||||
padding: style.padding,
|
||||
};
|
||||
};
|
||||
return {
|
||||
container: inspect('#container'),
|
||||
onlineNations: inspect('.onlineNations'),
|
||||
onlineUsers: inspect('.onlineUsers'),
|
||||
nationNotice: inspect('.nationNotice'),
|
||||
voteStatus: inspect('.subVoteState'),
|
||||
};
|
||||
});
|
||||
console.log(JSON.stringify({ viewport, measurement }));
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const pm2Candidates = [
|
||||
'/home/letrhee/.nvm/versions/node/v22.15.1/bin/pm2',
|
||||
'/home/letrhee/.nvm/versions/node/v24.13.0/bin/pm2',
|
||||
];
|
||||
|
||||
let processes;
|
||||
for (const candidate of pm2Candidates) {
|
||||
try {
|
||||
const output = execFileSync(candidate, ['jlist'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
processes = JSON.parse(output.slice(output.indexOf('[')));
|
||||
break;
|
||||
} catch {
|
||||
// Try the next locally installed PM2 client.
|
||||
}
|
||||
}
|
||||
if (!processes) {
|
||||
throw new Error('Unable to read the local PM2 process list.');
|
||||
}
|
||||
|
||||
const source = processes.find((process) => process.name === 'sammo-verify-che-api')?.pm2_env;
|
||||
if (!source) {
|
||||
throw new Error('sammo-verify-che-api is required as the live environment source.');
|
||||
}
|
||||
|
||||
const allowed = /^(DATABASE_URL|POSTGRES_|REDIS_|GAME_|GATEWAY_|PROFILE$|SCENARIO$)/;
|
||||
const liveEnvironment = Object.fromEntries(
|
||||
Object.entries(source).filter(([key, value]) => allowed.test(key) && typeof value === 'string')
|
||||
);
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'main-front-status.spec.ts',
|
||||
'--config',
|
||||
'tools/frontend-legacy-parity/main-front-status.playwright.config.mjs',
|
||||
],
|
||||
{
|
||||
cwd: repositoryRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...liveEnvironment,
|
||||
SAMMO_TEST_OUTPUT_DIR: '/dev/shm/sammo-main-front-status-live',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
}
|
||||
);
|
||||
process.exitCode = result.status ?? 1;
|
||||
@@ -69,6 +69,7 @@ export interface TurnCommandFixtureRequest {
|
||||
messageAfterId?: number;
|
||||
generalCooldowns?: GeneralCooldownSelector[];
|
||||
nationCooldowns?: NationCooldownSelector[];
|
||||
diplomacyPairs?: Array<{ fromNationId: number; toNationId: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -308,6 +308,19 @@ const buildRequest = (
|
||||
dead: 0,
|
||||
...fixturePatches.diplomacy?.['2:1'],
|
||||
},
|
||||
...Object.entries(fixturePatches.diplomacy ?? {})
|
||||
.filter(([key]) => !['1:2', '2:1'].includes(key))
|
||||
.map(([key, patch]) => {
|
||||
const [fromNationId, toNationId] = key.split(':').map(Number);
|
||||
return {
|
||||
fromNationId,
|
||||
toNationId,
|
||||
state: 3,
|
||||
term: 0,
|
||||
dead: 0,
|
||||
...patch,
|
||||
};
|
||||
}),
|
||||
],
|
||||
},
|
||||
observe: {
|
||||
@@ -331,12 +344,35 @@ const buildRequest = (
|
||||
...(fixturePatches.randomFoundingCandidateCityIds ?? []).filter((id) => id !== 3 && id !== 70),
|
||||
],
|
||||
nationIds: [1, 2],
|
||||
...(action === 'che_백성동원' || action === 'che_이호경식'
|
||||
...(fixturePatches.diplomacy
|
||||
? {
|
||||
diplomacyPairs: Object.keys(fixturePatches.diplomacy)
|
||||
.filter((key) => !['1:2', '2:1'].includes(key))
|
||||
.map((key) => {
|
||||
const [fromNationId, toNationId] = key.split(':').map(Number);
|
||||
return { fromNationId, toNationId };
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(action === 'che_백성동원' ||
|
||||
action === 'che_이호경식' ||
|
||||
action === 'che_급습' ||
|
||||
action === 'che_필사즉생' ||
|
||||
action === 'che_허보'
|
||||
? {
|
||||
nationCooldowns: [
|
||||
{
|
||||
nationId: 1,
|
||||
actionName: action === 'che_백성동원' ? '백성동원' : '이호경식',
|
||||
actionName:
|
||||
action === 'che_백성동원'
|
||||
? '백성동원'
|
||||
: action === 'che_이호경식'
|
||||
? '이호경식'
|
||||
: action === 'che_급습'
|
||||
? '급습'
|
||||
: action === 'che_필사즉생'
|
||||
? '필사즉생'
|
||||
: '허보',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -3265,3 +3301,933 @@ integration('nation degrade-relations target, diplomacy, front, and cooldown bou
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
const surpriseAttackBoundaryCases: Array<{
|
||||
name: string;
|
||||
destNationId: unknown;
|
||||
fixturePatches?: FixturePatches;
|
||||
completed?: boolean;
|
||||
coreResolution?: boolean;
|
||||
expectedForwardState?: number;
|
||||
expectedReverseState?: number;
|
||||
expectedForwardTerm?: number;
|
||||
expectedReverseTerm?: number;
|
||||
expectedSourceFront?: number;
|
||||
expectedDestFront?: number;
|
||||
expectedStrategicCommandLimit?: number;
|
||||
expectedPostReqTurn?: number;
|
||||
expectedDestLogGeneralId?: number;
|
||||
}> = [
|
||||
{
|
||||
name: 'rejects a missing destination nation',
|
||||
destNationId: 99,
|
||||
},
|
||||
{
|
||||
name: 'rejects a numeric string destination nation ID',
|
||||
destNationId: '2',
|
||||
},
|
||||
{
|
||||
name: 'rejects a fractional destination nation ID',
|
||||
destNationId: 2.9,
|
||||
},
|
||||
{
|
||||
name: 'rejects a source city occupied by another nation',
|
||||
destNationId: 2,
|
||||
fixturePatches: {
|
||||
cities: { 3: { nationId: 2 } },
|
||||
diplomacy: { '1:2': { state: 1, term: 12 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'rejects an actor below chief rank',
|
||||
destNationId: 2,
|
||||
fixturePatches: {
|
||||
generals: { 1: { officerLevel: 4, officerCityId: 0 } },
|
||||
diplomacy: { '1:2': { state: 1, term: 12 } },
|
||||
},
|
||||
coreResolution: false,
|
||||
},
|
||||
{
|
||||
name: 'rejects a remaining strategic-command delay',
|
||||
destNationId: 2,
|
||||
fixturePatches: {
|
||||
nations: { 1: { strategicCommandLimit: 1 } },
|
||||
diplomacy: { '1:2': { state: 1, term: 12 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'rejects a forward diplomacy state other than declaration',
|
||||
destNationId: 2,
|
||||
fixturePatches: {
|
||||
diplomacy: { '1:2': { state: 0, term: 12 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'rejects declaration term eleven at the lower boundary',
|
||||
destNationId: 2,
|
||||
fixturePatches: {
|
||||
diplomacy: { '1:2': { state: 1, term: 11 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'allows an unsupplied source city and preserves both fronts',
|
||||
destNationId: 2,
|
||||
fixturePatches: {
|
||||
cities: {
|
||||
3: { supplyState: 0, frontState: 2 },
|
||||
70: { frontState: 3 },
|
||||
},
|
||||
diplomacy: {
|
||||
'1:2': { state: 1, term: 12 },
|
||||
'2:1': { state: 1, term: 12 },
|
||||
},
|
||||
},
|
||||
completed: true,
|
||||
expectedForwardState: 1,
|
||||
expectedReverseState: 1,
|
||||
expectedForwardTerm: 9,
|
||||
expectedReverseTerm: 9,
|
||||
expectedSourceFront: 2,
|
||||
expectedDestFront: 3,
|
||||
expectedPostReqTurn: 126,
|
||||
},
|
||||
{
|
||||
name: 'subtracts three from a disallowed reverse state into a negative term',
|
||||
destNationId: 2,
|
||||
fixturePatches: {
|
||||
diplomacy: {
|
||||
'1:2': { state: 1, term: 15 },
|
||||
'2:1': { state: 2, term: 2 },
|
||||
},
|
||||
},
|
||||
completed: true,
|
||||
expectedForwardState: 1,
|
||||
expectedReverseState: 2,
|
||||
expectedForwardTerm: 12,
|
||||
expectedReverseTerm: -1,
|
||||
expectedSourceFront: 0,
|
||||
expectedDestFront: 1,
|
||||
expectedPostReqTurn: 126,
|
||||
},
|
||||
{
|
||||
name: 'allows the source nation when a self-diplomacy row is present',
|
||||
destNationId: 1,
|
||||
fixturePatches: {
|
||||
diplomacy: {
|
||||
'1:1': { state: 1, term: 12 },
|
||||
},
|
||||
},
|
||||
completed: true,
|
||||
expectedForwardState: 1,
|
||||
expectedReverseState: 1,
|
||||
expectedForwardTerm: 9,
|
||||
expectedReverseTerm: 9,
|
||||
expectedSourceFront: 0,
|
||||
expectedDestFront: 0,
|
||||
expectedPostReqTurn: 126,
|
||||
expectedDestLogGeneralId: 1,
|
||||
},
|
||||
{
|
||||
name: 'applies the strategist command and global-delay modifiers',
|
||||
destNationId: 2,
|
||||
fixturePatches: {
|
||||
nations: { 1: { typeCode: 'che_종횡가' } },
|
||||
diplomacy: {
|
||||
'1:2': { state: 1, term: 12 },
|
||||
'2:1': { state: 1, term: 12 },
|
||||
},
|
||||
},
|
||||
completed: true,
|
||||
expectedForwardState: 1,
|
||||
expectedReverseState: 1,
|
||||
expectedForwardTerm: 9,
|
||||
expectedReverseTerm: 9,
|
||||
expectedSourceFront: 0,
|
||||
expectedDestFront: 1,
|
||||
expectedStrategicCommandLimit: 5,
|
||||
expectedPostReqTurn: 95,
|
||||
},
|
||||
{
|
||||
name: 'uses the actual eleven-general count for the nation cooldown',
|
||||
destNationId: 2,
|
||||
fixturePatches: {
|
||||
nations: { 1: { generalCount: 11 } },
|
||||
generals: {
|
||||
4: { nationId: 1 },
|
||||
5: { nationId: 1 },
|
||||
6: { nationId: 1 },
|
||||
7: { nationId: 1 },
|
||||
8: { nationId: 1 },
|
||||
9: { nationId: 1 },
|
||||
10: { nationId: 1 },
|
||||
11: { nationId: 1 },
|
||||
12: { nationId: 1 },
|
||||
},
|
||||
diplomacy: {
|
||||
'1:2': { state: 1, term: 12 },
|
||||
'2:1': { state: 1, term: 12 },
|
||||
},
|
||||
},
|
||||
completed: true,
|
||||
expectedForwardState: 1,
|
||||
expectedReverseState: 1,
|
||||
expectedForwardTerm: 9,
|
||||
expectedReverseTerm: 9,
|
||||
expectedSourceFront: 0,
|
||||
expectedDestFront: 1,
|
||||
expectedPostReqTurn: 133,
|
||||
},
|
||||
];
|
||||
|
||||
integration('nation surprise-attack target, diplomacy-term, front, and cooldown boundaries', () => {
|
||||
it.each(surpriseAttackBoundaryCases)(
|
||||
'$name matches legacy fallback, diplomacy, fronts, cooldown, logs, RNG, and semantic delta',
|
||||
async ({
|
||||
destNationId,
|
||||
fixturePatches,
|
||||
completed = false,
|
||||
coreResolution = true,
|
||||
expectedForwardState,
|
||||
expectedReverseState,
|
||||
expectedForwardTerm,
|
||||
expectedReverseTerm,
|
||||
expectedSourceFront,
|
||||
expectedDestFront,
|
||||
expectedStrategicCommandLimit = 9,
|
||||
expectedPostReqTurn,
|
||||
expectedDestLogGeneralId = 2,
|
||||
}) => {
|
||||
const request = buildRequest('che_급습', { destNationID: destNationId }, fixturePatches);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||
if (coreResolution) {
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_급습',
|
||||
actionKey: completed ? 'che_급습' : '휴식',
|
||||
usedFallback: !completed,
|
||||
});
|
||||
} else {
|
||||
expect(core.execution.outcome).toBeUndefined();
|
||||
}
|
||||
|
||||
for (const snapshot of [reference, core]) {
|
||||
const actualDestNationId = destNationId === 1 ? 1 : 2;
|
||||
const actualDestCityId = actualDestNationId === 1 ? 3 : 70;
|
||||
const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1);
|
||||
const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1);
|
||||
const generalBefore = snapshot.before.generals.find((entry) => entry.id === 1);
|
||||
const generalAfter = snapshot.after.generals.find((entry) => entry.id === 1);
|
||||
const sourceCityBefore = snapshot.before.cities.find((entry) => entry.id === 3);
|
||||
const sourceCityAfter = snapshot.after.cities.find((entry) => entry.id === 3);
|
||||
const destCityBefore = snapshot.before.cities.find((entry) => entry.id === actualDestCityId);
|
||||
const destCityAfter = snapshot.after.cities.find((entry) => entry.id === actualDestCityId);
|
||||
const forwardBefore = snapshot.before.diplomacy.find(
|
||||
(entry) => entry.fromNationId === 1 && entry.toNationId === actualDestNationId
|
||||
);
|
||||
const forwardAfter = snapshot.after.diplomacy.find(
|
||||
(entry) => entry.fromNationId === 1 && entry.toNationId === actualDestNationId
|
||||
);
|
||||
const reverseBefore = snapshot.before.diplomacy.find(
|
||||
(entry) => entry.fromNationId === actualDestNationId && entry.toNationId === 1
|
||||
);
|
||||
const reverseAfter = snapshot.after.diplomacy.find(
|
||||
(entry) => entry.fromNationId === actualDestNationId && entry.toNationId === 1
|
||||
);
|
||||
|
||||
expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0);
|
||||
expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0);
|
||||
expect(
|
||||
readNumericField(generalAfter, 'experience') - readNumericField(generalBefore, 'experience')
|
||||
).toBe(completed ? 5 : 0);
|
||||
expect(
|
||||
readNumericField(generalAfter, 'dedication') - readNumericField(generalBefore, 'dedication')
|
||||
).toBe(completed ? 5 : 0);
|
||||
|
||||
if (completed) {
|
||||
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(expectedStrategicCommandLimit);
|
||||
expect(readNumericField(forwardAfter, 'state')).toBe(expectedForwardState);
|
||||
expect(readNumericField(reverseAfter, 'state')).toBe(expectedReverseState);
|
||||
expect(readNumericField(forwardAfter, 'term')).toBe(expectedForwardTerm);
|
||||
expect(readNumericField(reverseAfter, 'term')).toBe(expectedReverseTerm);
|
||||
expect(readNumericField(sourceCityAfter, 'frontState')).toBe(expectedSourceFront);
|
||||
expect(readNumericField(destCityAfter, 'frontState')).toBe(expectedDestFront);
|
||||
|
||||
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
|
||||
expect(addedLogs.map((entry) => entry.text)).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('급습 발동')])
|
||||
);
|
||||
expect(
|
||||
addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('급습'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
addedLogs.some(
|
||||
(entry) =>
|
||||
entry.generalId === expectedDestLogGeneralId &&
|
||||
String(entry.text).includes('아국에 <M>급습</>이 발동되었습니다.')
|
||||
)
|
||||
).toBe(true);
|
||||
} else {
|
||||
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(
|
||||
readNumericField(nationBefore, 'strategicCommandLimit')
|
||||
);
|
||||
expect(forwardAfter).toEqual(forwardBefore);
|
||||
expect(reverseAfter).toEqual(reverseBefore);
|
||||
expect(readNumericField(sourceCityAfter, 'frontState')).toBe(
|
||||
readNumericField(sourceCityBefore, 'frontState')
|
||||
);
|
||||
expect(readNumericField(destCityAfter, 'frontState')).toBe(
|
||||
readNumericField(destCityBefore, 'frontState')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!;
|
||||
expect(reference.after.world.nationCooldowns).toEqual([
|
||||
{
|
||||
nationId: 1,
|
||||
actionName: '급습',
|
||||
nextAvailableTurn: expectedNextAvailableTurn,
|
||||
},
|
||||
]);
|
||||
expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns);
|
||||
}
|
||||
expect(reference.rng).toEqual([]);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
const desperateSurvivalBoundaryCases: Array<{
|
||||
name: string;
|
||||
fixturePatches?: FixturePatches;
|
||||
outcome: 'fallback' | 'intermediate' | 'completed';
|
||||
coreResolution?: boolean;
|
||||
expectedLastTerm?: number;
|
||||
expectedStrategicCommandLimit?: number;
|
||||
expectedPostReqTurn?: number;
|
||||
}> = [
|
||||
{
|
||||
name: 'rejects a nation without an outgoing war',
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a reverse-only war',
|
||||
fixturePatches: {
|
||||
diplomacy: {
|
||||
'1:2': { state: 3 },
|
||||
'2:1': { state: 0 },
|
||||
},
|
||||
},
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a source city occupied by another nation',
|
||||
fixturePatches: {
|
||||
cities: { 3: { nationId: 2 } },
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects an actor below chief rank',
|
||||
fixturePatches: {
|
||||
generals: { 1: { officerLevel: 4, officerCityId: 0 } },
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'fallback',
|
||||
coreResolution: false,
|
||||
},
|
||||
{
|
||||
name: 'rejects a remaining strategic-command delay',
|
||||
fixturePatches: {
|
||||
nations: { 1: { strategicCommandLimit: 1 } },
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'allows an unsupplied city at the first intermediate turn',
|
||||
fixturePatches: {
|
||||
cities: { 3: { supplyState: 0 } },
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'intermediate',
|
||||
expectedLastTerm: 1,
|
||||
},
|
||||
{
|
||||
name: 'advances the second intermediate turn without side effects',
|
||||
fixturePatches: {
|
||||
nations: {
|
||||
1: {
|
||||
turnLastByOfficerLevel: {
|
||||
12: { command: '필사즉생', arg: {}, term: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'intermediate',
|
||||
expectedLastTerm: 2,
|
||||
},
|
||||
{
|
||||
name: 'completes with an outgoing war even when the reverse relation is trade',
|
||||
fixturePatches: {
|
||||
nations: {
|
||||
1: {
|
||||
turnLastByOfficerLevel: {
|
||||
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
diplomacy: {
|
||||
'1:2': { state: 0, term: 0 },
|
||||
'2:1': { state: 3, term: 0 },
|
||||
},
|
||||
},
|
||||
outcome: 'completed',
|
||||
expectedPostReqTurn: 87,
|
||||
},
|
||||
{
|
||||
name: 'preserves training and morale values already above one hundred',
|
||||
fixturePatches: {
|
||||
generals: {
|
||||
1: { train: 120, atmos: 110 },
|
||||
3: { train: 105, atmos: 130 },
|
||||
},
|
||||
nations: {
|
||||
1: {
|
||||
turnLastByOfficerLevel: {
|
||||
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'completed',
|
||||
expectedPostReqTurn: 87,
|
||||
},
|
||||
{
|
||||
name: 'accepts an explicit self-war as the outgoing war relation',
|
||||
fixturePatches: {
|
||||
nations: {
|
||||
1: {
|
||||
turnLastByOfficerLevel: {
|
||||
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
diplomacy: {
|
||||
'1:1': { state: 0 },
|
||||
},
|
||||
},
|
||||
outcome: 'completed',
|
||||
expectedPostReqTurn: 87,
|
||||
},
|
||||
{
|
||||
name: 'restarts at term one after a different command interrupted the stack',
|
||||
fixturePatches: {
|
||||
nations: {
|
||||
1: {
|
||||
turnLastByOfficerLevel: {
|
||||
12: { command: '급습', arg: { destNationID: 2 }, term: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'intermediate',
|
||||
expectedLastTerm: 1,
|
||||
},
|
||||
{
|
||||
name: 'applies the strategist command and global-delay modifiers',
|
||||
fixturePatches: {
|
||||
nations: {
|
||||
1: {
|
||||
typeCode: 'che_종횡가',
|
||||
turnLastByOfficerLevel: {
|
||||
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'completed',
|
||||
expectedStrategicCommandLimit: 5,
|
||||
expectedPostReqTurn: 65,
|
||||
},
|
||||
{
|
||||
name: 'uses the actual eleven-general count for the nation cooldown',
|
||||
fixturePatches: {
|
||||
nations: {
|
||||
1: {
|
||||
generalCount: 11,
|
||||
turnLastByOfficerLevel: {
|
||||
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
generals: {
|
||||
4: { nationId: 1 },
|
||||
5: { nationId: 1 },
|
||||
6: { nationId: 1 },
|
||||
7: { nationId: 1 },
|
||||
8: { nationId: 1 },
|
||||
9: { nationId: 1 },
|
||||
10: { nationId: 1 },
|
||||
11: { nationId: 1 },
|
||||
12: { nationId: 1 },
|
||||
},
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'completed',
|
||||
expectedPostReqTurn: 92,
|
||||
},
|
||||
];
|
||||
|
||||
integration('nation desperate-survival multistep, diplomacy, effects, and cooldown boundaries', () => {
|
||||
it.each(desperateSurvivalBoundaryCases)(
|
||||
'$name matches legacy progress, effects, cooldown, logs, RNG, and semantic delta',
|
||||
async ({
|
||||
fixturePatches,
|
||||
outcome,
|
||||
coreResolution = true,
|
||||
expectedLastTerm,
|
||||
expectedStrategicCommandLimit = 9,
|
||||
expectedPostReqTurn,
|
||||
}) => {
|
||||
const request = buildRequest('che_필사즉생', undefined, fixturePatches);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
const completed = outcome === 'completed';
|
||||
const fallback = outcome === 'fallback';
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||
if (coreResolution) {
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_필사즉생',
|
||||
actionKey: fallback ? '휴식' : 'che_필사즉생',
|
||||
usedFallback: fallback,
|
||||
...(fallback ? {} : { completed }),
|
||||
});
|
||||
} else {
|
||||
expect(core.execution.outcome).toBeUndefined();
|
||||
}
|
||||
|
||||
for (const snapshot of [reference, core]) {
|
||||
const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1);
|
||||
const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1);
|
||||
const actorBefore = snapshot.before.generals.find((entry) => entry.id === 1);
|
||||
const actorAfter = snapshot.after.generals.find((entry) => entry.id === 1);
|
||||
const teammateBefore = snapshot.before.generals.find((entry) => entry.id === 3);
|
||||
const teammateAfter = snapshot.after.generals.find((entry) => entry.id === 3);
|
||||
const foreignBefore = snapshot.before.generals.find((entry) => entry.id === 2);
|
||||
const foreignAfter = snapshot.after.generals.find((entry) => entry.id === 2);
|
||||
|
||||
expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0);
|
||||
expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0);
|
||||
expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe(
|
||||
completed ? 15 : 0
|
||||
);
|
||||
expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe(
|
||||
completed ? 15 : 0
|
||||
);
|
||||
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(
|
||||
completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit')
|
||||
);
|
||||
|
||||
for (const [before, after] of [
|
||||
[actorBefore, actorAfter],
|
||||
[teammateBefore, teammateAfter],
|
||||
] as const) {
|
||||
expect(readNumericField(after, 'train')).toBe(
|
||||
completed ? Math.max(100, readNumericField(before, 'train')) : readNumericField(before, 'train')
|
||||
);
|
||||
expect(readNumericField(after, 'atmos')).toBe(
|
||||
completed ? Math.max(100, readNumericField(before, 'atmos')) : readNumericField(before, 'atmos')
|
||||
);
|
||||
}
|
||||
expect(readNumericField(foreignAfter, 'train')).toBe(readNumericField(foreignBefore, 'train'));
|
||||
expect(readNumericField(foreignAfter, 'atmos')).toBe(readNumericField(foreignBefore, 'atmos'));
|
||||
|
||||
if (completed) {
|
||||
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
|
||||
expect(addedLogs.map((entry) => entry.text)).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('필사즉생 발동')])
|
||||
);
|
||||
expect(
|
||||
addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('필사즉생'))
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (outcome === 'intermediate') {
|
||||
expect(reference.execution.outcome).toMatchObject({
|
||||
lastTurn: {
|
||||
command: '필사즉생',
|
||||
term: expectedLastTerm,
|
||||
},
|
||||
});
|
||||
expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({
|
||||
command: '필사즉생',
|
||||
term: expectedLastTerm,
|
||||
});
|
||||
}
|
||||
if (completed) {
|
||||
const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!;
|
||||
expect(reference.after.world.nationCooldowns).toEqual([
|
||||
{
|
||||
nationId: 1,
|
||||
actionName: '필사즉생',
|
||||
nextAvailableTurn: expectedNextAvailableTurn,
|
||||
},
|
||||
]);
|
||||
expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns);
|
||||
}
|
||||
expect(reference.rng).toEqual([]);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
const deceptionBoundaryCases: Array<{
|
||||
name: string;
|
||||
destCityId: unknown;
|
||||
fixturePatches?: FixturePatches;
|
||||
outcome: 'fallback' | 'intermediate' | 'completed';
|
||||
coreResolution?: boolean;
|
||||
expectedPostReqTurn?: number;
|
||||
expectedStrategicCommandLimit?: number;
|
||||
expectedTargetCityId?: number;
|
||||
expectedRngCalls?: number;
|
||||
}> = [
|
||||
{
|
||||
name: 'rejects a missing destination city',
|
||||
destCityId: 99,
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'accepts a numeric string destination city ID',
|
||||
destCityId: '70',
|
||||
fixturePatches: { diplomacy: { '1:2': { state: 0 } } },
|
||||
outcome: 'completed',
|
||||
expectedPostReqTurn: 62,
|
||||
expectedTargetCityId: 70,
|
||||
expectedRngCalls: 2,
|
||||
},
|
||||
{
|
||||
name: 'truncates a fractional destination city ID',
|
||||
destCityId: 70.9,
|
||||
fixturePatches: { diplomacy: { '1:2': { state: 0 } } },
|
||||
outcome: 'completed',
|
||||
expectedPostReqTurn: 62,
|
||||
expectedTargetCityId: 70,
|
||||
expectedRngCalls: 2,
|
||||
},
|
||||
{
|
||||
name: 'rejects a neutral destination city',
|
||||
destCityId: 70,
|
||||
fixturePatches: { cities: { 70: { nationId: 0 } } },
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a destination city occupied by the source nation',
|
||||
destCityId: 70,
|
||||
fixturePatches: { cities: { 70: { nationId: 1 } } },
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a trade relation to the destination nation',
|
||||
destCityId: 70,
|
||||
fixturePatches: { diplomacy: { '1:2': { state: 3 } } },
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a reverse-only war relation',
|
||||
destCityId: 70,
|
||||
fixturePatches: {
|
||||
diplomacy: {
|
||||
'1:2': { state: 3 },
|
||||
'2:1': { state: 0 },
|
||||
},
|
||||
},
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects a source city occupied by another nation',
|
||||
destCityId: 70,
|
||||
fixturePatches: {
|
||||
cities: { 3: { nationId: 2 } },
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'rejects an actor below chief rank',
|
||||
destCityId: 70,
|
||||
fixturePatches: {
|
||||
generals: { 1: { officerLevel: 4, officerCityId: 0 } },
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'fallback',
|
||||
coreResolution: false,
|
||||
},
|
||||
{
|
||||
name: 'rejects a remaining strategic-command delay',
|
||||
destCityId: 70,
|
||||
fixturePatches: {
|
||||
nations: { 1: { strategicCommandLimit: 1 } },
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'fallback',
|
||||
},
|
||||
{
|
||||
name: 'allows an unsupplied source city on the intermediate turn',
|
||||
destCityId: 70,
|
||||
fixturePatches: {
|
||||
cities: { 3: { supplyState: 0 } },
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'intermediate',
|
||||
},
|
||||
{
|
||||
name: 'completes against a nation under declaration',
|
||||
destCityId: 70,
|
||||
fixturePatches: { diplomacy: { '1:2': { state: 1 } } },
|
||||
outcome: 'completed',
|
||||
expectedPostReqTurn: 62,
|
||||
expectedTargetCityId: 70,
|
||||
expectedRngCalls: 2,
|
||||
},
|
||||
{
|
||||
name: 'excludes supply state two and moves the target to the only supply-one city',
|
||||
destCityId: 70,
|
||||
fixturePatches: {
|
||||
cities: {
|
||||
70: { supplyState: 2 },
|
||||
23: { nationId: 2, supplyState: 1 },
|
||||
},
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'completed',
|
||||
expectedPostReqTurn: 62,
|
||||
expectedTargetCityId: 23,
|
||||
expectedRngCalls: 1,
|
||||
},
|
||||
{
|
||||
name: 'completes without RNG when no general is in the destination city',
|
||||
destCityId: 70,
|
||||
fixturePatches: {
|
||||
cities: { 23: { nationId: 2, supplyState: 1 } },
|
||||
generals: { 2: { cityId: 23 } },
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'completed',
|
||||
expectedPostReqTurn: 62,
|
||||
expectedTargetCityId: 23,
|
||||
expectedRngCalls: 0,
|
||||
},
|
||||
{
|
||||
name: 'restarts at term one after another command interrupted the stack',
|
||||
destCityId: 70,
|
||||
fixturePatches: {
|
||||
nations: {
|
||||
1: {
|
||||
turnLastByOfficerLevel: {
|
||||
12: { command: '필사즉생', arg: {}, term: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'intermediate',
|
||||
},
|
||||
{
|
||||
name: 'applies the strategist command and global-delay modifiers',
|
||||
destCityId: 70,
|
||||
fixturePatches: {
|
||||
nations: { 1: { typeCode: 'che_종횡가' } },
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'completed',
|
||||
expectedPostReqTurn: 46,
|
||||
expectedStrategicCommandLimit: 5,
|
||||
expectedTargetCityId: 70,
|
||||
expectedRngCalls: 2,
|
||||
},
|
||||
{
|
||||
name: 'uses the actual eleven-general count for the nation cooldown',
|
||||
destCityId: 70,
|
||||
fixturePatches: {
|
||||
nations: { 1: { generalCount: 11 } },
|
||||
generals: {
|
||||
4: { nationId: 1 },
|
||||
5: { nationId: 1 },
|
||||
6: { nationId: 1 },
|
||||
7: { nationId: 1 },
|
||||
8: { nationId: 1 },
|
||||
9: { nationId: 1 },
|
||||
10: { nationId: 1 },
|
||||
11: { nationId: 1 },
|
||||
12: { nationId: 1 },
|
||||
},
|
||||
diplomacy: { '1:2': { state: 0 } },
|
||||
},
|
||||
outcome: 'completed',
|
||||
expectedPostReqTurn: 65,
|
||||
expectedTargetCityId: 70,
|
||||
expectedRngCalls: 2,
|
||||
},
|
||||
];
|
||||
|
||||
integration('nation deception target, multistep, movement, RNG, and cooldown boundaries', () => {
|
||||
it.each(deceptionBoundaryCases)(
|
||||
'$name matches legacy progress, movement, cooldown, logs, RNG, and semantic delta',
|
||||
async ({
|
||||
destCityId,
|
||||
fixturePatches,
|
||||
outcome,
|
||||
coreResolution = true,
|
||||
expectedPostReqTurn,
|
||||
expectedStrategicCommandLimit = 9,
|
||||
expectedTargetCityId,
|
||||
expectedRngCalls,
|
||||
}) => {
|
||||
const normalizedDestCityId =
|
||||
typeof destCityId === 'string' ? Math.trunc(Number(destCityId)) : Math.trunc(Number(destCityId));
|
||||
const completed = outcome === 'completed';
|
||||
const fallback = outcome === 'fallback';
|
||||
const completionNationPatch = completed
|
||||
? {
|
||||
turnLastByOfficerLevel: {
|
||||
12: { command: '허보', arg: { destCityID: destCityId }, term: 1 },
|
||||
},
|
||||
coreTurnLastByOfficerLevel: {
|
||||
12: { command: '허보', arg: { destCityId: normalizedDestCityId }, term: 1 },
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const request = buildRequest(
|
||||
'che_허보',
|
||||
{ destCityID: destCityId },
|
||||
{
|
||||
...fixturePatches,
|
||||
nations: {
|
||||
...fixturePatches?.nations,
|
||||
1: {
|
||||
...fixturePatches?.nations?.[1],
|
||||
...completionNationPatch,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||
if (coreResolution) {
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_허보',
|
||||
actionKey: fallback ? '휴식' : 'che_허보',
|
||||
usedFallback: fallback,
|
||||
...(fallback ? {} : { completed }),
|
||||
});
|
||||
} else {
|
||||
expect(core.execution.outcome).toBeUndefined();
|
||||
}
|
||||
|
||||
for (const snapshot of [reference, core]) {
|
||||
const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1);
|
||||
const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1);
|
||||
const actorBefore = snapshot.before.generals.find((entry) => entry.id === 1);
|
||||
const actorAfter = snapshot.after.generals.find((entry) => entry.id === 1);
|
||||
const targetBefore = snapshot.before.generals.find((entry) => entry.id === 2);
|
||||
const targetAfter = snapshot.after.generals.find((entry) => entry.id === 2);
|
||||
|
||||
expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0);
|
||||
expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0);
|
||||
expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe(
|
||||
completed ? 10 : 0
|
||||
);
|
||||
expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe(
|
||||
completed ? 10 : 0
|
||||
);
|
||||
expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(
|
||||
completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit')
|
||||
);
|
||||
expect(readNumericField(targetAfter, 'cityId')).toBe(
|
||||
completed && expectedTargetCityId !== undefined
|
||||
? expectedTargetCityId
|
||||
: readNumericField(targetBefore, 'cityId')
|
||||
);
|
||||
|
||||
if (completed) {
|
||||
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
|
||||
expect(addedLogs.map((entry) => entry.text)).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('허보 발동')])
|
||||
);
|
||||
expect(
|
||||
addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('허보'))
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (outcome === 'intermediate') {
|
||||
expect(reference.execution.outcome).toMatchObject({
|
||||
lastTurn: {
|
||||
command: '허보',
|
||||
term: 1,
|
||||
},
|
||||
});
|
||||
expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({
|
||||
command: '허보',
|
||||
term: 1,
|
||||
});
|
||||
}
|
||||
if (completed) {
|
||||
const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!;
|
||||
expect(reference.after.world.nationCooldowns).toEqual([
|
||||
{
|
||||
nationId: 1,
|
||||
actionName: '허보',
|
||||
nextAvailableTurn: expectedNextAvailableTurn,
|
||||
},
|
||||
]);
|
||||
expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns);
|
||||
}
|
||||
if (expectedRngCalls !== undefined) {
|
||||
expect(reference.rng).toHaveLength(expectedRngCalls);
|
||||
}
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user