diff --git a/docs/architecture/general-command-differential-testing.md b/docs/architecture/general-command-differential-testing.md index 1fcce3f..1d5ba6b 100644 --- a/docs/architecture/general-command-differential-testing.md +++ b/docs/architecture/general-command-differential-testing.md @@ -9,7 +9,7 @@ 현재 ref MariaDB와 core memory를 잇는 공통 runner, 성공 경로 55개, 실행 중 확률 실패 9개, full constraint fallback 7개와 모략 확률 clamp -8개, 모략 결과값 경계 5개가 구현됐다. +8개, 모략 결과값 경계 5개, 부상 경계 3개가 구현됐다. 실패 9개는 내정 critical `주민선정/정착장려/상업투자/기술연구/물자조달`과 모략 `화계/선동/파괴/탈취`이며 RNG 전체 trace, semantic state delta와 실패 @@ -19,9 +19,11 @@ 0.5 경계에서 성공 판정 RNG의 무소비 또는 `nextBits(1)` 소비와 전체 state delta를 비교한다. 결과값 5개는 화계 농업·상업, 선동 치안·민심, 파괴 수비·성벽의 0 바닥과 탈취의 대상 국가 자원 상한·미보급 도시 최종 -저장 상태를 비교한다. 나머지 명령별 제약 실패·값 경계·alternative와 -전체 core PostgreSQL 재조회가 완료 기준을 통과하기 전까지 55개 명령 -전체의 동적 호환 상태를 `확인`으로 올리지 않는다. +저장 상태를 비교한다. 부상 3개는 화계·선동·파괴의 대상 장수별 판정, +부상도 80 상한, 병력·훈련·사기 0.98 정수 저장과 대상 장수 로그를 +비교한다. 나머지 명령별 제약 실패·값 경계·alternative와 전체 core +PostgreSQL 재조회가 완료 기준을 통과하기 전까지 55개 명령 전체의 동적 +호환 상태를 `확인`으로 올리지 않는다. ## 결정 요약 @@ -596,6 +598,11 @@ patch로 좁혔다. 미보급 탈취는 레거시가 자원 감소 update 뒤 `destCity.agri/comm`을 다시 저장하므로 관찰 가능한 최종 자원은 변하지 않는다. 버그로 보이지만 호환 계약으로 보존하고 `state=32`만 남긴다. +부상 경계 fixture에서는 화계의 `계략로 인해` 조사를 레거시 +`계략으로 인해`로 수정하고, 선동·파괴에 누락된 대상 장수 부상 로그를 +추가했다. 병력·훈련·사기의 0.98 감소는 ref 정수 DB 저장처럼 내림이 아닌 +반올림을 적용한다. + ## 55개 명령 coverage manifest `manifest.json`은 PHP command inventory와 TS registry의 합집합을 기준으로 diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index d1cc99e..3c44ac4 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -192,6 +192,11 @@ agitation and destruction city values, the supplied-nation resource cap for seizure, and the legacy final state of unsupplied seizure. They also remove destruction's unintended front recalculation and preserve the legacy unsupplied-seizure overwrite that leaves city resources unchanged. +Three sabotage injury-boundary cases cover per-defender rolls, the injury cap +of 80, integer persistence after multiplying crew/train/atmosphere by 0.98, +and the target-general injury log for fire attack, agitation and destruction. +They restore the legacy Korean particle in the log, add the two missing logs, +and round rather than floor the integer fields. This is not yet a claim that every command-specific constraint, clamp, alternative and persistence boundary has been dynamically compared. diff --git a/packages/logic/src/actions/turn/general/che_선동.ts b/packages/logic/src/actions/turn/general/che_선동.ts index 4334367..77cb928 100644 --- a/packages/logic/src/actions/turn/general/che_선동.ts +++ b/packages/logic/src/actions/turn/general/che_선동.ts @@ -136,6 +136,10 @@ export class ActionResolver< consumeSuccessfulStrategyItem(this.pipeline, context); for (const injured of result.injuredGenerals) { effects.push(createGeneralPatchEffect(injured.patch, injured.id)); + ctx.addLog('계략으로 인해 부상을 당했습니다.', { + generalId: injured.id, + format: LogFormat.MONTH, + }); } return { effects }; diff --git a/packages/logic/src/actions/turn/general/che_파괴.ts b/packages/logic/src/actions/turn/general/che_파괴.ts index 1ae1a35..b208b70 100644 --- a/packages/logic/src/actions/turn/general/che_파괴.ts +++ b/packages/logic/src/actions/turn/general/che_파괴.ts @@ -117,6 +117,10 @@ export class ActionResolver< consumeSuccessfulStrategyItem(this.pipeline, context); for (const injured of result.injuredGenerals) { effects.push(createGeneralPatchEffect(injured.patch, injured.id)); + ctx.addLog('계략으로 인해 부상을 당했습니다.', { + generalId: injured.id, + format: LogFormat.MONTH, + }); } return { effects }; diff --git a/packages/logic/src/actions/turn/general/che_화계.ts b/packages/logic/src/actions/turn/general/che_화계.ts index 4df3066..60aa2ba 100644 --- a/packages/logic/src/actions/turn/general/che_화계.ts +++ b/packages/logic/src/actions/turn/general/che_화계.ts @@ -212,9 +212,9 @@ export class CommandResolver${ACTION_KEY}로 인해 부상을 당했습니다.`, { + context.addLog('계략으로 인해 부상을 당했습니다.', { generalId: injured.id, format: LogFormat.MONTH, }); diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 344bb73..5994c8b 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -649,6 +649,74 @@ integration('general sabotage value boundary matrix', () => { ); }); +type SabotageInjuryBoundaryCase = { + action: 'che_화계' | 'che_선동' | 'che_파괴'; + stat: 'leadership' | 'strength' | 'intelligence'; + hiddenSeed: string; +}; + +const sabotageInjuryBoundaryCases: SabotageInjuryBoundaryCase[] = [ + { action: 'che_화계', stat: 'intelligence', hiddenSeed: 'general-injury-4' }, + { action: 'che_선동', stat: 'leadership', hiddenSeed: 'general-injury-13' }, + { action: 'che_파괴', stat: 'strength', hiddenSeed: 'general-injury-4' }, +]; + +const injuryLogTexts = (logs: Array>): string[] => + logs + .map((entry) => entry.text) + .filter((text): text is string => typeof text === 'string' && text.includes('부상을 당했습니다.')); + +const legacyInjuryLogBody = (text: string): string => text.replace(/^●<\/>\d+월:/, ''); + +integration('general sabotage injury boundary matrix', () => { + it.each(sabotageInjuryBoundaryCases)( + '$action matches legacy injury cap, integer persistence, and log', + async ({ action, stat, hiddenSeed }) => { + const request = buildRequest( + action, + { destCityID: 70 }, + { [stat]: 100 }, + { + generals: { + 2: { + [stat]: 10, + injury: 79, + crew: 101, + atmos: 51, + train: 51, + }, + }, + cities: { 70: { security: 0, securityMax: 2_000 } }, + } + ); + 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(injuryLogTexts(reference.after.logs)).toHaveLength(1); + expect(injuryLogTexts(core.after.logs)).toEqual( + injuryLogTexts(reference.after.logs).map(legacyInjuryLogBody) + ); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); + type GeneralConstraintCase = { name: string; action: string;