Merge branch 'main' into feature/dynasty-list-parity
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const targetRoot = process.env.REF_AUCTION_URL ?? 'https://dev-sam-ref.hided.net/sam/';
|
||||
const secretRoot = process.env.REF_SECRET_ROOT;
|
||||
const username = process.env.REF_USER_ID ?? 'refuser1';
|
||||
const passwordFile = process.env.REF_PASSWORD_FILE ?? 'user1_password';
|
||||
const allowGeneralCreate = process.env.REF_CREATE_GENERAL === '1';
|
||||
const outputRoot = resolve(
|
||||
process.env.REF_AUCTION_ARTIFACT_DIR ?? resolve(repositoryRoot, 'test-results/auction-reference')
|
||||
);
|
||||
|
||||
if (!secretRoot) {
|
||||
throw new Error('REF_SECRET_ROOT is required.');
|
||||
}
|
||||
|
||||
const password = (await readFile(resolve(secretRoot, passwordFile), 'utf8')).trim();
|
||||
const viewports = [
|
||||
{ name: 'desktop', width: 1000, height: 800 },
|
||||
{ name: 'mobile', width: 500, height: 800 },
|
||||
];
|
||||
|
||||
const login = async (context) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(targetRoot, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const clientPasswordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', targetRoot).toString(), {
|
||||
data: { username, password: clientPasswordHash },
|
||||
timeout: 60_000,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok() || result.result !== true) {
|
||||
throw new Error('Reference login failed.');
|
||||
}
|
||||
if (allowGeneralCreate) {
|
||||
const joinUrl = new URL('hwe/v_join.php', targetRoot).toString();
|
||||
await page.goto(joinUrl, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
if (page.url().includes('v_join.php')) {
|
||||
const createButton = page.getByRole('button', { name: '장수 생성', exact: true });
|
||||
try {
|
||||
await createButton.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
} catch {
|
||||
const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300);
|
||||
throw new Error(`Reference general form did not render: ${page.url()} | ${pageText}`);
|
||||
}
|
||||
page.on('dialog', async (dialog) => dialog.accept());
|
||||
await createButton.click();
|
||||
await page.waitForURL((url) => !url.pathname.endsWith('/v_join.php'), { timeout: 60_000 });
|
||||
}
|
||||
}
|
||||
await page.close();
|
||||
};
|
||||
|
||||
const roundedRect = (rect) => ({
|
||||
x: Math.round(rect.x * 100) / 100,
|
||||
y: Math.round(rect.y * 100) / 100,
|
||||
width: Math.round(rect.width * 100) / 100,
|
||||
height: Math.round(rect.height * 100) / 100,
|
||||
});
|
||||
|
||||
const measurePage = async (page, type) => {
|
||||
const diagnostics = [];
|
||||
const onPageError = (error) => diagnostics.push(`pageerror: ${error.message}`);
|
||||
const onConsole = (message) => {
|
||||
if (message.type() === 'error') {
|
||||
diagnostics.push(`console: ${message.text()}`);
|
||||
}
|
||||
};
|
||||
const onResponse = (response) => {
|
||||
if (response.status() >= 400) {
|
||||
diagnostics.push(`http ${response.status()}: ${response.url()}`);
|
||||
}
|
||||
};
|
||||
page.on('pageerror', onPageError);
|
||||
page.on('console', onConsole);
|
||||
page.on('response', onResponse);
|
||||
const relative = type === 'unique' ? 'hwe/v_auction.php?type=unique' : 'hwe/v_auction.php';
|
||||
await page.goto(new URL(relative, targetRoot).toString(), { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
try {
|
||||
await page.locator('#container').waitFor({ state: 'visible', timeout: 30_000 });
|
||||
} catch {
|
||||
const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300);
|
||||
const scripts = await page.locator('script').evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
src: element.getAttribute('src'),
|
||||
type: element.getAttribute('type'),
|
||||
length: element.textContent?.length ?? 0,
|
||||
}))
|
||||
);
|
||||
throw new Error(
|
||||
`Reference auction did not render: ${page.url()} | ${await page.title()} | ${pageText} | ${JSON.stringify(scripts)} | ${diagnostics.slice(0, 8).join(' | ')}`
|
||||
);
|
||||
} finally {
|
||||
page.off('pageerror', onPageError);
|
||||
page.off('console', onConsole);
|
||||
page.off('response', onResponse);
|
||||
}
|
||||
await page.locator('button').first().waitFor({ state: 'visible' });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
|
||||
const measurement = await page.evaluate(() => {
|
||||
const rect = (element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
const value = element.getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||
};
|
||||
const style = (element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
const value = getComputedStyle(element);
|
||||
return {
|
||||
color: value.color,
|
||||
backgroundColor: value.backgroundColor,
|
||||
backgroundImage: value.backgroundImage,
|
||||
borderColor: value.borderColor,
|
||||
borderWidth: value.borderWidth,
|
||||
borderRadius: value.borderRadius,
|
||||
fontFamily: value.fontFamily,
|
||||
fontSize: value.fontSize,
|
||||
fontWeight: value.fontWeight,
|
||||
lineHeight: value.lineHeight,
|
||||
padding: value.padding,
|
||||
cursor: value.cursor,
|
||||
};
|
||||
};
|
||||
const container = document.querySelector('#container');
|
||||
const topBar = container?.firstElementChild ?? null;
|
||||
const topBarButtons = [...(topBar?.querySelectorAll('button') ?? [])].map((element) => ({
|
||||
text: element.textContent?.trim() ?? '',
|
||||
rect: rect(element),
|
||||
style: style(element),
|
||||
}));
|
||||
const firstButton = document.querySelector('button');
|
||||
const firstInput = [...document.querySelectorAll('input')].find(
|
||||
(element) => element.getBoundingClientRect().width > 0
|
||||
);
|
||||
const firstAuctionRow = document.querySelector('.auctionItem');
|
||||
const firstAuctionRowChildren = [...(firstAuctionRow?.children ?? [])].map((element) => ({
|
||||
className: element.className,
|
||||
rect: rect(element),
|
||||
style: style(element),
|
||||
}));
|
||||
const firstSection = [...document.querySelectorAll('#container > div')].find((element) =>
|
||||
['쌀 구매', '쌀 판매'].includes(element.textContent?.trim() ?? '')
|
||||
);
|
||||
const directChildren = [...(container?.children ?? [])].slice(0, 12).map((element) => ({
|
||||
tag: element.tagName,
|
||||
className: element.className,
|
||||
text: element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80) ?? '',
|
||||
rect: rect(element),
|
||||
}));
|
||||
return {
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
document: {
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
},
|
||||
body: { rect: rect(document.body), style: style(document.body) },
|
||||
container: { rect: rect(container), style: style(container) },
|
||||
topBar: { rect: rect(topBar), style: style(topBar) },
|
||||
topBarButtons,
|
||||
firstButton: { rect: rect(firstButton), style: style(firstButton) },
|
||||
firstInput: { rect: rect(firstInput), style: style(firstInput) },
|
||||
firstAuctionRow: { rect: rect(firstAuctionRow), style: style(firstAuctionRow) },
|
||||
firstAuctionRowChildren,
|
||||
firstSection: { rect: rect(firstSection), style: style(firstSection) },
|
||||
directChildren,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...measurement,
|
||||
body: {
|
||||
...measurement.body,
|
||||
rect: measurement.body.rect ? roundedRect(measurement.body.rect) : null,
|
||||
},
|
||||
container: {
|
||||
...measurement.container,
|
||||
rect: measurement.container.rect ? roundedRect(measurement.container.rect) : null,
|
||||
},
|
||||
topBar: {
|
||||
...measurement.topBar,
|
||||
rect: measurement.topBar.rect ? roundedRect(measurement.topBar.rect) : null,
|
||||
},
|
||||
topBarButtons: measurement.topBarButtons.map((button) => ({
|
||||
...button,
|
||||
rect: button.rect ? roundedRect(button.rect) : null,
|
||||
})),
|
||||
firstButton: {
|
||||
...measurement.firstButton,
|
||||
rect: measurement.firstButton.rect ? roundedRect(measurement.firstButton.rect) : null,
|
||||
},
|
||||
firstInput: {
|
||||
...measurement.firstInput,
|
||||
rect: measurement.firstInput.rect ? roundedRect(measurement.firstInput.rect) : null,
|
||||
},
|
||||
firstAuctionRow: {
|
||||
...measurement.firstAuctionRow,
|
||||
rect: measurement.firstAuctionRow.rect ? roundedRect(measurement.firstAuctionRow.rect) : null,
|
||||
},
|
||||
firstAuctionRowChildren: measurement.firstAuctionRowChildren.map((child) => ({
|
||||
...child,
|
||||
rect: child.rect ? roundedRect(child.rect) : null,
|
||||
})),
|
||||
firstSection: {
|
||||
...measurement.firstSection,
|
||||
rect: measurement.firstSection.rect ? roundedRect(measurement.firstSection.rect) : null,
|
||||
},
|
||||
directChildren: measurement.directChildren.map((child) => ({
|
||||
...child,
|
||||
rect: child.rect ? roundedRect(child.rect) : null,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
await mkdir(outputRoot, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||
const results = {};
|
||||
try {
|
||||
for (const viewport of viewports) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
try {
|
||||
await login(context);
|
||||
const page = await context.newPage();
|
||||
for (const type of ['resource', 'unique']) {
|
||||
results[`${viewport.name}-${type}`] = await measurePage(page, type);
|
||||
await page.screenshot({
|
||||
path: resolve(outputRoot, `${viewport.name}-${type}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const outputPath = resolve(outputRoot, 'computed-dom.json');
|
||||
await writeFile(outputPath, `${JSON.stringify(results, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ outputPath, views: Object.keys(results) }));
|
||||
@@ -13,6 +13,7 @@ export interface CanonicalTurnSnapshot {
|
||||
engine: CanonicalEngine;
|
||||
world: Record<string, unknown>;
|
||||
generals: Array<Record<string, unknown>>;
|
||||
rankData: Array<Record<string, unknown>>;
|
||||
cities: Array<Record<string, unknown>>;
|
||||
nations: Array<Record<string, unknown>>;
|
||||
diplomacy: Array<Record<string, unknown>>;
|
||||
@@ -91,6 +92,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
||||
meta: unknown;
|
||||
};
|
||||
generals: Array<Record<string, unknown>>;
|
||||
rankData: Array<Record<string, unknown>>;
|
||||
cities: Array<Record<string, unknown>>;
|
||||
nations: Array<Record<string, unknown>>;
|
||||
diplomacy: Array<Record<string, unknown>>;
|
||||
@@ -99,6 +101,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
||||
logs: Array<Record<string, unknown>>;
|
||||
}): CanonicalTurnSnapshot => {
|
||||
const worldMeta = asRecord(rows.world.meta);
|
||||
const legacyRankTypes = new Set<string>(LEGACY_RANK_DATA_TYPES);
|
||||
const generals = rows.generals.map((row) => {
|
||||
const meta = asRecord(row.meta);
|
||||
return {
|
||||
@@ -235,6 +238,14 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
||||
isUnited: readNumber(worldMeta, 'isUnited', readNumber(worldMeta, 'isunited')),
|
||||
},
|
||||
generals,
|
||||
rankData: rows.rankData
|
||||
.filter((row) => typeof row.type === 'string' && legacyRankTypes.has(row.type))
|
||||
.map((row) => ({
|
||||
generalId: row.generalId,
|
||||
nationId: row.nationId,
|
||||
type: row.type,
|
||||
value: row.value,
|
||||
})),
|
||||
cities,
|
||||
nations,
|
||||
diplomacy,
|
||||
@@ -248,3 +259,4 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
||||
},
|
||||
};
|
||||
};
|
||||
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface SnapshotComparisonOptions {
|
||||
type FlatSnapshot = Map<string, unknown>;
|
||||
|
||||
const entityKey = (value: Record<string, unknown>, index: number): string => {
|
||||
if (
|
||||
(typeof value.generalId === 'number' || typeof value.generalId === 'string') &&
|
||||
typeof value.type === 'string'
|
||||
) {
|
||||
return `${String(value.generalId)}:${value.type}`;
|
||||
}
|
||||
for (const key of ['id', 'generalId', 'nationId', 'fromNationId']) {
|
||||
const candidate = value[key];
|
||||
if (typeof candidate === 'number' || typeof candidate === 'string') {
|
||||
|
||||
@@ -18,6 +18,10 @@ import type {
|
||||
TurnWorldSnapshot,
|
||||
TurnWorldState,
|
||||
} from '@sammo-ts/game-engine/turn/types.js';
|
||||
import {
|
||||
applyPersistedRankRowsToMeta,
|
||||
buildLegacyComparableRankRows,
|
||||
} from '@sammo-ts/game-engine/turn/rankData.js';
|
||||
|
||||
import {
|
||||
canonicalizeTurnCommandArgs,
|
||||
@@ -47,6 +51,7 @@ export interface TurnCommandFixtureRequest {
|
||||
};
|
||||
isolateWorld?: boolean;
|
||||
generals?: Array<Record<string, unknown>>;
|
||||
rankData?: Array<{ generalId: number; type: string; value: number }>;
|
||||
nations?: Array<Record<string, unknown>>;
|
||||
cities?: Array<Record<string, unknown>>;
|
||||
troops?: Array<Record<string, unknown>>;
|
||||
@@ -295,6 +300,17 @@ const buildWorldInput = (
|
||||
const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1);
|
||||
const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`);
|
||||
const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime));
|
||||
for (const general of generals) {
|
||||
applyPersistedRankRowsToMeta(
|
||||
general.meta,
|
||||
referenceBefore.rankData
|
||||
.filter((row) => readNumber(row, 'generalId') === general.id)
|
||||
.map((row) => ({
|
||||
type: readString(row, 'type', ''),
|
||||
value: readNumber(row, 'value'),
|
||||
}))
|
||||
);
|
||||
}
|
||||
const referenceGeneralCooldowns = Array.isArray(referenceBefore.world.generalCooldowns)
|
||||
? referenceBefore.world.generalCooldowns
|
||||
: [];
|
||||
@@ -350,6 +366,7 @@ const buildWorldInput = (
|
||||
baseRice: 2_000,
|
||||
generalMinimumGold: 0,
|
||||
generalMinimumRice: 500,
|
||||
npcSeizureMessageProb: 0.01,
|
||||
maxResourceActionAmount: 10_000,
|
||||
maxTechLevel: 12,
|
||||
maxLevel: 255,
|
||||
@@ -523,6 +540,11 @@ const projectWorld = (
|
||||
}),
|
||||
},
|
||||
generals,
|
||||
rankData: world
|
||||
.listGenerals()
|
||||
.filter((general) => selector.generalIds.has(general.id))
|
||||
.flatMap(buildLegacyComparableRankRows)
|
||||
.map((row) => ({ ...row })),
|
||||
cities: world
|
||||
.listCities()
|
||||
.filter((city) => selector.cityIds.has(city.id))
|
||||
|
||||
@@ -11,11 +11,15 @@ export const readCoreDatabaseSnapshot = async (
|
||||
try {
|
||||
const db = connector.prisma;
|
||||
const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
const [generals, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([
|
||||
const [generals, rankData, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([
|
||||
db.general.findMany({
|
||||
where: { id: { in: selector.generalIds } },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
db.rankData.findMany({
|
||||
where: { generalId: { in: selector.generalIds } },
|
||||
orderBy: [{ generalId: 'asc' }, { type: 'asc' }],
|
||||
}),
|
||||
db.city.findMany({
|
||||
where: { id: { in: selector.cityIds } },
|
||||
orderBy: { id: 'asc' },
|
||||
@@ -54,6 +58,7 @@ export const readCoreDatabaseSnapshot = async (
|
||||
return projectCoreDatabaseSnapshot({
|
||||
world,
|
||||
generals,
|
||||
rankData,
|
||||
cities,
|
||||
nations,
|
||||
diplomacy,
|
||||
|
||||
@@ -451,6 +451,10 @@ describe('auction integration flow', () => {
|
||||
const initialScore = await redis.zScore(keys.timerKey, String(auction.id));
|
||||
expect(Number(initialScore)).toBe(initialCloseAt.getTime());
|
||||
|
||||
await expect(hostClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow(
|
||||
'자신이 연 경매에 입찰할 수 없습니다.'
|
||||
);
|
||||
|
||||
const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||
value: bidder1.accessToken,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||
|
||||
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
|
||||
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
|
||||
@@ -72,6 +73,7 @@ interface FixturePatches {
|
||||
troops?: Array<Record<string, unknown>>;
|
||||
diplomacy?: Record<string, Record<string, unknown>>;
|
||||
randomFoundingCandidateCityIds?: number[];
|
||||
rankData?: Array<{ generalId: number; type: string; value: number }>;
|
||||
}
|
||||
|
||||
const buildRequest = (
|
||||
@@ -166,6 +168,7 @@ const buildRequest = (
|
||||
{ ...general(2, 2, 70, 12), ...fixturePatches.generals?.[2] },
|
||||
{ ...general(3, 1, 3, 1), ...fixturePatches.generals?.[3] },
|
||||
],
|
||||
...(fixturePatches.rankData ? { rankData: fixturePatches.rankData } : {}),
|
||||
...(fixturePatches.troops ? { troops: fixturePatches.troops } : {}),
|
||||
...(fixturePatches.randomFoundingCandidateCityIds
|
||||
? { randomFoundingCandidateCityIds: fixturePatches.randomFoundingCandidateCityIds }
|
||||
@@ -382,6 +385,67 @@ integration('general command success matrix', () => {
|
||||
);
|
||||
});
|
||||
|
||||
integration('명장일람 rank_data command parity', () => {
|
||||
it('화계 increments firenum from the same seeded value as legacy', async () => {
|
||||
const request = buildRequest(
|
||||
'che_화계',
|
||||
{ destCityID: 70 },
|
||||
{ intelligence: 100 },
|
||||
{
|
||||
generals: { 2: { intelligence: 10 } },
|
||||
rankData: [{ generalId: 1, type: 'firenum', value: 17 }],
|
||||
}
|
||||
);
|
||||
request.setup!.world!.hiddenSeed = 'general-injury-4';
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.after.rankData).toContainEqual(
|
||||
expect.objectContaining({ generalId: 1, type: 'firenum', value: 18 })
|
||||
);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
it('은퇴 resets every legacy RankColumn row exactly like legacy', async () => {
|
||||
const request = buildRequest(
|
||||
'che_은퇴',
|
||||
undefined,
|
||||
{ age: 65, lastTurn: { command: '은퇴', term: 1 } },
|
||||
{
|
||||
rankData: LEGACY_RANK_DATA_TYPES.map((type, index) => ({
|
||||
generalId: 1,
|
||||
type,
|
||||
value: index + 1,
|
||||
})),
|
||||
}
|
||||
);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.after.rankData.filter((row) => row.generalId === 1)).toHaveLength(
|
||||
LEGACY_RANK_DATA_TYPES.length
|
||||
);
|
||||
expect(reference.after.rankData.filter((row) => row.generalId === 1).every((row) => row.value === 0)).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
type GeneralFailureCase = {
|
||||
action: string;
|
||||
args?: Record<string, unknown>;
|
||||
|
||||
@@ -11,6 +11,9 @@ const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
||||
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
||||
|
||||
const readGold = (row: { gold?: unknown } | undefined): number => (typeof row?.gold === 'number' ? row.gold : 0);
|
||||
const NPC_SEIZURE_MESSAGE_TEXT = '몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...';
|
||||
|
||||
const ignoredLifecyclePaths = [
|
||||
/^generalTurns/,
|
||||
/^nationTurns/,
|
||||
@@ -469,3 +472,204 @@ integration('nation command success matrix', () => {
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
const nationResourceAmountCases: Array<{
|
||||
name: string;
|
||||
action: 'che_포상' | 'che_몰수';
|
||||
args: Record<string, unknown>;
|
||||
expectedAmount: number;
|
||||
}> = [
|
||||
{
|
||||
name: 'award rounds a half unit up',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 150, destGeneralID: 3 },
|
||||
expectedAmount: 200,
|
||||
},
|
||||
{
|
||||
name: 'award clamps below the minimum',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 1, destGeneralID: 3 },
|
||||
expectedAmount: 100,
|
||||
},
|
||||
{
|
||||
name: 'award clamps above the maximum',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
|
||||
expectedAmount: 10_000,
|
||||
},
|
||||
{
|
||||
name: 'seizure rounds a half unit up',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 150, destGeneralID: 3 },
|
||||
expectedAmount: 200,
|
||||
},
|
||||
{
|
||||
name: 'seizure clamps below the minimum',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 1, destGeneralID: 3 },
|
||||
expectedAmount: 100,
|
||||
},
|
||||
{
|
||||
name: 'seizure clamps above the maximum',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
|
||||
expectedAmount: 10_000,
|
||||
},
|
||||
];
|
||||
|
||||
integration('nation command resource amount normalization matrix', () => {
|
||||
it.each(nationResourceAmountCases)(
|
||||
'$name matches legacy rounding and clamp semantics',
|
||||
async ({ action, args, expectedAmount }) => {
|
||||
const request = buildRequest(action, args);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
const referenceTargetBefore = reference.before.generals.find((entry) => entry.id === 3);
|
||||
const referenceTargetAfter = reference.after.generals.find((entry) => entry.id === 3);
|
||||
const coreTargetBefore = core.before.generals.find((entry) => entry.id === 3);
|
||||
const coreTargetAfter = core.after.generals.find((entry) => entry.id === 3);
|
||||
const referenceAmount =
|
||||
action === 'che_포상'
|
||||
? readGold(referenceTargetAfter) - readGold(referenceTargetBefore)
|
||||
: readGold(referenceTargetBefore) - readGold(referenceTargetAfter);
|
||||
const coreAmount =
|
||||
action === 'che_포상'
|
||||
? readGold(coreTargetAfter) - readGold(coreTargetBefore)
|
||||
: readGold(coreTargetBefore) - readGold(coreTargetAfter);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: action,
|
||||
actionKey: action,
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(referenceAmount).toBe(expectedAmount);
|
||||
expect(coreAmount).toBe(expectedAmount);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
const nationResourceBoundaryCases: Array<{
|
||||
name: string;
|
||||
action: 'che_포상' | 'che_몰수';
|
||||
args: Record<string, unknown>;
|
||||
fixturePatches?: FixturePatches;
|
||||
completed: boolean;
|
||||
}> = [
|
||||
{
|
||||
name: 'award is limited to the available nation gold',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 10_000, destGeneralID: 3 },
|
||||
fixturePatches: { nations: { 1: { gold: 5_000 } } },
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
name: 'award keeps the legacy base rice reserve',
|
||||
action: 'che_포상',
|
||||
args: { isGold: false, amount: 10_000, destGeneralID: 3 },
|
||||
fixturePatches: { nations: { 1: { rice: 2_100 } } },
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
name: 'award rejects the actor as its target',
|
||||
action: 'che_포상',
|
||||
args: { isGold: true, amount: 100, destGeneralID: 1 },
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
name: 'seizure is limited to the target general gold',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 1_000, destGeneralID: 3 },
|
||||
fixturePatches: { generals: { 3: { gold: 50 } } },
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
name: 'seizure rejects the actor as its target',
|
||||
action: 'che_몰수',
|
||||
args: { isGold: true, amount: 100, destGeneralID: 1 },
|
||||
completed: false,
|
||||
},
|
||||
];
|
||||
|
||||
integration('nation command resource balance and target boundaries', () => {
|
||||
it.each(nationResourceBoundaryCases)(
|
||||
'$name matches legacy completion, RNG, and state delta',
|
||||
async ({ action, args, fixturePatches, completed }) => {
|
||||
const request = buildRequest(action, args, 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 });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: action,
|
||||
actionKey: completed ? action : '휴식',
|
||||
usedFallback: !completed,
|
||||
});
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
integration('nation seizure NPC public message parity', () => {
|
||||
it('matches the legacy fixed-seed RNG and public message side effect', async () => {
|
||||
const request = buildRequest(
|
||||
'che_몰수',
|
||||
{ isGold: true, amount: 100, destGeneralID: 3 },
|
||||
{
|
||||
world: { hiddenSeed: 'seizure-message-37' },
|
||||
generals: { 3: { name: '몰수NPC', npcState: 2 } },
|
||||
}
|
||||
);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(reference.rng).toHaveLength(2);
|
||||
expect(reference.rng.map((call) => call.operation)).toEqual(['nextFloat1', 'nextInt']);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
const referenceMessages = reference.after.messages.slice(reference.before.messages.length);
|
||||
expect(referenceMessages).toHaveLength(1);
|
||||
expect(core.after.messages).toHaveLength(1);
|
||||
expect(referenceMessages[0]).toMatchObject({
|
||||
mailbox: 9999,
|
||||
type: 'public',
|
||||
sourceId: 3,
|
||||
destinationId: 9999,
|
||||
payload: {
|
||||
src: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' },
|
||||
dest: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' },
|
||||
text: NPC_SEIZURE_MESSAGE_TEXT,
|
||||
},
|
||||
});
|
||||
expect(core.after.messages[0]).toMatchObject({
|
||||
payload: {
|
||||
msgType: 'public',
|
||||
src: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' },
|
||||
dest: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' },
|
||||
text: NPC_SEIZURE_MESSAGE_TEXT,
|
||||
},
|
||||
});
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ const snapshot = (
|
||||
engine,
|
||||
world: { year: 183, month: 1, tickMinutes: 10, turnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 },
|
||||
generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
|
||||
rankData: [],
|
||||
cities: [{ id: 1, nationId: 1, agriculture: 1000, defence: 500 }],
|
||||
nations: [{ id: 1, gold: 0, rice: 0 }],
|
||||
diplomacy: [],
|
||||
@@ -44,6 +45,23 @@ describe('turn snapshot differential comparator', () => {
|
||||
expect(compareTurnSnapshots(reference, core)).toEqual([]);
|
||||
});
|
||||
|
||||
it('compares rank rows by general and type instead of array position', () => {
|
||||
const reference = snapshot('ref', {
|
||||
rankData: [
|
||||
{ generalId: 2, nationId: 1, type: 'firenum', value: 3 },
|
||||
{ generalId: 1, nationId: 1, type: 'warnum', value: 5 },
|
||||
],
|
||||
});
|
||||
const core = snapshot('core2026', {
|
||||
rankData: [
|
||||
{ generalId: 1, nationId: 1, type: 'warnum', value: 5 },
|
||||
{ generalId: 2, nationId: 1, type: 'firenum', value: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(compareTurnSnapshots(reference, core)).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes legacy ID argument spelling at the trace boundary', () => {
|
||||
expect(
|
||||
canonicalizeTurnCommandArgs({
|
||||
|
||||
Reference in New Issue
Block a user