Add general command failure parity matrix

This commit is contained in:
2026-07-26 03:31:39 +00:00
parent 675ec44990
commit 2e46fb6a70
5 changed files with 131 additions and 14 deletions
@@ -2,14 +2,18 @@
## 상태
- 문서 상태: 구현 전 승인 가능한 설계
- 문서 상태: 단계적 구현 중
- 비교 기준: `ref/sam``ng_compare` 브랜치
- 대상: 휴식과 `cr_건국`을 포함한 일반 장수 예약 명령 55개
- 범위: 명령 결과, RNG 소비, 로그, 예약 턴 lifecycle, DB 영속화
이 문서는 테스트 구현 자체가 아니다. 아래의 파일, 실행기, 격리 스택과
fixture가 구현되고 완료 기준을 통과하기 전까지 55개 명령의 동적 호환 상태를
`확인`으로 올리지 않는다.
현재 ref MariaDB와 core memory를 잇는 공통 runner, 성공 경로 55개,
실행 중 확률 실패 9개가 구현됐다. 실패 9개는 내정 critical
`주민선정/정착장려/상업투자/기술연구/물자조달`과 모략
`화계/선동/파괴/탈취`이며 RNG 전체 trace, semantic state delta와 실패
로그 본문을 비교한다. 나머지 제약 실패·값 경계·alternative와 전체
core PostgreSQL 재조회가 완료 기준을 통과하기 전까지 55개 명령 전체의
동적 호환 상태를 `확인`으로 올리지 않는다.
## 결정 요약
@@ -169,13 +169,18 @@ Compatibility is established per case only when:
4. live sortie also passes the battle trace comparison;
5. any ignored path is documented in the case evidence.
As of 2026-07-26, 54 general matrix cases, 35 nation matrix cases, declaration
and live sortie pass this boundary. Live sortie is the remaining general
command key and covers battle entry, conquest, defeated-general neutralization
and last-city nation collapse. Thus all 55 general command keys have a
completed success-path comparison. This is 91 executable comparison cases;
it is not yet a claim that failure/boundary paths or all 38 nation commands
have been dynamically compared.
As of 2026-07-26, 54 general matrix cases, 35 reserved nation matrix cases,
declaration and live sortie pass this boundary. Live sortie is the remaining
general command key and covers battle entry, conquest, defeated-general
neutralization and last-city nation collapse. Thus all 55 general command keys
have a completed success-path comparison. The three instant diplomatic nation
responses pass a separate reference trace and core resolver/API boundary.
Nine general in-action failure cases now also pass the common differential:
five domestic critical failures and four sabotage failures. They compare the
full command RNG trace, semantic state delta and the exact action-log body.
This is not yet a claim that the remaining constraint, clamp, alternative and
persistence boundaries have been dynamically compared.
The fixture runner also reports whether the requested legacy command reached
its completed execution path. For multi-turn commands this is derived from the
@@ -100,8 +100,9 @@ export class ActionResolver<
const ded = (score * 1.0) / 3;
// 7. Update General
const nextExp = general.experience + Math.trunc(exp);
const nextDed = general.dedication + Math.trunc(ded);
// 레거시는 부동소수점 증가분을 INT column에 저장할 때 반올림한다.
const nextExp = general.experience + Math.round(exp);
const nextDed = general.dedication + Math.round(ded);
let appliedScore = score;
if (context.city && [1, 3].includes(context.city.frontState)) {
@@ -46,6 +46,10 @@ const readTrust = (city: City): number => {
return typeof trust === 'number' && Number.isFinite(trust) ? trust : DEFAULT_TRUST;
};
// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로
// 재조회된다. 같은 턴의 후속 명령도 그 저장 경계를 보도록 정규화한다.
const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6));
const remainCityTrust = (): Constraint => ({
name: 'remainCityTrust',
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
@@ -106,7 +110,7 @@ export class ActionDefinition<
const trustDelta = result.score / 10;
context.city.meta = {
...context.city.meta,
trust: clamp(readTrust(context.city) + trustDelta, 0, 100),
trust: toLegacyStoredTrust(clamp(readTrust(context.city) + trustDelta, 0, 100)),
};
context.general.rice = Math.max(0, context.general.rice - result.costGold);
context.general.experience += result.exp;
@@ -381,3 +381,106 @@ integration('general command success matrix', () => {
120_000
);
});
type GeneralFailureCase = {
action: string;
args?: Record<string, unknown>;
hiddenSeed: string;
failureText: string;
};
const failureCases: GeneralFailureCase[] = [
{
action: 'che_주민선정',
hiddenSeed: 'general-failure-che_주민선정-0',
failureText: "주민 선정을 <span class='ev_failed'>실패</span>",
},
{
action: 'che_정착장려',
hiddenSeed: 'general-failure-che_정착장려-0',
failureText: "정착 장려를 <span class='ev_failed'>실패</span>",
},
{
action: 'che_상업투자',
hiddenSeed: 'general-failure-che_상업투자-2',
failureText: "상업 투자를 <span class='ev_failed'>실패</span>",
},
{
action: 'che_기술연구',
hiddenSeed: 'general-failure-che_기술연구-0',
failureText: "기술 연구를 <span class='ev_failed'>실패</span>",
},
{
action: 'che_물자조달',
hiddenSeed: 'general-failure-che_물자조달-1',
failureText: "조달을 <span class='ev_failed'>실패</span>",
},
{
action: 'che_화계',
args: { destCityID: 70 },
hiddenSeed: 'general-failure-che_화계-0',
failureText: '화계가 실패했습니다.',
},
{
action: 'che_선동',
args: { destCityID: 70 },
hiddenSeed: 'general-failure-che_선동-0',
failureText: '선동이 실패했습니다.',
},
{
action: 'che_파괴',
args: { destCityID: 70 },
hiddenSeed: 'general-failure-che_파괴-0',
failureText: '파괴가 실패했습니다.',
},
{
action: 'che_탈취',
args: { destCityID: 70 },
hiddenSeed: 'general-failure-che_탈취-0',
failureText: '탈취가 실패했습니다.',
},
];
const failureLogTexts = (logs: Array<Record<string, unknown>>, failureText: string): string[] =>
logs
.map((entry) => entry.text)
.filter((text): text is string => typeof text === 'string' && text.includes(failureText));
const legacyActionLogBody = (text: string): string => {
const match = /^<C>●<\/>\d+월:(.*) <1>\d{2}:\d{2}<\/>$/.exec(text);
return match?.[1] ?? text;
};
integration('general command in-action failure matrix', () => {
it.each(failureCases)(
'$action matches legacy failure RNG, side effects, and failure log',
async ({ action, args, hiddenSeed, failureText }) => {
const request = buildRequest(action, args);
request.setup!.world!.hiddenSeed = hiddenSeed;
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: action,
usedFallback: false,
});
expect(core.execution.outcome).not.toHaveProperty('blockedReason');
expect(failureLogTexts(reference.after.logs, failureText)).toHaveLength(1);
expect(failureLogTexts(core.after.logs, failureText)).toEqual(
failureLogTexts(reference.after.logs, failureText).map(legacyActionLogBody)
);
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});