diff --git a/app/game-api/test/auctionRouter.test.ts b/app/game-api/test/auctionRouter.test.ts index fb915be..cf570d6 100644 --- a/app/game-api/test/auctionRouter.test.ts +++ b/app/game-api/test/auctionRouter.test.ts @@ -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), }, ]; } diff --git a/app/game-frontend/e2e/auction.spec.ts b/app/game-frontend/e2e/auction.spec.ts index 7267ece..ecc4b2a 100644 --- a/app/game-frontend/e2e/auction.spec.ts +++ b/app/game-frontend/e2e/auction.spec.ts @@ -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; diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts index bf0760f..b9f27ec 100644 --- a/app/game-frontend/e2e/battleSimulator.spec.ts +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -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 } }); diff --git a/app/game-frontend/e2e/board.spec.ts b/app/game-frontend/e2e/board.spec.ts index eeea3a6..e853fa1 100644 --- a/app/game-frontend/e2e/board.spec.ts +++ b/app/game-frontend/e2e/board.spec.ts @@ -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 = { diff --git a/app/game-frontend/e2e/commandArgumentsLive.spec.ts b/app/game-frontend/e2e/commandArgumentsLive.spec.ts index bb1bf1d..3923074 100644 --- a/app/game-frontend/e2e/commandArgumentsLive.spec.ts +++ b/app/game-frontend/e2e/commandArgumentsLive.spec.ts @@ -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 { diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index 7307f12..546f1c9 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -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 => { for (const root of imageRoots) { try { diff --git a/app/game-frontend/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts index e627d63..c8dee62 100644 --- a/app/game-frontend/e2e/npcPolicy.spec.ts +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -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 => { for (const root of imageRoots) { try { diff --git a/app/game-frontend/e2e/troop.spec.ts b/app/game-frontend/e2e/troop.spec.ts index 55cd0fe..f3bf016 100644 --- a/app/game-frontend/e2e/troop.spec.ts +++ b/app/game-frontend/e2e/troop.spec.ts @@ -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 => { for (const imageRoot of imageRoots) { diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index 7155947..73002d0 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -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: diff --git a/docs/command-log-checklist.md b/docs/command-log-checklist.md index f3e85e8..2f245b9 100644 --- a/docs/command-log-checklist.md +++ b/docs/command-log-checklist.md @@ -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 diff --git a/docs/integration-tests.md b/docs/integration-tests.md index 951ac22..37cc989 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -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 diff --git a/tools/compare-command-constraints.compat.json b/tools/compare-command-constraints.compat.json index a5525a6..b7540d9 100644 --- a/tools/compare-command-constraints.compat.json +++ b/tools/compare-command-constraints.compat.json @@ -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" } ] } diff --git a/tools/compare-command-constraints.mjs b/tools/compare-command-constraints.mjs index 40dba84..eb77512 100644 --- a/tools/compare-command-constraints.mjs +++ b/tools/compare-command-constraints.mjs @@ -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}`); diff --git a/tools/compare-command-logs.ignore.json b/tools/compare-command-logs.ignore.json index b6807d7..3390f81 100644 --- a/tools/compare-command-logs.ignore.json +++ b/tools/compare-command-logs.ignore.json @@ -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": [ "${}에게서 몰수할 ${}이 없습니다.", "이동할 인구가 부족합니다.", "이미 같은 국호를 가진 곳이 있습니다. ${} 실패", + "이미 같은 국호를 가진 곳이 있습니다. 국호변경 실패", "${}에게 등용 권유 서신을 보내지 못했습니다. ${}", "${}에게 부대 탈퇴를 지시 받았습니다.", "${}에 의해 ${}${} 발령됐습니다.", @@ -26,6 +32,13 @@ "templates": ["${}"], "regex": [] }, + "General/che_인재탐색": { + "templates": [ + "${}${}는 인재를 ${}하였습니다!", + "${}${}는 인재를 발견하였습니다!" + ], + "regex": [] + }, "General/che_기술연구": { "templates": [ "${}${} 하여 ${} 상승했습니다.", @@ -74,5 +87,13 @@ "General/che_파괴": { "templates": ["${}에 파괴${} 실패했습니다."], "regex": [] + }, + "Nation/che_발령": { + "templates": ["본인입니다 ${} 발령 실패."], + "regex": [] + }, + "Nation/che_부대탈퇴지시": { + "templates": ["본인입니다 부대 탈퇴 지시 실패."], + "regex": [] } } diff --git a/tools/compare-command-logs.mjs b/tools/compare-command-logs.mjs index 4d77387..77a135c 100644 --- a/tools/compare-command-logs.mjs +++ b/tools/compare-command-logs.mjs @@ -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) { diff --git a/tools/compare-general-turn-contracts.mjs b/tools/compare-general-turn-contracts.mjs index 8027698..1fb0417 100644 --- a/tools/compare-general-turn-contracts.mjs +++ b/tools/compare-general-turn-contracts.mjs @@ -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)', }, ]; diff --git a/tools/frontend-legacy-parity/ingame-message-parity.spec.ts b/tools/frontend-legacy-parity/ingame-message-parity.spec.ts index 726494a..f30eaf9 100644 --- a/tools/frontend-legacy-parity/ingame-message-parity.spec.ts +++ b/tools/frontend-legacy-parity/ingame-message-parity.spec.ts @@ -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); }); diff --git a/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts b/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts index d4b5558..7297d00 100644 --- a/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts +++ b/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts @@ -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'); diff --git a/tools/integration-tests/src/turn-differential/referenceSnapshot.ts b/tools/integration-tests/src/turn-differential/referenceSnapshot.ts index 5087bbc..0fa1c9f 100644 --- a/tools/integration-tests/src/turn-differential/referenceSnapshot.ts +++ b/tools/integration-tests/src/turn-differential/referenceSnapshot.ts @@ -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 ): 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), diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index 8f5d8a2..e38a92b 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -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', diff --git a/tools/integration-tests/test/battleDifferential.test.ts b/tools/integration-tests/test/battleDifferential.test.ts index c08c3b8..ef27600 100644 --- a/tools/integration-tests/test/battleDifferential.test.ts +++ b/tools/integration-tests/test/battleDifferential.test.ts @@ -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', () => { diff --git a/tools/integration-tests/test/initialization.test.ts b/tools/integration-tests/test/initialization.test.ts index a1e36fc..d9621fb 100644 --- a/tools/integration-tests/test/initialization.test.ts +++ b/tools/integration-tests/test/initialization.test.ts @@ -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); } diff --git a/tools/integration-tests/test/instantDiplomacyReference.integration.test.ts b/tools/integration-tests/test/instantDiplomacyReference.integration.test.ts index 91e5c47..e47842f 100644 --- a/tools/integration-tests/test/instantDiplomacyReference.integration.test.ts +++ b/tools/integration-tests/test/instantDiplomacyReference.integration.test.ts @@ -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'], ]); diff --git a/tools/integration-tests/test/orchestrator.e2e.test.ts b/tools/integration-tests/test/orchestrator.e2e.test.ts index 0c18411..b3017c6 100644 --- a/tools/integration-tests/test/orchestrator.e2e.test.ts +++ b/tools/integration-tests/test/orchestrator.e2e.test.ts @@ -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 => { + 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, 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}`, diff --git a/tools/integration-tests/test/tournamentLifecycle.test.ts b/tools/integration-tests/test/tournamentLifecycle.test.ts index 9b61827..9834cb6 100644 --- a/tools/integration-tests/test/tournamentLifecycle.test.ts +++ b/tools/integration-tests/test/tournamentLifecycle.test.ts @@ -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: { diff --git a/tools/integration-tests/test/turnCommandReference.integration.test.ts b/tools/integration-tests/test/turnCommandReference.integration.test.ts index 195ea9e..e2522fc 100644 --- a/tools/integration-tests/test/turnCommandReference.integration.test.ts +++ b/tools/integration-tests/test/turnCommandReference.integration.test.ts @@ -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', () => { diff --git a/tools/integration-tests/test/turnSnapshotReference.integration.test.ts b/tools/integration-tests/test/turnSnapshotReference.integration.test.ts index 50381d0..ccd7d2f 100644 --- a/tools/integration-tests/test/turnSnapshotReference.integration.test.ts +++ b/tools/integration-tests/test/turnSnapshotReference.integration.test.ts @@ -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', () => { diff --git a/tools/resolve-ref-root.mjs b/tools/resolve-ref-root.mjs new file mode 100644 index 0000000..1dc9803 --- /dev/null +++ b/tools/resolve-ref-root.mjs @@ -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]; +};