Add sabotage injury boundary parity

This commit is contained in:
2026-07-26 04:07:42 +00:00
parent ab22384def
commit ac711e66f5
6 changed files with 96 additions and 8 deletions
@@ -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의 합집합을 기준으로
@@ -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.
@@ -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('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
generalId: injured.id,
format: LogFormat.MONTH,
});
}
return { effects };
@@ -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('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
generalId: injured.id,
format: LogFormat.MONTH,
});
}
return { effects };
@@ -212,9 +212,9 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
id: defender.id,
patch: {
injury: clamp(defender.injury + injuryAmount, 0, INJURY_MAX),
crew: Math.floor(defender.crew * 0.98),
atmos: Math.floor(defender.atmos * 0.98),
train: Math.floor(defender.train * 0.98),
crew: Math.round(defender.crew * 0.98),
atmos: Math.round(defender.atmos * 0.98),
train: Math.round(defender.train * 0.98),
},
});
}
@@ -386,7 +386,7 @@ export class ActionResolver<
for (const injured of result.injuredGenerals) {
// 타겟 장수는 Draft가 아니므로 Effect 반환
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
context.addLog(`<M>${ACTION_KEY}</>로 인해 <R>부상</>을 당했습니다.`, {
context.addLog('<M>계략</>로 인해 <R>부상</>을 당했습니다.', {
generalId: injured.id,
format: LogFormat.MONTH,
});
@@ -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<Record<string, unknown>>): string[] =>
logs
.map((entry) => entry.text)
.filter((text): text is string => typeof text === 'string' && text.includes('부상</>을 당했습니다.'));
const legacyInjuryLogBody = (text: string): string => text.replace(/^<C>●<\/>\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<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(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;