merge: complete general turn compatibility

This commit is contained in:
2026-07-25 08:16:27 +00:00
68 changed files with 2466 additions and 970 deletions
+50 -7
View File
@@ -15,21 +15,64 @@
"regex": []
},
"General/che_모반시도": {
"templates": [
"<Y>${}</>에게 군주의 자리를 뺏겼습니다."
],
"templates": ["<Y>${}</>에게 군주의 자리를 뺏겼습니다."],
"regex": []
},
"General/che_선양": {
"templates": [
"<Y>${}</>에게서 군주의 자리를 물려받습니다."
],
"templates": ["<Y>${}</>에게서 군주의 자리를 물려받습니다."],
"regex": []
},
"General/che_장비매매": {
"templates": ["${}"],
"regex": []
},
"General/che_기술연구": {
"templates": [
"${}"
"${}${} 하여 <C>${}</> 상승했습니다.",
"기술 연구${} <span class='ev_failed'>실패</span>하여 <C>${}</> 상승했습니다.",
"기술 연구${} <S>성공</>하여 <C>${}</> 상승했습니다.",
"기술 연구${} 하여 <C>${}</> 상승했습니다."
],
"regex": []
},
"General/che_내정특기초기화": {
"templates": ["새로운 ${}를 가질 준비가 되었습니다.", "새로운 내정 특기를 가질 준비가 되었습니다."],
"regex": []
},
"General/che_선동": {
"templates": ["<G>${}</>에 선동${} 실패했습니다."],
"regex": []
},
"General/che_은퇴": {
"templates": ["나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다."],
"regex": []
},
"General/che_정착장려": {
"templates": [
"${}${} <span class='ev_failed'>실패</span>하여 주민이 <C>${}</>명 증가했습니다.",
"${}${} <S>성공</>하여 주민이 <C>${}</>명 증가했습니다.",
"${}${} 하여 주민이 <C>${}</>명 증가했습니다.",
"정착 장려${} <span class='ev_failed'>실패</span>하여 주민이 <C>${}</>명 증가했습니다.",
"정착 장려${} <S>성공</>하여 주민이 <C>${}</>명 증가했습니다.",
"정착 장려${} 하여 주민이 <C>${}</>명 증가했습니다."
],
"regex": []
},
"General/che_주민선정": {
"templates": [
"${}${} 하여 <C>${}</> 상승했습니다.",
"주민 선정${} <span class='ev_failed'>실패</span>하여 <C>${}</> 상승했습니다.",
"주민 선정${} <S>성공</>하여 <C>${}</> 상승했습니다.",
"주민 선정${} 하여 <C>${}</> 상승했습니다."
],
"regex": []
},
"General/che_탈취": {
"templates": ["<G>${}</>에 탈취${} 실패했습니다."],
"regex": []
},
"General/che_파괴": {
"templates": ["<G>${}</>에 파괴${} 실패했습니다."],
"regex": []
}
}
+39
View File
@@ -10,6 +10,7 @@ const DEFAULT_MODE = 'action';
const DEFAULT_EXCLUDE_GUARDS = true;
const DEFAULT_EXCLUDE_TARGET = true;
const DEFAULT_IGNORE_FILE = 'tools/compare-command-logs.ignore.json';
const PHP_INHERITED_LOG_SOURCE_WHEN_EMPTY = new Map([['General/che_내정특기초기화', 'General/che_전투특기초기화']]);
const ARG_HELP = `
Usage: node tools/compare-command-logs.mjs [options]
@@ -894,6 +895,7 @@ const diffTemplateCounts = (lhsCounts, rhsCounts) => {
const loadPhpLogs = async () => {
const files = (await collectFiles(PHP_ROOT)).filter((file) => file.endsWith('.php'));
const logsByKey = new Map();
const sourceByKey = new Map();
for (const file of files) {
const baseName = path.basename(file, '.php');
@@ -909,6 +911,7 @@ const loadPhpLogs = async () => {
const text = await fs.readFile(file, 'utf-8');
const assignments = findPhpAssignments(text);
const logs = extractPhpLogCalls(text, assignments);
sourceByKey.set(key, { file, logs });
if (logs.length === 0) {
continue;
}
@@ -940,6 +943,42 @@ const loadPhpLogs = async () => {
logsByKey.set(key, filtered);
}
for (const [key, parentKey] of PHP_INHERITED_LOG_SOURCE_WHEN_EMPTY) {
if (!filterCommandKey(key) || logsByKey.has(key)) {
continue;
}
const parent = sourceByKey.get(parentKey);
if (!parent) {
continue;
}
const inherited = parent.logs
.map((log) => ({
file: path.relative(ROOT_DIR, parent.file),
line: log.line,
template: log.template,
raw: log.raw,
category: log.category,
scope: log.scope,
format: log.format,
hasGeneralId: log.hasGeneralId,
}))
.filter((entry) => {
if (!shouldIncludeEntryByMode(entry)) {
return false;
}
if (excludeGuards && isGuardLog(entry.template)) {
return false;
}
if (excludeTarget && isTargetLog(entry)) {
return false;
}
return true;
});
if (inherited.length > 0) {
logsByKey.set(key, inherited);
}
}
return logsByKey;
};
+141
View File
@@ -0,0 +1,141 @@
import fs from 'node:fs/promises';
import path from 'node:path';
const root = process.cwd();
const phpDir = path.join(root, 'legacy/hwe/sammo/Command/General');
const tsDir = path.join(root, 'packages/logic/src/actions/turn/general');
const check = process.argv.includes('--check');
const activeAction = new Map([
['che_거병', 1],
['che_건국', 1],
['che_등용수락', 1],
['che_랜덤임관', 1],
['che_모반시도', 1],
['che_무작위건국', 1],
['che_방랑', 1],
['che_임관', 1],
['che_장수대상임관', 1],
['che_선양', 1],
['che_출병', 1],
['che_첩보', 0.5],
['che_하야', 1],
['cr_건국', 1],
]);
const readSources = async (dir, extension) => {
const result = new Map();
for (const name of await fs.readdir(dir)) {
if (!name.endsWith(extension)) continue;
const key = name.slice(0, -extension.length);
if (!(key.startsWith('che_') || key.startsWith('cr_') || key === '휴식')) continue;
result.set(key, await fs.readFile(path.join(dir, name), 'utf8'));
}
return result;
};
const php = await readSources(phpDir, '.php');
const ts = await readSources(tsDir, '.ts');
const handlerSource = await fs.readFile(path.join(root, 'app/game-engine/src/turn/reservedTurnHandler.ts'), 'utf8');
const phpParent = new Map();
for (const [key, source] of php) {
const classMatch = source.match(/\bclass\s+\S+\s+extends\s+(?:Command\\GeneralCommand|([^\s{]+))/);
const rawParent = classMatch?.[1];
if (rawParent) phpParent.set(key, rawParent.split('\\').at(-1));
}
const literalMethod = (source, method) => {
const match = source.match(
new RegExp(`function\\s+${method}\\s*\\([^)]*\\)\\s*:[^{]+\\{[\\s\\S]*?return\\s+(-?\\d+(?:\\.\\d+)?)\\s*;`)
);
return match ? Number(match[1]) : null;
};
const resolvePhpMethod = (key, method, seen = new Set()) => {
if (seen.has(key)) return null;
seen.add(key);
const source = php.get(key);
if (!source) return null;
const own = literalMethod(source, method);
if (own !== null) return own;
const parent = phpParent.get(key);
return parent ? resolvePhpMethod(parent, method, seen) : 0;
};
const tsMethod = (source, method) => {
const match = source.match(
new RegExp(`${method}\\s*\\([^)]*\\)\\s*:\\s*number\\s*\\{[\\s\\S]*?return\\s+(-?\\d+(?:\\.\\d+)?)\\s*;`)
);
return match ? Number(match[1]) : 0;
};
const missingTs = [...php.keys()].filter((key) => !ts.has(key)).sort();
const extraTs = [...ts.keys()].filter((key) => !php.has(key)).sort();
const timingMismatches = [];
const activeMismatches = [];
for (const key of [...php.keys()].filter((value) => ts.has(value)).sort()) {
const source = ts.get(key);
const phpPre = resolvePhpMethod(key, 'getPreReqTurn');
const phpPost = resolvePhpMethod(key, 'getPostReqTurn');
const tsPre = tsMethod(source, 'getPreReqTurn');
const tsPost = tsMethod(source, 'getPostReqTurn');
if (phpPre !== tsPre || phpPost !== tsPost) {
timingMismatches.push({ key, php: [phpPre, phpPost], ts: [tsPre, tsPost] });
}
const expectedActive = activeAction.get(key) ?? 0;
const actualActive = tsMethod(source, 'getInheritanceActiveActionAmount');
if (expectedActive !== actualActive) {
activeMismatches.push({ key, php: expectedActive, ts: actualActive });
}
}
const dynamicChecks = [
{
key: 'che_인재탐색',
ok: /inherit_active_action[\s\S]*Math\.max\(Math\.sqrt\(1\s*\/\s*prop\),\s*1\)/.test(
ts.get('che_인재탐색') ?? ''
),
contract: '성공 시 max(sqrt(1 / 발견확률), 1)',
},
{
key: 'generalCommand RNG seed',
ok: /kind === 'general' \? 'generalCommand' : 'nationCommand'[\s\S]*currentYear[\s\S]*currentMonth[\s\S]*currentGeneral\.id,[\s\S]*key/.test(
handlerSource
),
contract: 'hiddenSeed, generalCommand, year, month, generalId, raw command key',
},
{
key: 'preprocess RNG seed',
ok: /buildSeedBase\(context\.world\),[\s\S]*'preprocess',[\s\S]*currentYear,[\s\S]*currentMonth,[\s\S]*currentGeneral\.id/.test(
handlerSource
),
contract: 'hiddenSeed, preprocess, year, month, generalId',
},
{
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',
},
];
console.log(`General command inventory: PHP ${php.size}, TS ${ts.size}`);
console.log(`Missing TS: ${missingTs.length}; Extra TS: ${extraTs.length}`);
console.log(`Timing mismatches: ${timingMismatches.length}`);
console.log(`Active-action mismatches: ${activeMismatches.length}`);
for (const mismatch of timingMismatches) console.log('timing', JSON.stringify(mismatch));
for (const mismatch of activeMismatches) console.log('active', JSON.stringify(mismatch));
for (const item of dynamicChecks) console.log(`dynamic ${item.key}: ${item.ok ? 'ok' : 'missing'} (${item.contract})`);
if (
check &&
(missingTs.length > 0 ||
extraTs.length > 0 ||
timingMismatches.length > 0 ||
activeMismatches.length > 0 ||
dynamicChecks.some((item) => !item.ok))
) {
process.exitCode = 1;
}