From 2e46fb6a704d285516820ad0e40e7a727fa3199a Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 03:31:39 +0000 Subject: [PATCH] Add general command failure parity matrix --- .../general-command-differential-testing.md | 12 +- .../turn-state-differential-testing.md | 19 ++-- .../src/actions/turn/general/che_물자조달.ts | 5 +- .../src/actions/turn/general/che_주민선정.ts | 6 +- ...rnCommandGeneralMatrix.integration.test.ts | 103 ++++++++++++++++++ 5 files changed, 131 insertions(+), 14 deletions(-) diff --git a/docs/architecture/general-command-differential-testing.md b/docs/architecture/general-command-differential-testing.md index a68ea30..e92518b 100644 --- a/docs/architecture/general-command-differential-testing.md +++ b/docs/architecture/general-command-differential-testing.md @@ -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개 명령 전체의 +동적 호환 상태를 `확인`으로 올리지 않는다. ## 결정 요약 diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index 7107b3b..46eddba 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -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 diff --git a/packages/logic/src/actions/turn/general/che_물자조달.ts b/packages/logic/src/actions/turn/general/che_물자조달.ts index a744965..a052526 100644 --- a/packages/logic/src/actions/turn/general/che_물자조달.ts +++ b/packages/logic/src/actions/turn/general/che_물자조달.ts @@ -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)) { diff --git a/packages/logic/src/actions/turn/general/che_주민선정.ts b/packages/logic/src/actions/turn/general/che_주민선정.ts index ac7e256..4a3f77a 100644 --- a/packages/logic/src/actions/turn/general/che_주민선정.ts +++ b/packages/logic/src/actions/turn/general/che_주민선정.ts @@ -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; diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index a19caaf..0c0a1f6 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -381,3 +381,106 @@ integration('general command success matrix', () => { 120_000 ); }); + +type GeneralFailureCase = { + action: string; + args?: Record; + hiddenSeed: string; + failureText: string; +}; + +const failureCases: GeneralFailureCase[] = [ + { + action: 'che_주민선정', + hiddenSeed: 'general-failure-che_주민선정-0', + failureText: "주민 선정을 실패", + }, + { + action: 'che_정착장려', + hiddenSeed: 'general-failure-che_정착장려-0', + failureText: "정착 장려를 실패", + }, + { + action: 'che_상업투자', + hiddenSeed: 'general-failure-che_상업투자-2', + failureText: "상업 투자를 실패", + }, + { + action: 'che_기술연구', + hiddenSeed: 'general-failure-che_기술연구-0', + failureText: "기술 연구를 실패", + }, + { + action: 'che_물자조달', + hiddenSeed: 'general-failure-che_물자조달-1', + 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: '파괴가 실패했습니다.', + }, + { + action: 'che_탈취', + args: { destCityID: 70 }, + hiddenSeed: 'general-failure-che_탈취-0', + failureText: '탈취가 실패했습니다.', + }, +]; + +const failureLogTexts = (logs: Array>, 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 = /^●<\/>\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 + ); + 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 + ); +});