Add sabotage probability clamp parity

This commit is contained in:
2026-07-26 03:50:44 +00:00
parent 9b6d5288d3
commit 7607e6ccc5
5 changed files with 93 additions and 22 deletions
@@ -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
@@ -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.
@@ -46,6 +46,10 @@ const ARGS_SCHEMA = z.object({
});
export type AgitateArgs = z.infer<typeof ARGS_SCHEMA>;
// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로
// 재조회된다. 메모리 상태도 같은 persistence 경계로 정규화한다.
const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6));
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionResolver<TriggerState, AgitateArgs> {
@@ -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: {
@@ -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<string, TriggerValue> = {
...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
)
@@ -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<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(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;