test: harden exhaustive compatibility suites
This commit is contained in:
@@ -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),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
Reference in New Issue
Block a user