From 7607e6ccc51e0b39dc2de0c7b0bf9bd683794bb7 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 03:50:44 +0000 Subject: [PATCH] Add sabotage probability clamp parity --- .../general-command-differential-testing.md | 16 +++-- .../turn-state-differential-testing.md | 7 +- .../src/actions/turn/general/che_선동.ts | 7 +- .../src/actions/turn/general/che_화계.ts | 16 +---- ...rnCommandGeneralMatrix.integration.test.ts | 69 +++++++++++++++++++ 5 files changed, 93 insertions(+), 22 deletions(-) diff --git a/docs/architecture/general-command-differential-testing.md b/docs/architecture/general-command-differential-testing.md index 9c6c3c2..70a48e9 100644 --- a/docs/architecture/general-command-differential-testing.md +++ b/docs/architecture/general-command-differential-testing.md @@ -8,13 +8,16 @@ - 범위: 명령 결과, RNG 소비, 로그, 예약 턴 lifecycle, DB 영속화 현재 ref MariaDB와 core memory를 잇는 공통 runner, 성공 경로 55개, -실행 중 확률 실패 9개와 full constraint fallback 7개가 구현됐다. +실행 중 확률 실패 9개, full constraint fallback 7개와 모략 확률 clamp +8개가 구현됐다. 실패 9개는 내정 critical `주민선정/정착장려/상업투자/기술연구/물자조달`과 모략 `화계/선동/파괴/탈취`이며 RNG 전체 trace, semantic state delta와 실패 로그 본문을 비교한다. 제약 7개는 무소속, 방랑국, 타국 도시, 보급 단절, 금·쌀 부족과 민심 상한을 대표하며 휴식 fallback과 RNG/state delta를 -비교한다. 나머지 명령별 제약 실패·값 경계·alternative와 전체 core +비교한다. clamp 8개는 `화계/선동/파괴/탈취` 각각의 계산 확률 0과 +0.5 경계에서 성공 판정 RNG의 무소비 또는 `nextBits(1)` 소비와 전체 +state delta를 비교한다. 나머지 명령별 제약 실패·값 경계·alternative와 전체 core PostgreSQL 재조회가 완료 기준을 통과하기 전까지 55개 명령 전체의 동적 호환 상태를 `확인`으로 올리지 않는다. @@ -578,9 +581,12 @@ fixture가 명시한 필드만 비교하면 예상하지 못한 side effect를 | `damage-clamp` | 낮은 농업·상업에서 0 미만 방지 | | `constraint-denied` | 중립/같은 도시/자원/보급/불가침 제약과 queue fallback | -`success-basic`은 현재의 `City.meta.state`와 `City.state` 혼동을 반드시 -실패로 검출해야 한다. 이 fixture가 해당 production line을 고의로 잘못 -바꿨을 때 실패하고 복구하면 통과하는 것을 mutation audit에 기록한다. +`success-basic`과 확률 상한 fixture는 `City.meta.state`와 `City.state` +혼동을 검출한다. 상한 fixture에서 실제로 이 결함을 발견해 화계 성공 시 +물리 `City.state=32`를 저장하도록 수정했다. 같은 fixture 묶음에서 선동의 +전체 city spread가 불필요한 front 재계산을 일으키는 문제와 MariaDB +`city.trust FLOAT` 재조회 정밀도 차이도 발견해 부분 patch와 6자리 +유효숫자 저장 경계로 바로잡았다. ## 55개 명령 coverage manifest diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index ee3d389..5b7b8b2 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -182,7 +182,12 @@ full command RNG trace, semantic state delta and the exact action-log body. Seven full-constraint cases cover neutral status, wandering nation, city ownership, supply, gold, rice and trust-cap rejection. Both engines replace the denied command with rest, consume the same RNG and produce the same -semantic delta. This is not yet a claim that every command-specific +semantic delta. Eight sabotage clamp cases cover probability zero and 0.5 for +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. +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 9371882..4334367 100644 --- a/packages/logic/src/actions/turn/general/che_선동.ts +++ b/packages/logic/src/actions/turn/general/che_선동.ts @@ -46,6 +46,10 @@ const ARGS_SCHEMA = z.object({ }); export type AgitateArgs = z.infer; +// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로 +// 재조회된다. 메모리 상태도 같은 persistence 경계로 정규화한다. +const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6)); + export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionResolver { @@ -95,7 +99,7 @@ export class ActionResolver< general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1; const newSecu = Math.max(0, destCity.security - result.agriDamage); const currentTrust = typeof destCity.meta.trust === 'number' ? destCity.meta.trust : 50; - const newTrust = Math.max(0, currentTrust - result.commDamage); + const newTrust = toLegacyStoredTrust(Math.max(0, currentTrust - result.commDamage)); // Log const commandName = ACTION_NAME; @@ -118,7 +122,6 @@ export class ActionResolver< effects.push( createCityPatchEffect( { - ...destCity, security: newSecu, state: 32, meta: { diff --git a/packages/logic/src/actions/turn/general/che_화계.ts b/packages/logic/src/actions/turn/general/che_화계.ts index 78450a5..4df3066 100644 --- a/packages/logic/src/actions/turn/general/che_화계.ts +++ b/packages/logic/src/actions/turn/general/che_화계.ts @@ -1,12 +1,5 @@ import type { RandomGenerator } from '@sammo-ts/common'; -import type { - City, - General, - GeneralMeta, - GeneralTriggerState, - Nation, - TriggerValue, -} from '@sammo-ts/logic/domain/entities.js'; +import type { City, General, GeneralMeta, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { disallowDiplomacyBetweenStatus, @@ -350,18 +343,13 @@ export class ActionResolver< return { effects: [] }; } - const updatedCityMeta: Record = { - ...context.destCity.meta, - state: CITY_STATE_BURNING, - }; - // 타겟 도시는 Draft가 아니므로 Effect 반환 effects.push( createCityPatchEffect( { agriculture: context.destCity.agriculture - result.agriDamage, commerce: context.destCity.commerce - result.commDamage, - meta: updatedCityMeta, + state: CITY_STATE_BURNING, }, context.destCity.id ) diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index e61a01f..b6fdf07 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -485,6 +485,75 @@ integration('general command in-action failure matrix', () => { ); }); +type SabotageProbabilityClampCase = { + name: string; + action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취'; + stat: 'leadership' | 'strength' | 'intelligence'; + boundary: 'zero' | 'max'; +}; + +const sabotageProbabilityClampCases: SabotageProbabilityClampCase[] = ( + [ + ['che_화계', 'intelligence'], + ['che_선동', 'leadership'], + ['che_파괴', 'strength'], + ['che_탈취', 'strength'], + ] as const +).flatMap(([action, stat]) => [ + { name: `${action} probability zero clamp`, action, stat, boundary: 'zero' }, + { name: `${action} probability 0.5 clamp`, action, stat, boundary: 'max' }, +]); + +integration('general sabotage probability clamp matrix', () => { + it.each(sabotageProbabilityClampCases)( + '$name preserves the legacy clamp and RNG primitive', + async ({ action, stat, boundary }) => { + const isZero = boundary === 'zero'; + const request = buildRequest( + action, + { destCityID: 70 }, + { [stat]: isZero ? 10 : 100 }, + { + generals: { 2: { [stat]: isZero ? 100 : 10 } }, + cities: { + 70: { + security: isZero ? 2_000 : 0, + securityMax: 2_000, + supplyState: 1, + }, + }, + } + ); + request.setup!.world!.hiddenSeed = `general-probability-clamp-${action}-${boundary}`; + 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(reference.rng[0]).toMatchObject( + isZero + ? { operation: 'nextInt', arguments: { maxInclusive: 99 } } + : { operation: 'nextBits', arguments: { bits: 1 } } + ); + 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;