test: harden exhaustive compatibility suites

This commit is contained in:
2026-07-30 16:19:30 +00:00
parent 4ebd3fd39b
commit a9dbb3fd4d
28 changed files with 454 additions and 97 deletions
+1 -1
View File
@@ -272,7 +272,7 @@ describe('auction router actor and permission boundaries', () => {
hostGeneralId: 88,
detail: { startBidAmount: 100, isReverse: false },
status: 'OPEN',
closeAt: new Date('2026-07-27T00:00:00Z'),
closeAt: new Date(Date.now() + 60 * 60_000),
},
];
}
+7 -1
View File
@@ -4,7 +4,13 @@ import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
const imageRoots = [
...(process.env.FRONTEND_PARITY_IMAGE_ROOT
? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')]
: []),
resolve(repositoryRoot, '../image/game'),
resolve(repositoryRoot, '../../image/game'),
];
type AuctionFixture = {
failResourceBid?: boolean;
@@ -4,7 +4,11 @@ import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
const imageRoots = [
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT)] : []),
resolve(repositoryRoot, '../image'),
resolve(repositoryRoot, '../../image'),
];
const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR;
const response = (data: unknown) => ({ result: { data } });
+5 -1
View File
@@ -4,7 +4,11 @@ import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
const imageRoots = [
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT)] : []),
resolve(repositoryRoot, '../image'),
resolve(repositoryRoot, '../../image'),
];
const artifactRoot = process.env.BOARD_ARTIFACT_DIR;
type Article = {
@@ -18,7 +18,7 @@ test('reserves an argument command in the real game API and reads it back from P
});
const passwordKey = await gateway.auth.passwordKey.query();
const login = await gateway.auth.login.mutate({
username: 'demo1',
username: 'demo2',
credential: {
keyId: passwordKey.keyId,
ciphertext: publicEncrypt(
@@ -27,7 +27,7 @@ test('reserves an argument command in the real game API and reads it back from P
padding: constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256',
},
Buffer.from('demo-pass-1', 'utf8')
Buffer.from('demo-pass-2', 'utf8')
).toString('base64'),
},
});
@@ -46,7 +46,9 @@ test('reserves an argument command in the real game API and reads it back from P
});
accessToken = (await game.auth.exchangeGatewayToken.mutate({ gatewayToken: issued.gameToken })).accessToken;
const context = await game.general.me.query();
if (!context) throw new Error('demo1 general is missing');
if (!context) throw new Error('demo2 general is missing');
if (!context.nation) throw new Error('demo2 nation is missing');
const nationName = context.nation.name;
const generalId = context.general.id;
const originalGeneralSnapshot = await game.turns.reserved.getGeneral.query({ generalId });
const original = (originalGeneralSnapshot.turns as unknown as PlainTurn[])[29];
@@ -95,8 +97,19 @@ test('reserves an argument command in the real game API and reads it back from P
const generalSelect = form.locator('select');
const generalValues = await generalSelect
.locator('option')
.evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value));
const targetGeneralId = Number(generalValues.find((value) => Number(value) !== generalId));
.evaluateAll((options) =>
options.map((option) => ({
value: (option as HTMLOptionElement).value,
label: option.textContent ?? '',
}))
);
const targetGeneralId = Number(
generalValues.find(
(option) =>
Number(option.value) !== generalId &&
option.label.includes(`(${nationName} ·`)
)?.value
);
expect(targetGeneralId).toBeGreaterThan(0);
await generalSelect.selectOption(String(targetGeneralId));
const nationSection = page.locator('.reserved-section').filter({ hasText: '국가 예턴' });
@@ -110,7 +123,7 @@ test('reserves an argument command in the real game API and reads it back from P
args: { isGold: false, amount: 1, destGeneralId: targetGeneralId },
});
await page.screenshot({
path: testInfo.outputPath('real-hwe-command-reserved.png'),
path: testInfo.outputPath('real-che-command-reserved.png'),
fullPage: true,
});
} finally {
+5 -1
View File
@@ -14,7 +14,11 @@ type FixtureState = {
const artifactRoot = process.env.OFFICE_PARITY_ARTIFACT_DIR ? resolve(process.env.OFFICE_PARITY_ARTIFACT_DIR) : null;
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
const imageRoots = [
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT)] : []),
resolve(repositoryRoot, '../image'),
resolve(repositoryRoot, '../../image'),
];
const referenceAsset = async (relativePath: string): Promise<Buffer> => {
for (const root of imageRoots) {
try {
+5 -1
View File
@@ -14,7 +14,11 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../.
const artifactRoot = process.env.NPC_POLICY_PARITY_ARTIFACT_DIR
? resolve(process.env.NPC_POLICY_PARITY_ARTIFACT_DIR)
: null;
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
const imageRoots = [
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT)] : []),
resolve(repositoryRoot, '../image'),
resolve(repositoryRoot, '../../image'),
];
const referenceAsset = async (relativePath: string): Promise<Buffer> => {
for (const root of imageRoots) {
try {
+7 -1
View File
@@ -4,7 +4,13 @@ import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
const imageRoots = [
...(process.env.FRONTEND_PARITY_IMAGE_ROOT
? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')]
: []),
resolve(repositoryRoot, '../image/game'),
resolve(repositoryRoot, '../../image/game'),
];
const readReferenceImage = async (filename: string): Promise<Buffer> => {
for (const imageRoot of imageRoots) {
@@ -118,6 +118,12 @@ cd docker_compose_files/reference
> /tmp/ref-trace.json
```
When the integration tests need to exercise a feature-worktree copy of the
case wrapper, set `TURN_DIFFERENTIAL_CASE_SCRIPT` to that shell script. This is
intentionally distinct from `TURN_DIFFERENTIAL_RUNNER_SCRIPT`, which the shell
wrapper forwards to select the PHP comparison runner inside the reference
container.
Capture the core side by wrapping the real reserved-turn or daemon execution
with `captureCoreDatabaseTurnTrace()`. Save the returned JSON, then compare:
+17 -37
View File
@@ -1,43 +1,23 @@
Compare command logs (mode: action, strict: off, keepDate: off, excludeGuards: on, excludeTarget: on)
PHP commands: 83
TS commands: 63
Matched commands: 60
Compare command logs (mode: action, strict: off, keepDate: off, excludeGuards: on, excludeTarget: on, countSensitive: off)
PHP commands: 87
TS commands: 84
Matched commands: 87
Mismatched commands: 0
Missing in TS: 23
Missing in PHP: 3
Ignored mismatches: 10
Missing in TS: 0
Missing in PHP: 0
Ignored mismatches: 17
Missing in TS:
- General/che_군량매매
- General/che_등용수락
- General/che_모반시도
- General/che_무작위건국
- General/che_선양
- General/che_장비매매
- General/che_장수대상임관
- General/che_전투태세
- General/che_접경귀환
- General/che_증여
- General/che_해산
- General/cr_건국
- General/cr_맹훈련
- Nation/che_선전포고
- Nation/event_극병연구
- Nation/event_대검병연구
- Nation/event_무희연구
- Nation/event_산저병연구
- Nation/event_상병연구
- Nation/event_원융노병연구
- Nation/event_음귀병연구
- Nation/event_화륜차연구
- Nation/event_화시병연구
Shared TS log implementations excluded from per-command extraction:
- Nation/che_불가침수락: logs are emitted by
`packages/logic/src/diplomacy/instantResponse.ts` and verified by
`instantDiplomacyReference.integration.test.ts`.
- Nation/che_불가침파기수락: logs are emitted by
`packages/logic/src/diplomacy/instantResponse.ts` and verified by
`instantDiplomacyReference.integration.test.ts`.
- Nation/che_종전수락: logs are emitted by
`packages/logic/src/diplomacy/instantResponse.ts` and verified by
`instantDiplomacyReference.integration.test.ts`.
Missing in PHP:
- General/che_내정특기초기화
- Nation/che_불가침수락
- Nation/che_불가침파기수락
Checklist:
# Command Log Checklist
Mode: action
+10
View File
@@ -64,6 +64,16 @@ pnpm --filter @sammo-ts/integration-tests test:integration
These are loaded from `.env.ci` and can be overridden per run.
The HTTP lifecycle fixtures explicitly set
`localAccountGeneralCreationGraceDays` on their disposable profile before
issuing game sessions for locally provisioned users. This keeps the test on the
same profile-policy path as production instead of bypassing Kakao eligibility in
the game API. The PM2 orchestrator fixture also uses a temporary `PM2_HOME` and
deletes all five profile roles before stopping that dedicated daemon. Cleanup
verifies the temporary daemon PID and command line, waits for its exit, and only
then removes `PM2_HOME`, so it does not share the operator's global PM2 daemon
or retain processes and paths from an older worktree.
## Notes
- `auth.bootstrapLocal` only works when no users exist; the test resets the DB
@@ -75,6 +75,70 @@
"ts": {
"name": "notBeNeutral"
}
},
{
"id": "reqDestNationValue(nation==destGeneral.nation) -> destGeneralBelongsToDestNation",
"noteTemplate": "destination general belongs to destination nation",
"php": {
"name": "reqDestNationValue",
"equals": [
{ "index": 0, "value": "nation", "transform": "field" },
{ "index": 2, "value": "==", "transform": "comp" }
]
},
"ts": {
"name": "destGeneralBelongsToDestNation"
}
}
],
"oneSidedRules": [
{
"id": "npc-active destination loaded by legacy argTest",
"command": "General/che_NPC능동",
"kind": "full",
"side": "ts",
"name": "existsDestCity",
"note": "legacy argTest loads and rejects a missing destination; core exposes the equivalent constraint"
},
{
"id": "recruit-accept self target checked outside legacy constraint array",
"command": "General/che_등용수락",
"kind": "full",
"side": "ts",
"name": "differentDestGeneral",
"note": "legacy argTest rejects self before building constraints"
},
{
"id": "abdication self target checked outside legacy constraint array",
"command": "General/che_선양",
"kind": "full",
"side": "ts",
"name": "differentDestGeneral",
"note": "legacy argTest rejects self before building constraints"
},
{
"id": "gift self target checked outside legacy constraint array",
"command": "General/che_증여",
"kind": "full",
"side": "ts",
"name": "alwaysFail",
"note": "legacy argTest rejects self before building constraints"
},
{
"id": "assignment self target becomes legacy dynamic AlwaysFail",
"command": "Nation/che_발령",
"kind": "full",
"side": "php",
"name": "alwaysFail",
"note": "core preserves the legacy completed-false action in resolve-time handling"
},
{
"id": "troop-kick self target becomes legacy dynamic AlwaysFail",
"command": "Nation/che_부대탈퇴지시",
"kind": "full",
"side": "php",
"name": "alwaysFail",
"note": "core preserves the legacy completed-false action in resolve-time handling"
}
]
}
+62 -15
View File
@@ -1,9 +1,11 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import ts from 'typescript';
import { resolveRefRoot } from './resolve-ref-root.mjs';
const ROOT_DIR = process.cwd();
const PHP_ROOT = path.join(ROOT_DIR, 'legacy', 'hwe', 'sammo', 'Command');
const REF_ROOT = resolveRefRoot(ROOT_DIR);
const PHP_ROOT = path.join(REF_ROOT, 'hwe', 'sammo', 'Command');
const TS_ROOT = path.join(ROOT_DIR, 'packages', 'logic', 'src', 'actions');
const DEFAULT_MODE = 'all';
@@ -70,6 +72,7 @@ if (!Number.isFinite(similarityThreshold) || similarityThreshold < 0 || similari
}
let compatibilityRules = [];
let oneSidedCompatibilityRules = [];
let loadedCompatFile = null;
const collectFiles = async (dir) => {
@@ -546,6 +549,36 @@ const compileCompatibilityRules = (parsed) => {
return compiled;
};
const compileOneSidedCompatibilityRules = (parsed) => {
const rawRules = toArrayOrEmpty(parsed?.oneSidedRules);
return rawRules.map((rule, idx) => {
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
throw new Error(`One-sided compatibility rule at index ${idx} must be an object.`);
}
const id =
typeof rule.id === 'string' && rule.id.trim().length > 0
? rule.id.trim()
: `one_sided_compat_rule_${idx + 1}`;
const command = typeof rule.command === 'string' ? rule.command.trim() : '';
const kind = rule.kind === 'full' || rule.kind === 'min' ? rule.kind : null;
const side = rule.side === 'php' || rule.side === 'ts' ? rule.side : null;
const rawName = typeof rule.name === 'string' ? rule.name.trim() : '';
if (!command || !kind || !side || !rawName) {
throw new Error(
`One-sided compatibility rule "${id}" requires command, kind (full|min), side (php|ts), and name.`
);
}
return {
id,
command,
kind,
side,
name: normalizeRuleConstraintName(side, rawName),
note: typeof rule.note === 'string' ? rule.note : '',
};
});
};
const formatCompatibilityNote = (template, bindings) => {
if (!template) {
const keys = Object.keys(bindings);
@@ -623,6 +656,7 @@ const findCompatibilityMatch = (phpEntry, tsEntry) => {
const loadCompatibilityRules = async () => {
compatibilityRules = [];
oneSidedCompatibilityRules = [];
loadedCompatFile = null;
if (!useCompat) {
return;
@@ -631,6 +665,7 @@ const loadCompatibilityRules = async () => {
const text = await fs.readFile(resolvedFile, 'utf-8');
const parsed = JSON.parse(text);
compatibilityRules = compileCompatibilityRules(parsed);
oneSidedCompatibilityRules = compileOneSidedCompatibilityRules(parsed);
loadedCompatFile = path.relative(ROOT_DIR, resolvedFile);
};
@@ -1155,7 +1190,7 @@ const extractTsConstraintEntriesFromExpr = (expr, sourceFile, side, kind, file,
}
const looksConstraintFactory =
factorySet.has(callName) ||
/^(req|not|be|exists|exist|friendly|different|always|near|allow|disallow|must|check|occupied|supplied|hasRoute|available)/i.test(
/^(req|not|be|exists|exist|friendly|different|dest|always|near|allow|disallow|must|check|occupied|supplied|hasRoute|available)/i.test(
callName
);
if (looksConstraintFactory) {
@@ -1603,7 +1638,7 @@ const pairCompatibleMatches = (phpIndex, tsIndex, missing, extra) => {
return { compatible, unresolvedMissing, unresolvedExtra };
};
const compareConstraintSet = (phpEntries, tsEntries, kind) => {
const compareConstraintSet = (commandKey, phpEntries, tsEntries, kind) => {
const phpIndex = indexByName(phpEntries);
const tsIndex = indexByName(tsEntries);
const phpKeys = [...phpIndex.keys()].sort();
@@ -1625,6 +1660,24 @@ const compareConstraintSet = (phpEntries, tsEntries, kind) => {
near = nearPair.near;
missingInTs = nearPair.unresolvedMissing;
extraInTs = nearPair.unresolvedExtra;
for (const rule of oneSidedCompatibilityRules) {
if (rule.command !== commandKey || rule.kind !== kind) {
continue;
}
const bucket = rule.side === 'php' ? missingInTs : extraInTs;
const index = bucket.indexOf(rule.name);
if (index < 0) {
continue;
}
bucket.splice(index, 1);
compatible.push({
phpName: rule.side === 'php' ? rule.name : '[legacy runtime check]',
tsName: rule.side === 'ts' ? rule.name : '[core resolve-time check]',
ruleId: rule.id,
note: rule.note,
});
}
}
let status = 'MATCH';
@@ -1687,8 +1740,8 @@ const buildReport = (phpByCommand, tsByCommand) => {
continue;
}
const fullResult = compareConstraintSet(php.full ?? [], tsEntry.full ?? [], 'full');
const minResult = compareConstraintSet(php.min ?? [], tsEntry.min ?? [], 'min');
const fullResult = compareConstraintSet(key, php.full ?? [], tsEntry.full ?? [], 'full');
const minResult = compareConstraintSet(key, php.min ?? [], tsEntry.min ?? [], 'min');
let overallStatus = 'MATCH';
const activeResults = [];
@@ -1713,15 +1766,7 @@ const buildReport = (phpByCommand, tsByCommand) => {
});
}
const comparedCommands = commands.filter((command) => {
if (mode === 'full') {
return true;
}
if (mode === 'min') {
return true;
}
return true;
});
const comparedCommands = commands;
const totals = {
phpCommands: phpByCommand.size,
@@ -1768,7 +1813,9 @@ const printReport = (report) => {
`Compare command constraints (mode: ${mode}, strict: ${strict ? 'on' : 'off'}, compat: ${useCompat ? 'on' : 'off'}, similarity: ${similarityThreshold})`
);
if (useCompat) {
console.log(`Compatibility rules: ${compatibilityRules.length} (${loadedCompatFile ?? compatFile})`);
console.log(
`Compatibility rules: ${compatibilityRules.length} pair + ${oneSidedCompatibilityRules.length} one-sided (${loadedCompatFile ?? compatFile})`
);
}
console.log(`PHP commands: ${report.totals.phpCommands}`);
console.log(`TS commands: ${report.totals.tsCommands}`);
+21
View File
@@ -1,9 +1,15 @@
{
"SharedTsLogCommands": {
"Nation/che_불가침수락": "logs are emitted by diplomacy/instantResponse.ts and verified by instantDiplomacyReference.integration.test.ts",
"Nation/che_불가침파기수락": "logs are emitted by diplomacy/instantResponse.ts and verified by instantDiplomacyReference.integration.test.ts",
"Nation/che_종전수락": "logs are emitted by diplomacy/instantResponse.ts and verified by instantDiplomacyReference.integration.test.ts"
},
"Global": {
"templates": [
"${}에게서 몰수할 ${}이 없습니다.",
"이동할 인구가 부족합니다.",
"이미 같은 국호를 가진 곳이 있습니다. ${} 실패",
"이미 같은 국호를 가진 곳이 있습니다. 국호변경 실패",
"<Y>${}</>에게 등용 권유 서신을 보내지 못했습니다. ${}",
"<Y>${}</>에게 부대 탈퇴를 지시 받았습니다.",
"<Y>${}</>에 의해 <G>${}</>${} 발령됐습니다.",
@@ -26,6 +32,13 @@
"templates": ["${}"],
"regex": []
},
"General/che_인재탐색": {
"templates": [
"<Y>${}</>${}는 <C>인재</>를 ${}하였습니다!",
"<Y>${}</>${}는 <C>인재</>를 발견하였습니다!"
],
"regex": []
},
"General/che_기술연구": {
"templates": [
"${}${} 하여 <C>${}</> 상승했습니다.",
@@ -74,5 +87,13 @@
"General/che_파괴": {
"templates": ["<G>${}</>에 파괴${} 실패했습니다."],
"regex": []
},
"Nation/che_발령": {
"templates": ["본인입니다 <Y>${}</> 발령 실패."],
"regex": []
},
"Nation/che_부대탈퇴지시": {
"templates": ["본인입니다 부대 탈퇴 지시 실패."],
"regex": []
}
}
+24 -4
View File
@@ -1,9 +1,11 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import ts from 'typescript';
import { resolveRefRoot } from './resolve-ref-root.mjs';
const ROOT_DIR = process.cwd();
const PHP_ROOT = path.join(ROOT_DIR, 'legacy', 'hwe', 'sammo', 'Command');
const REF_ROOT = resolveRefRoot(ROOT_DIR);
const PHP_ROOT = path.join(REF_ROOT, 'hwe', 'sammo', 'Command');
const TS_ROOT = path.join(ROOT_DIR, 'packages', 'logic', 'src', 'actions');
const DEFAULT_MODE = 'action';
@@ -480,7 +482,7 @@ const extractPhpLogCalls = (text, assignments) => {
const actorGeneralVars = findPhpActorGeneralVars(text);
const actorLoggerVars = findPhpActorLoggerVars(text, actorGeneralVars);
const regex =
/([$\w><:\-\(\)]+)\s*->\s*(pushGeneralActionLog|pushGeneralHistoryLog|pushNationalActionLog|pushNationalHistoryLog|pushGlobalActionLog|pushGlobalHistoryLog)\s*\(/g;
/([$\w><:\-()]+)\s*->\s*(pushGeneralActionLog|pushGeneralHistoryLog|pushNationalActionLog|pushNationalHistoryLog|pushGlobalActionLog|pushGlobalHistoryLog)\s*\(/g;
while (true) {
const match = regex.exec(text);
@@ -786,9 +788,12 @@ const compileIgnoreRules = (config) => {
return {
globalTemplates: new Set(normalizeList(global.templates)),
globalRegex: compileRegex(global.regex),
sharedTsLogCommands: new Map(
Object.entries(config.SharedTsLogCommands ?? {}).map(([key, reason]) => [key, String(reason)])
),
perCommand: new Map(
Object.entries(config)
.filter(([key]) => key !== 'Global')
.filter(([key]) => key !== 'Global' && key !== 'SharedTsLogCommands')
.map(([key, value]) => [
key,
{
@@ -1044,6 +1049,7 @@ const buildReport = (phpLogs, tsLogs, ignoreRules) => {
const mismatches = [];
const matches = [];
const ignored = [];
const ignoredCommands = [];
for (const key of sortedKeys) {
const phpEntries = phpLogs.get(key) ?? [];
@@ -1053,6 +1059,12 @@ const buildReport = (phpLogs, tsLogs, ignoreRules) => {
continue;
}
if (tsEntries.length === 0) {
const reason = ignoreRules.sharedTsLogCommands.get(key);
if (reason) {
matches.push(key);
ignoredCommands.push({ key, side: 'ts', reason });
continue;
}
missingInTs.push(key);
continue;
}
@@ -1118,12 +1130,13 @@ const buildReport = (phpLogs, tsLogs, ignoreRules) => {
sharedCommands: keys.size - missingInPhp.length - missingInTs.length,
matches: matches.length,
mismatches: mismatches.length,
ignored: ignored.length,
ignored: ignored.length + ignoredCommands.length,
},
missingInTs,
missingInPhp,
mismatches,
ignored,
ignoredCommands,
};
};
@@ -1177,6 +1190,13 @@ const main = async () => {
console.log(`Missing in PHP: ${report.missingInPhp.length}`);
console.log(`Ignored mismatches: ${report.totals.ignored}`);
if (report.ignoredCommands.length > 0) {
console.log('\nShared TS log implementations excluded from per-command extraction:');
for (const item of report.ignoredCommands) {
console.log(`- ${item.key}: ${item.reason}`);
}
}
if (report.missingInTs.length > 0) {
console.log('\nMissing in TS:');
for (const key of report.missingInTs) {
+7 -3
View File
@@ -1,8 +1,10 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { resolveRefRoot } from './resolve-ref-root.mjs';
const root = process.cwd();
const phpDir = path.join(root, 'legacy/hwe/sammo/Command/General');
const refRoot = resolveRefRoot(root);
const phpDir = path.join(refRoot, 'hwe/sammo/Command/General');
const tsDir = path.join(root, 'packages/logic/src/actions/turn/general');
const check = process.argv.includes('--check');
@@ -116,8 +118,10 @@ const dynamicChecks = [
},
{
key: 'post-turn myset',
ok: /myset:\s*Math\.min\([\s\S]*9,[\s\S]*currentGeneral\.meta\.myset[\s\S]*\+\s*3/.test(handlerSource),
contract: '매 턴 +3, 상한 9',
ok: /const incDefSettingChange = readConfigNumber\(options\.scenarioConfig,\s*'incDefSettingChange',\s*3\);[\s\S]*const maxDefSettingChange = readConfigNumber\(options\.scenarioConfig,\s*'maxDefSettingChange',\s*9\);[\s\S]*myset:\s*Math\.min\(\s*maxDefSettingChange,\s*readMetaNumber\(currentGeneral\.meta,\s*'myset',\s*0\)\s*\+\s*incDefSettingChange/.test(
handlerSource
),
contract: '매 턴 scenario 증가량/상한 적용 (기본 +3, 상한 9)',
},
];
@@ -202,12 +202,26 @@ const installFixture = async (
}
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
return response([]);
return response({ turns: [], revision: 0 });
}
if (operation === 'general.getFrontStatus') {
return response({
onlineUserCount: 1,
onlineNations: '테스트국(1)',
onlineGenerals: general.name,
nationNotice: '',
lastExecuted: null,
latestVote: null,
});
}
if (operation === 'general.getRecentRecords') {
return response({ global: [], general: [], history: [] });
}
if (operation === 'messages.getRecent') return response(buildMessages(options.permission));
if (operation === 'messages.getContacts') return response(contacts);
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
if (operation === 'tournament.getState') return response({ stage: 0 });
if (operation === 'public.recordAccess') return response({ recorded: true });
if (
operation === 'messages.send' ||
operation === 'messages.readLatest' ||
@@ -379,6 +393,6 @@ test('redacts diplomacy for a low-permission general and preserves the failed-se
await page.getByLabel('메시지 입력').fill('차단될 메시지');
await page.getByRole('button', { name: '서신전달&갱신' }).click();
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
await expect(page.locator('.error')).toHaveText('공개 메세지를 보낼 수 없습니다.');
await expect(page.getByRole('alert').filter({ hasText: '공개 메세지를 보낼 수 없습니다.' })).toBeVisible();
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
});
@@ -128,7 +128,20 @@ const installFixture = async (
}
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
return response([]);
return response({ turns: [], revision: 0 });
}
if (operation === 'general.getFrontStatus') {
return response({
onlineUserCount: 1,
onlineNations: '수락국(1)',
onlineGenerals: general.name,
nationNotice: '',
lastExecuted: null,
latestVote: null,
});
}
if (operation === 'general.getRecentRecords') {
return response({ global: [], general: [], history: [] });
}
if (operation === 'messages.getRecent') {
return response(messageBundle(visible, options.canRespondDiplomacy));
@@ -136,6 +149,7 @@ const installFixture = async (
if (operation === 'messages.getContacts') return response({ nation: [] });
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
if (operation === 'tournament.getState') return response({ stage: 0 });
if (operation === 'public.recordAccess') return response({ recorded: true });
if (operation === 'messages.respond') {
mutations.push({ operation, body: requestBody });
if (options.acceptResponse) {
@@ -256,7 +270,9 @@ test.describe('instant diplomacy response UI', () => {
await dialog.accept();
});
await responseRow.getByRole('button', { name: '거절' }).click();
await expect(page.locator('.error')).toHaveText('현재 외교 상태에서는 수락할 수 없습니다.');
await expect(
page.getByRole('alert').filter({ hasText: '현재 외교 상태에서는 수락할 수 없습니다.' })
).toBeVisible();
await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
expect(mutations).toHaveLength(1);
expect(JSON.stringify(mutations[0]!.body)).toContain('"response":false');
@@ -76,10 +76,15 @@ export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath:
if (resolvedFixture !== fixtureRoot && !resolvedFixture.startsWith(`${fixtureRoot}${path.sep}`)) {
throw new Error(`Reference turn fixture must be under ${fixtureRoot}`);
}
const stdout = execFileSync('./scripts/run-turn-differential-case.sh', [resolvedFixture], {
const runner = process.env.TURN_DIFFERENTIAL_CASE_SCRIPT ?? './scripts/run-turn-differential-case.sh';
const stdout = execFileSync(runner, [resolvedFixture], {
cwd: stackDirectory,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
},
});
return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace);
};
@@ -89,7 +94,7 @@ export const runReferenceTurnCommandTraceRequest = (
request: Record<string, unknown>
): CanonicalTurnCommandTrace => {
const stackDirectory = path.join(workspaceRoot, 'docker_compose_files/reference');
const runner = process.env.TURN_DIFFERENTIAL_RUNNER_SCRIPT ?? './scripts/run-turn-differential-case.sh';
const runner = process.env.TURN_DIFFERENTIAL_CASE_SCRIPT ?? './scripts/run-turn-differential-case.sh';
const stdout = execFileSync(runner, ['-'], {
cwd: stackDirectory,
input: JSON.stringify(request),
@@ -276,6 +276,12 @@ describe('auction integration flow', () => {
apiPort: Number(process.env.GAME_API_PORT ?? 14000),
status: 'RUNNING',
});
await gatewayClient.admin.profiles.updateMeta.mutate({
profileName: 'che:908',
patch: {
localAccountGeneralCreationGraceDays: 7,
},
});
await gatewayClient.admin.profiles.installNow.mutate({
profileName: 'che:908',
@@ -200,7 +200,8 @@ const assertTraceParity = (
}
};
const workspaceRoot = findWorkspaceRoot(process.cwd());
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
const workspaceRoot = configuredWorkspaceRoot ?? findWorkspaceRoot(process.cwd());
const describeWithReference = workspaceRoot ? describe : describe.skip;
describeWithReference('ref ↔ core2026 battle differential', () => {
@@ -240,6 +240,12 @@ describe('integration initialization flow', () => {
apiPort: Number(process.env.GAME_API_PORT ?? 14000),
status: 'RUNNING',
});
await gatewayClient.admin.profiles.updateMeta.mutate({
profileName: 'che:2',
patch: {
localAccountGeneralCreationGraceDays: 7,
},
});
await gatewayClient.admin.profiles.installNow.mutate({
profileName: 'che:2',
@@ -358,7 +364,7 @@ describe('integration initialization flow', () => {
action: 'che_건국',
args: {
nationName: `TestNation${idx + 1}`,
nationType: 'che_def',
nationType: 'che_명가',
colorType: idx,
},
expectedRevision: queueRevision,
@@ -462,10 +468,9 @@ describe('integration initialization flow', () => {
const nation = nationRows.find((row) => row.id === nationId);
expect(nation).toBeTruthy();
const count = generalCountMap.get(nationId) ?? 0;
if (count >= 2) {
if (nation!.capitalCityId !== null) {
expect(cityNationMap.get(nation!.capitalCityId)).toBe(nationId);
}
expect(count).toBeGreaterThanOrEqual(1);
if ((nation!.capitalCityId ?? 0) > 0) {
expect(cityNationMap.get(nation!.capitalCityId!)).toBe(nationId);
} else {
expect(nation!.level).toBe(0);
}
@@ -99,7 +99,7 @@ integration('legacy instant diplomacy responses', () => {
expect(addedLogs(trace).map((log) => [log.generalId, log.category])).toEqual([
[1, 'history'],
[1, 'action'],
[0, 'history'],
[0, 'summary'],
[2, 'history'],
[2, 'action'],
]);
@@ -130,7 +130,7 @@ integration('legacy instant diplomacy responses', () => {
expect(addedLogs(trace).map((log) => [log.generalId, log.category])).toEqual([
[1, 'history'],
[1, 'action'],
[0, 'history'],
[0, 'summary'],
[2, 'history'],
[2, 'action'],
]);
@@ -2,6 +2,7 @@ import path from 'node:path';
import fs from 'node:fs/promises';
import http from 'node:http';
import https from 'node:https';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
@@ -173,8 +174,19 @@ const createGameClient = (baseUrl: string, trpcPath: string, accessTokenRef: { v
],
});
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string =>
`sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`;
const buildProcessName = (
profileName: string,
role: 'api' | 'daemon' | 'auction' | 'battle' | 'tournament'
): string => {
const roleName = {
api: 'game-api',
daemon: 'turn-daemon',
auction: 'auction-worker',
battle: 'battle-sim-worker',
tournament: 'tournament-worker',
}[role];
return `sammo:${profileName}:${roleName}`;
};
const waitForPm2Online = async (manager: Pm2ProcessManager, names: string[], timeoutMs = 30_000) => {
const deadline = Date.now() + timeoutMs;
@@ -204,6 +216,55 @@ const cleanupPm2 = async (manager: Pm2ProcessManager, names: string[]) => {
}
};
const processIsAlive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
const stopDedicatedPm2Daemon = async (pm2Home: string): Promise<void> => {
let daemonPid: number | null = null;
try {
const rawPid = await fs.readFile(path.join(pm2Home, 'pm2.pid'), 'utf8');
const parsedPid = Number(rawPid.trim());
if (Number.isSafeInteger(parsedPid) && parsedPid > 1) {
daemonPid = parsedPid;
}
} catch {
// PM2 might already have removed its pid file.
}
await execCommand('pnpm', ['exec', 'pm2', 'kill'], {
...process.env,
PM2_HOME: pm2Home,
}).catch(() => undefined);
if (daemonPid === null || !processIsAlive(daemonPid)) {
return;
}
for (let attempt = 0; attempt < 50 && processIsAlive(daemonPid); attempt += 1) {
await sleep(100);
}
if (!processIsAlive(daemonPid)) {
return;
}
const commandLine = (await fs.readFile(`/proc/${daemonPid}/cmdline`, 'utf8')).replaceAll('\0', ' ');
if (!commandLine.includes('PM2') || !commandLine.includes(pm2Home)) {
throw new Error(`Refusing to stop PID ${daemonPid}: it is not the dedicated PM2 daemon for ${pm2Home}`);
}
process.kill(daemonPid, 'SIGTERM');
for (let attempt = 0; attempt < 50 && processIsAlive(daemonPid); attempt += 1) {
await sleep(100);
}
if (processIsAlive(daemonPid)) {
throw new Error(`Dedicated PM2 daemon did not stop: PID ${daemonPid}, PM2_HOME ${pm2Home}`);
}
};
const waitForTurnDaemonStatus = async (gameClient: ReturnType<typeof createGameClient>, timeoutMs = 90_000) => {
const deadline = Date.now() + timeoutMs;
let lastError: string | null = null;
@@ -399,11 +460,16 @@ describe('pm2 orchestrator e2e', () => {
let profileName = 'che:2';
let apiPort = 14000;
let processNames: string[] = [];
let pm2Home: string | null = null;
let previousPm2Home: string | undefined;
beforeAll(async () => {
await loadEnv();
process.chdir(workspaceRoot);
await ensureBuildArtifacts();
previousPm2Home = process.env.PM2_HOME;
pm2Home = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-pm2-e2e-'));
process.env.PM2_HOME = pm2Home;
profile = process.env.PROFILE ?? 'che';
scenario = process.env.SCENARIO ?? '2';
@@ -416,6 +482,8 @@ describe('pm2 orchestrator e2e', () => {
processNames = [
buildProcessName(profileName, 'api'),
buildProcessName(profileName, 'daemon'),
buildProcessName(profileName, 'auction'),
buildProcessName(profileName, 'battle'),
buildProcessName(profileName, 'tournament'),
];
@@ -433,11 +501,23 @@ describe('pm2 orchestrator e2e', () => {
}, 60_000);
afterAll(async () => {
if (pm2Manager) {
await cleanupPm2(pm2Manager, processNames);
}
if (gatewayServer) {
await gatewayServer.app.close();
try {
if (pm2Manager) {
await cleanupPm2(pm2Manager, processNames);
}
if (gatewayServer) {
await gatewayServer.app.close();
}
if (pm2Home) {
await stopDedicatedPm2Daemon(pm2Home);
await fs.rm(pm2Home, { recursive: true, force: true });
}
} finally {
if (previousPm2Home === undefined) {
delete process.env.PM2_HOME;
} else {
process.env.PM2_HOME = previousPm2Home;
}
}
}, 30_000);
@@ -456,7 +536,7 @@ describe('pm2 orchestrator e2e', () => {
const adminGameAccessRef: { value?: string } = {};
const accessTokenRef: { value?: string } = {};
const gatewayClient = createGatewayClient(gatewayUrl, gatewayServer.config.trpcPath, adminSessionRef);
const gameTrpcPath = process.env.GAME_TRPC_PATH ?? process.env.TRPC_PATH ?? '/trpc';
const gameTrpcPath = `/${profile}/api/trpc`;
const gameClientPublic = createGameClient(gameUrl, gameTrpcPath, { value: undefined });
const gameClientAdmin = createGameClient(gameUrl, gameTrpcPath, adminGameAccessRef);
const gameClientAuthed = createGameClient(gameUrl, gameTrpcPath, accessTokenRef);
@@ -482,6 +562,12 @@ describe('pm2 orchestrator e2e', () => {
apiPort,
status: 'RUNNING',
});
await gatewayClient.admin.profiles.updateMeta.mutate({
profileName,
patch: {
localAccountGeneralCreationGraceDays: 7,
},
});
await gatewayClient.admin.profiles.installNow.mutate({
profileName,
@@ -556,7 +642,7 @@ describe('pm2 orchestrator e2e', () => {
});
expect(createdGeneral.generalId).toBeGreaterThan(0);
const eventsPath = process.env.GAME_API_EVENTS_PATH ?? '/events';
const eventsPath = `/${profile}/api/events`;
const runStartMs = Date.now();
const ssePromise = waitForSseEvent({
url: `${gameUrl}${eventsPath}`,
@@ -204,6 +204,12 @@ describe('actual tournament lifecycle', () => {
apiPort: Number(process.env.GAME_API_PORT ?? 14000),
status: 'RUNNING',
});
await gatewayClient.admin.profiles.updateMeta.mutate({
profileName: 'che:908',
patch: {
localAccountGeneralCreationGraceDays: 7,
},
});
await gatewayClient.admin.profiles.installNow.mutate({
profileName: 'che:908',
install: {
@@ -26,7 +26,8 @@ integration('legacy command trace runner', () => {
expect.objectContaining({ fromNationId: 1, toNationId: 2, state: 1, term: 24 }),
expect.objectContaining({ fromNationId: 2, toNationId: 1, state: 1, term: 24 }),
]);
expect(trace.after.messages).toHaveLength(2);
const addedMessages = trace.after.messages.slice(trace.before.messages.length);
expect(addedMessages).toHaveLength(2);
});
it('runs live sortie through conquest and nation collapse', () => {
@@ -5,7 +5,8 @@ import {
readReferenceDatabaseSnapshot,
} from '../src/turn-differential/referenceSnapshot.js';
const workspaceRoot = findTurnDifferentialWorkspaceRoot(process.cwd());
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');
integration('legacy turn state snapshot adapter', () => {
+23
View File
@@ -0,0 +1,23 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
export const resolveRefRoot = (repositoryRoot = process.cwd()) => {
if (process.env.SAMMO_REF_ROOT) {
return path.resolve(process.env.SAMMO_REF_ROOT);
}
const candidates = [path.resolve(repositoryRoot, '..', 'ref', 'sam')];
try {
const commonDir = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
cwd: repositoryRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
candidates.push(path.resolve(path.dirname(commonDir), '..', 'ref', 'sam'));
} catch {
// The direct sibling remains the fallback outside a Git checkout.
}
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
};