merge: add general command failure parity

This commit is contained in:
2026-07-26 03:31:50 +00:00
5 changed files with 131 additions and 14 deletions
@@ -2,14 +2,18 @@
## 상태 ## 상태
- 문서 상태: 구현 전 승인 가능한 설계 - 문서 상태: 단계적 구현 중
- 비교 기준: `ref/sam``ng_compare` 브랜치 - 비교 기준: `ref/sam``ng_compare` 브랜치
- 대상: 휴식과 `cr_건국`을 포함한 일반 장수 예약 명령 55개 - 대상: 휴식과 `cr_건국`을 포함한 일반 장수 예약 명령 55개
- 범위: 명령 결과, RNG 소비, 로그, 예약 턴 lifecycle, DB 영속화 - 범위: 명령 결과, RNG 소비, 로그, 예약 턴 lifecycle, DB 영속화
이 문서는 테스트 구현 자체가 아니다. 아래의 파일, 실행기, 격리 스택과 현재 ref MariaDB와 core memory를 잇는 공통 runner, 성공 경로 55개,
fixture가 구현되고 완료 기준을 통과하기 전까지 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; 4. live sortie also passes the battle trace comparison;
5. any ignored path is documented in the case evidence. 5. any ignored path is documented in the case evidence.
As of 2026-07-26, 54 general matrix cases, 35 nation matrix cases, declaration As of 2026-07-26, 54 general matrix cases, 35 reserved nation matrix cases,
and live sortie pass this boundary. Live sortie is the remaining general declaration and live sortie pass this boundary. Live sortie is the remaining
command key and covers battle entry, conquest, defeated-general neutralization general command key and covers battle entry, conquest, defeated-general
and last-city nation collapse. Thus all 55 general command keys have a neutralization and last-city nation collapse. Thus all 55 general command keys
completed success-path comparison. This is 91 executable comparison cases; have a completed success-path comparison. The three instant diplomatic nation
it is not yet a claim that failure/boundary paths or all 38 nation commands responses pass a separate reference trace and core resolver/API boundary.
have been dynamically compared.
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 The fixture runner also reports whether the requested legacy command reached
its completed execution path. For multi-turn commands this is derived from the 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; const ded = (score * 1.0) / 3;
// 7. Update General // 7. Update General
const nextExp = general.experience + Math.trunc(exp); // 레거시는 부동소수점 증가분을 INT column에 저장할 때 반올림한다.
const nextDed = general.dedication + Math.trunc(ded); const nextExp = general.experience + Math.round(exp);
const nextDed = general.dedication + Math.round(ded);
let appliedScore = score; let appliedScore = score;
if (context.city && [1, 3].includes(context.city.frontState)) { 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; 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 => ({ const remainCityTrust = (): Constraint => ({
name: 'remainCityTrust', name: 'remainCityTrust',
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
@@ -106,7 +110,7 @@ export class ActionDefinition<
const trustDelta = result.score / 10; const trustDelta = result.score / 10;
context.city.meta = { context.city.meta = {
...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.rice = Math.max(0, context.general.rice - result.costGold);
context.general.experience += result.exp; context.general.experience += result.exp;
@@ -381,3 +381,106 @@ integration('general command success matrix', () => {
120_000 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
);
});