From d8e94c428c349e7630ebc26b576616da83f7fddb Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 03:59:16 +0000 Subject: [PATCH] Add sabotage value boundary parity --- .../general-command-differential-testing.md | 16 +++- .../turn-state-differential-testing.md | 5 + .../src/actions/turn/general/che_탈취.ts | 8 +- .../src/actions/turn/general/che_파괴.ts | 1 - ...rnCommandGeneralMatrix.integration.test.ts | 95 +++++++++++++++++++ 5 files changed, 115 insertions(+), 10 deletions(-) diff --git a/docs/architecture/general-command-differential-testing.md b/docs/architecture/general-command-differential-testing.md index 70a48e9..1fcce3f 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개가 구현됐다. +8개, 모략 결과값 경계 5개가 구현됐다. 실패 9개는 내정 critical `주민선정/정착장려/상업투자/기술연구/물자조달`과 모략 `화계/선동/파괴/탈취`이며 RNG 전체 trace, semantic state delta와 실패 @@ -17,9 +17,11 @@ 금·쌀 부족과 민심 상한을 대표하며 휴식 fallback과 RNG/state delta를 비교한다. clamp 8개는 `화계/선동/파괴/탈취` 각각의 계산 확률 0과 0.5 경계에서 성공 판정 RNG의 무소비 또는 `nextBits(1)` 소비와 전체 -state delta를 비교한다. 나머지 명령별 제약 실패·값 경계·alternative와 전체 core -PostgreSQL 재조회가 완료 기준을 통과하기 전까지 55개 명령 전체의 동적 -호환 상태를 `확인`으로 올리지 않는다. +state delta를 비교한다. 결과값 5개는 화계 농업·상업, 선동 치안·민심, +파괴 수비·성벽의 0 바닥과 탈취의 대상 국가 자원 상한·미보급 도시 최종 +저장 상태를 비교한다. 나머지 명령별 제약 실패·값 경계·alternative와 +전체 core PostgreSQL 재조회가 완료 기준을 통과하기 전까지 55개 명령 +전체의 동적 호환 상태를 `확인`으로 올리지 않는다. ## 결정 요약 @@ -588,6 +590,12 @@ fixture가 명시한 필드만 비교하면 예상하지 못한 side effect를 `city.trust FLOAT` 재조회 정밀도 차이도 발견해 부분 patch와 6자리 유효숫자 저장 경계로 바로잡았다. +결과값 경계 fixture에서는 파괴의 전체 city spread도 선동과 같이 +불필요한 front 재계산을 일으키는 문제를 발견해 수비·성벽·state 부분 +patch로 좁혔다. 미보급 탈취는 레거시가 자원 감소 update 뒤 원본 +`destCity.agri/comm`을 다시 저장하므로 관찰 가능한 최종 자원은 변하지 +않는다. 버그로 보이지만 호환 계약으로 보존하고 `state=32`만 남긴다. + ## 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 5b7b8b2..d1cc99e 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -187,6 +187,11 @@ fire attack, agitation, destruction and seizure. The zero boundary skips the success RNG primitive, while exactly 0.5 consumes `nextBits(1)`; the complete RNG trace and semantic delta match. These cases also fixed fire-attack city state persistence and agitation front/trust persistence differences. +Five sabotage value-boundary cases cover zero floors for fire-attack, +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. 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 3fb35cd..7084481 100644 --- a/packages/logic/src/actions/turn/general/che_탈취.ts +++ b/packages/logic/src/actions/turn/general/che_탈취.ts @@ -140,14 +140,12 @@ export class ActionResolver< ) ); } else { - const commDmg = stolenGold / 12; - const agriDmg = stolenRice / 12; - + // 레거시는 미보급 도시 자원을 먼저 감소시키지만 같은 명령 끝의 + // 원본 destCity 전체 저장이 이를 덮어쓴다. 관찰 가능한 최종 + // 상태는 자원 변화 없이 state 32만 남는다. effects.push( createCityPatchEffect( { - commerce: Math.round(Math.max(0, destCity.commerce - commDmg)), - agriculture: Math.round(Math.max(0, destCity.agriculture - agriDmg)), state: 32, }, args.destCityId diff --git a/packages/logic/src/actions/turn/general/che_파괴.ts b/packages/logic/src/actions/turn/general/che_파괴.ts index 0af57fd..1ae1a35 100644 --- a/packages/logic/src/actions/turn/general/che_파괴.ts +++ b/packages/logic/src/actions/turn/general/che_파괴.ts @@ -106,7 +106,6 @@ export class ActionResolver< effects.push( createCityPatchEffect( { - ...destCity, defence: newDef, wall: newWall, state: 32, // Legacy sabotage state diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index b6fdf07..344bb73 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -554,6 +554,101 @@ integration('general sabotage probability clamp matrix', () => { ); }); +type SabotageValueBoundaryCase = { + name: string; + action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취'; + stat: 'leadership' | 'strength' | 'intelligence'; + fixturePatches: FixturePatches; +}; + +const sabotageValueBoundaryCases: SabotageValueBoundaryCase[] = [ + { + name: 'fire attack does not reduce agriculture or commerce below zero', + action: 'che_화계', + stat: 'intelligence', + fixturePatches: { cities: { 70: { agriculture: 1, commerce: 1 } } }, + }, + { + name: 'agitation does not reduce security or trust below zero', + action: 'che_선동', + stat: 'leadership', + fixturePatches: { cities: { 70: { security: 1, trust: 1 } } }, + }, + { + name: 'destruction does not reduce defence or wall below zero', + action: 'che_파괴', + stat: 'strength', + fixturePatches: { cities: { 70: { defence: 1, wall: 1 } } }, + }, + { + name: 'seizure does not take more than supplied nation resources', + action: 'che_탈취', + stat: 'strength', + fixturePatches: { nations: { 2: { gold: 1, rice: 1 } } }, + }, + { + name: 'seizure does not reduce unsupplied city resources below zero', + action: 'che_탈취', + stat: 'strength', + fixturePatches: { + cities: { 70: { agriculture: 1, commerce: 1, supplyState: 0 } }, + }, + }, +]; + +const hasSuccessfulSabotageLog = (logs: Array>): boolean => + logs.some((entry) => typeof entry.text === 'string' && entry.text.includes('성공했습니다.')); + +integration('general sabotage value boundary matrix', () => { + it.each(sabotageValueBoundaryCases)( + '$name matches the legacy clamped state delta', + async ({ action, stat, fixturePatches }) => { + const request = buildRequest( + action, + { destCityID: 70 }, + { [stat]: 100 }, + { + ...fixturePatches, + generals: { + ...fixturePatches.generals, + 2: { ...fixturePatches.generals?.[2], [stat]: 10 }, + }, + cities: { + ...fixturePatches.cities, + 70: { + ...fixturePatches.cities?.[70], + security: 0, + securityMax: 2_000, + }, + }, + } + ); + request.setup!.world!.hiddenSeed = 'general-value-0'; + 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(hasSuccessfulSabotageLog(reference.after.logs)).toBe(true); + expect(hasSuccessfulSabotageLog(core.after.logs)).toBe(true); + 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;