Add sabotage probability clamp parity
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user