fix core domestic command persistence parity
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
|||||||
CommandResolver,
|
CommandResolver,
|
||||||
type DomesticActionContext,
|
type DomesticActionContext,
|
||||||
type InvestmentConfig,
|
type InvestmentConfig,
|
||||||
|
updateDomesticCriticalMeta,
|
||||||
} from './che_상업투자.js';
|
} from './che_상업투자.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { clamp } from 'es-toolkit';
|
import { clamp } from 'es-toolkit';
|
||||||
@@ -54,7 +55,7 @@ const readTech = (nation: Nation): number => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 레거시 nation.tech는 MariaDB FLOAT이며 다음 명령 재조회 시 6자리 유효숫자로 양자화된다.
|
// 레거시 nation.tech는 MariaDB FLOAT이며 다음 명령 재조회 시 6자리 유효숫자로 양자화된다.
|
||||||
const toLegacyStoredTech = (value: number): number => Number(value.toPrecision(6));
|
const toLegacyStoredTech = (value: number): number => Number(Math.fround(value).toPrecision(6));
|
||||||
|
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
@@ -116,11 +117,14 @@ export class ActionDefinition<
|
|||||||
context.general.experience += result.exp;
|
context.general.experience += result.exp;
|
||||||
context.general.dedication += result.dedication;
|
context.general.dedication += result.dedication;
|
||||||
const intelExp = typeof context.general.meta.intel_exp === 'number' ? context.general.meta.intel_exp : 0;
|
const intelExp = typeof context.general.meta.intel_exp === 'number' ? context.general.meta.intel_exp : 0;
|
||||||
context.general.meta = {
|
context.general.meta = updateDomesticCriticalMeta(
|
||||||
...context.general.meta,
|
{
|
||||||
intel_exp: intelExp + 1,
|
...context.general.meta,
|
||||||
max_domestic_critical: result.pick === 'success' ? result.score : 0,
|
intel_exp: intelExp + 1,
|
||||||
};
|
},
|
||||||
|
result.pick,
|
||||||
|
result.score
|
||||||
|
);
|
||||||
|
|
||||||
const scoreText = Math.round(result.score).toLocaleString();
|
const scoreText = Math.round(result.score).toLocaleString();
|
||||||
const josaUl = JosaUtil.pick(ACTION_NAME, '을');
|
const josaUl = JosaUtil.pick(ACTION_NAME, '을');
|
||||||
|
|||||||
@@ -125,6 +125,15 @@ const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMe
|
|||||||
return { ...meta, [key]: current + delta };
|
return { ...meta, [key]: current + delta };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateDomesticCriticalMeta = (
|
||||||
|
meta: GeneralMeta,
|
||||||
|
pick: DomesticCriticalPick,
|
||||||
|
score: number
|
||||||
|
): GeneralMeta => ({
|
||||||
|
...meta,
|
||||||
|
max_domestic_critical: pick === 'success' ? (getMetaNumber(meta, 'max_domestic_critical') ?? 0) + score / 2 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
export const buildDomesticContextFromView = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
export const buildDomesticContextFromView = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||||
ctx: ConstraintContext,
|
ctx: ConstraintContext,
|
||||||
view: StateView
|
view: StateView
|
||||||
@@ -360,10 +369,7 @@ export class ActionResolver<
|
|||||||
general.dedication += result.dedication;
|
general.dedication += result.dedication;
|
||||||
|
|
||||||
const metaWithStatExp = addMetaNumber(general.meta, this.config.statExpKey, 1);
|
const metaWithStatExp = addMetaNumber(general.meta, this.config.statExpKey, 1);
|
||||||
general.meta =
|
general.meta = updateDomesticCriticalMeta(metaWithStatExp, result.pick, result.score);
|
||||||
result.pick === 'success'
|
|
||||||
? { ...metaWithStatExp, max_domestic_critical: result.score }
|
|
||||||
: { ...metaWithStatExp, max_domestic_critical: 0 };
|
|
||||||
|
|
||||||
const scoreText = Math.round(result.score).toLocaleString();
|
const scoreText = Math.round(result.score).toLocaleString();
|
||||||
const josaUl = JosaUtil.pick(this.config.name, '을');
|
const josaUl = JosaUtil.pick(this.config.name, '을');
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
CommandResolver,
|
CommandResolver,
|
||||||
type DomesticActionContext,
|
type DomesticActionContext,
|
||||||
type InvestmentConfig,
|
type InvestmentConfig,
|
||||||
|
updateDomesticCriticalMeta,
|
||||||
} from './che_상업투자.js';
|
} from './che_상업투자.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { clamp } from 'es-toolkit';
|
import { clamp } from 'es-toolkit';
|
||||||
@@ -95,11 +96,14 @@ export class ActionDefinition<
|
|||||||
context.general.dedication += result.dedication;
|
context.general.dedication += result.dedication;
|
||||||
const leadershipExp =
|
const leadershipExp =
|
||||||
typeof context.general.meta.leadership_exp === 'number' ? context.general.meta.leadership_exp : 0;
|
typeof context.general.meta.leadership_exp === 'number' ? context.general.meta.leadership_exp : 0;
|
||||||
context.general.meta = {
|
context.general.meta = updateDomesticCriticalMeta(
|
||||||
...context.general.meta,
|
{
|
||||||
leadership_exp: leadershipExp + 1,
|
...context.general.meta,
|
||||||
max_domestic_critical: result.pick === 'success' ? result.score : 0,
|
leadership_exp: leadershipExp + 1,
|
||||||
};
|
},
|
||||||
|
result.pick,
|
||||||
|
result.score
|
||||||
|
);
|
||||||
|
|
||||||
const scoreText = populationGain.toLocaleString();
|
const scoreText = populationGain.toLocaleString();
|
||||||
const josaUl = JosaUtil.pick(ACTION_NAME, '을');
|
const josaUl = JosaUtil.pick(ACTION_NAME, '을');
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
CommandResolver,
|
CommandResolver,
|
||||||
type DomesticActionContext,
|
type DomesticActionContext,
|
||||||
type InvestmentConfig,
|
type InvestmentConfig,
|
||||||
|
updateDomesticCriticalMeta,
|
||||||
} from './che_상업투자.js';
|
} from './che_상업투자.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import { clamp } from 'es-toolkit';
|
import { clamp } from 'es-toolkit';
|
||||||
@@ -48,7 +49,7 @@ const readTrust = (city: City): number => {
|
|||||||
|
|
||||||
// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로
|
// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로
|
||||||
// 재조회된다. 같은 턴의 후속 명령도 그 저장 경계를 보도록 정규화한다.
|
// 재조회된다. 같은 턴의 후속 명령도 그 저장 경계를 보도록 정규화한다.
|
||||||
const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6));
|
const toLegacyStoredTrust = (value: number): number => Number(Math.fround(value).toPrecision(6));
|
||||||
|
|
||||||
const remainCityTrust = (): Constraint => ({
|
const remainCityTrust = (): Constraint => ({
|
||||||
name: 'remainCityTrust',
|
name: 'remainCityTrust',
|
||||||
@@ -117,11 +118,14 @@ export class ActionDefinition<
|
|||||||
context.general.dedication += result.dedication;
|
context.general.dedication += result.dedication;
|
||||||
const leadershipExp =
|
const leadershipExp =
|
||||||
typeof context.general.meta.leadership_exp === 'number' ? context.general.meta.leadership_exp : 0;
|
typeof context.general.meta.leadership_exp === 'number' ? context.general.meta.leadership_exp : 0;
|
||||||
context.general.meta = {
|
context.general.meta = updateDomesticCriticalMeta(
|
||||||
...context.general.meta,
|
{
|
||||||
leadership_exp: leadershipExp + 1,
|
...context.general.meta,
|
||||||
max_domestic_critical: result.pick === 'success' ? result.score : 0,
|
leadership_exp: leadershipExp + 1,
|
||||||
};
|
},
|
||||||
|
result.pick,
|
||||||
|
result.score
|
||||||
|
);
|
||||||
|
|
||||||
const scoreText = trustDelta.toFixed(1);
|
const scoreText = trustDelta.toFixed(1);
|
||||||
const josaUl = JosaUtil.pick(ACTION_NAME, '을');
|
const josaUl = JosaUtil.pick(ACTION_NAME, '을');
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
|
|||||||
belong: readNumber(row, 'belong', readNumber(meta, 'belong')),
|
belong: readNumber(row, 'belong', readNumber(meta, 'belong')),
|
||||||
permission: readString(row, 'permission') ?? readString(meta, 'permission') ?? 'normal',
|
permission: readString(row, 'permission') ?? readString(meta, 'permission') ?? 'normal',
|
||||||
maxBelong: readNumber(meta, 'max_belong'),
|
maxBelong: readNumber(meta, 'max_belong'),
|
||||||
|
maxDomesticCritical: readNumber(meta, 'max_domestic_critical'),
|
||||||
betray: row.betray,
|
betray: row.betray,
|
||||||
personality: row.personality ?? null,
|
personality: row.personality ?? null,
|
||||||
specialDomestic: row.specialDomestic ?? null,
|
specialDomestic: row.specialDomestic ?? null,
|
||||||
|
|||||||
@@ -548,6 +548,7 @@ const projectWorld = (
|
|||||||
belong: readNumber(general.meta, 'belong'),
|
belong: readNumber(general.meta, 'belong'),
|
||||||
permission: readString(general.meta, 'permission', 'normal'),
|
permission: readString(general.meta, 'permission', 'normal'),
|
||||||
maxBelong: readNumber(general.meta, 'max_belong'),
|
maxBelong: readNumber(general.meta, 'max_belong'),
|
||||||
|
maxDomesticCritical: readNumber(general.meta, 'max_domestic_critical'),
|
||||||
betray: readNumber(general.meta, 'betray'),
|
betray: readNumber(general.meta, 'betray'),
|
||||||
personality: general.role.personality,
|
personality: general.role.personality,
|
||||||
specialDomestic: general.role.specialDomestic,
|
specialDomestic: general.role.specialDomestic,
|
||||||
|
|||||||
@@ -4,6 +4,27 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import type { CanonicalTurnCommandTrace, CanonicalTurnSnapshot, TurnSnapshotSelector } from './canonical.js';
|
import type { CanonicalTurnCommandTrace, CanonicalTurnSnapshot, TurnSnapshotSelector } from './canonical.js';
|
||||||
|
|
||||||
|
const withProjectedGeneralMeta = (snapshot: CanonicalTurnSnapshot): CanonicalTurnSnapshot => ({
|
||||||
|
...snapshot,
|
||||||
|
generals: snapshot.generals.map((general) => {
|
||||||
|
const meta =
|
||||||
|
typeof general.meta === 'object' && general.meta !== null && !Array.isArray(general.meta)
|
||||||
|
? (general.meta as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const value = meta.max_domestic_critical;
|
||||||
|
return {
|
||||||
|
...general,
|
||||||
|
maxDomesticCritical: typeof value === 'number' && Number.isFinite(value) ? value : 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const withProjectedTraceMeta = (trace: CanonicalTurnCommandTrace): CanonicalTurnCommandTrace => ({
|
||||||
|
...trace,
|
||||||
|
before: withProjectedGeneralMeta(trace.before),
|
||||||
|
after: withProjectedGeneralMeta(trace.after),
|
||||||
|
});
|
||||||
|
|
||||||
export const findTurnDifferentialWorkspaceRoot = (start: string): string | null => {
|
export const findTurnDifferentialWorkspaceRoot = (start: string): string | null => {
|
||||||
let current = path.resolve(start);
|
let current = path.resolve(start);
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -45,7 +66,7 @@ export const readReferenceDatabaseSnapshot = (
|
|||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return JSON.parse(stdout) as CanonicalTurnSnapshot;
|
return withProjectedGeneralMeta(JSON.parse(stdout) as CanonicalTurnSnapshot);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath: string): CanonicalTurnCommandTrace => {
|
export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath: string): CanonicalTurnCommandTrace => {
|
||||||
@@ -60,7 +81,7 @@ export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath:
|
|||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
return JSON.parse(stdout) as CanonicalTurnCommandTrace;
|
return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runReferenceTurnCommandTraceRequest = (
|
export const runReferenceTurnCommandTraceRequest = (
|
||||||
@@ -79,5 +100,5 @@ export const runReferenceTurnCommandTraceRequest = (
|
|||||||
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
|
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return JSON.parse(stdout) as CanonicalTurnCommandTrace;
|
return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1281,6 +1281,112 @@ integration('general domestic command boundary, value, and log parity', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface CoreDomesticBoundaryCase {
|
||||||
|
name: string;
|
||||||
|
action: 'che_농지개간' | 'che_상업투자' | 'che_주민선정' | 'che_정착장려' | 'che_기술연구';
|
||||||
|
actorPatch?: Record<string, unknown>;
|
||||||
|
fixturePatches?: FixturePatches;
|
||||||
|
hiddenSeed: string;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreDomesticBoundaryCases: CoreDomesticBoundaryCase[] = [
|
||||||
|
{
|
||||||
|
name: 'agriculture rejects a city at its exact capacity',
|
||||||
|
action: 'che_농지개간',
|
||||||
|
fixturePatches: { cities: { 3: { agriculture: 1_000, agricultureMax: 1_000 } } },
|
||||||
|
hiddenSeed: 'general-core-domestic-agriculture-capacity',
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'agriculture preserves the early capital front adjustment',
|
||||||
|
action: 'che_농지개간',
|
||||||
|
fixturePatches: {
|
||||||
|
world: { year: 183 },
|
||||||
|
cities: { 3: { frontState: 1, agriculture: 1_000, agricultureMax: 2_000 } },
|
||||||
|
},
|
||||||
|
hiddenSeed: 'turn-command-general-matrix-v1',
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'commerce preserves the non-capital front debuff and integer city storage',
|
||||||
|
action: 'che_상업투자',
|
||||||
|
fixturePatches: {
|
||||||
|
nations: { 1: { capitalCityId: 70 } },
|
||||||
|
cities: { 3: { frontState: 1, commerce: 1_000, commerceMax: 2_000 } },
|
||||||
|
},
|
||||||
|
hiddenSeed: 'turn-command-general-matrix-v1',
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resident selection preserves fractional FLOAT storage and accumulates critical score by half',
|
||||||
|
action: 'che_주민선정',
|
||||||
|
actorPatch: { meta: { max_domestic_critical: 10 } },
|
||||||
|
fixturePatches: {
|
||||||
|
cities: { 3: { trust: 10 } },
|
||||||
|
},
|
||||||
|
hiddenSeed: 'turn-command-general-matrix-v1',
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'settlement critical success uses the shared half-score accumulator',
|
||||||
|
action: 'che_정착장려',
|
||||||
|
actorPatch: { meta: { max_domestic_critical: 10 } },
|
||||||
|
hiddenSeed: 'turn-command-general-matrix-v1',
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'technology critical success uses the shared half-score accumulator',
|
||||||
|
action: 'che_기술연구',
|
||||||
|
actorPatch: { meta: { max_domestic_critical: 10 } },
|
||||||
|
hiddenSeed: 'turn-command-general-matrix-v1',
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'commerce failure resets the current domestic critical accumulator',
|
||||||
|
action: 'che_상업투자',
|
||||||
|
actorPatch: { meta: { max_domestic_critical: 10 } },
|
||||||
|
hiddenSeed: 'general-failure-che_상업투자-2',
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
integration('core domestic command critical, front, and storage boundary parity', () => {
|
||||||
|
it.each(coreDomesticBoundaryCases)(
|
||||||
|
'$name',
|
||||||
|
async ({ action, actorPatch, fixturePatches, hiddenSeed, completed }) => {
|
||||||
|
const request = buildRequest(action, undefined, actorPatch, fixturePatches);
|
||||||
|
request.setup!.world!.hiddenSeed = hiddenSeed;
|
||||||
|
request.observe!.includeGlobalHistoryLogs = true;
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: action,
|
||||||
|
actionKey: completed ? action : '휴식',
|
||||||
|
usedFallback: !completed,
|
||||||
|
});
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
expect(semanticLogSignatures(core.after.logs)).toEqual(
|
||||||
|
semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
interface MilitaryPreparationBoundaryCase {
|
interface MilitaryPreparationBoundaryCase {
|
||||||
name: string;
|
name: string;
|
||||||
action: 'che_모병' | 'che_징병' | 'che_장비매매' | 'che_소집해제' | 'cr_맹훈련';
|
action: 'che_모병' | 'che_징병' | 'che_장비매매' | 'che_소집해제' | 'cr_맹훈련';
|
||||||
@@ -2365,16 +2471,7 @@ const generalStatusTransitionBoundaryCases: GeneralStatusTransitionBoundaryCase[
|
|||||||
integration('general resignation, retirement, and abdication boundary, state, and log parity', () => {
|
integration('general resignation, retirement, and abdication boundary, state, and log parity', () => {
|
||||||
it.each(generalStatusTransitionBoundaryCases)(
|
it.each(generalStatusTransitionBoundaryCases)(
|
||||||
'$name',
|
'$name',
|
||||||
async ({
|
async ({ name, action, args, actorPatch, fixturePatches, completed, expectedActionKey, compareLogs }) => {
|
||||||
name,
|
|
||||||
action,
|
|
||||||
args,
|
|
||||||
actorPatch,
|
|
||||||
fixturePatches,
|
|
||||||
completed,
|
|
||||||
expectedActionKey,
|
|
||||||
compareLogs,
|
|
||||||
}) => {
|
|
||||||
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
||||||
request.setup!.world!.hiddenSeed = `general-status-transition-${name}`;
|
request.setup!.world!.hiddenSeed = `general-status-transition-${name}`;
|
||||||
request.observe!.includeGlobalHistoryLogs = true;
|
request.observe!.includeGlobalHistoryLogs = true;
|
||||||
@@ -2550,16 +2647,7 @@ const generalFoundingRebellionBoundaryCases: GeneralFoundingRebellionBoundaryCas
|
|||||||
integration('general uprising, rebellion, and founding boundary, state, RNG, and log parity', () => {
|
integration('general uprising, rebellion, and founding boundary, state, RNG, and log parity', () => {
|
||||||
it.each(generalFoundingRebellionBoundaryCases)(
|
it.each(generalFoundingRebellionBoundaryCases)(
|
||||||
'$name',
|
'$name',
|
||||||
async ({
|
async ({ name, action, args, actorPatch, fixturePatches, completed, expectedActionKey, compareLogs }) => {
|
||||||
name,
|
|
||||||
action,
|
|
||||||
args,
|
|
||||||
actorPatch,
|
|
||||||
fixturePatches,
|
|
||||||
completed,
|
|
||||||
expectedActionKey,
|
|
||||||
compareLogs,
|
|
||||||
}) => {
|
|
||||||
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
const request = buildRequest(action, args, actorPatch, fixturePatches);
|
||||||
request.setup!.world!.hiddenSeed = `general-founding-rebellion-${name}`;
|
request.setup!.world!.hiddenSeed = `general-founding-rebellion-${name}`;
|
||||||
if (action === 'che_거병') {
|
if (action === 'che_거병') {
|
||||||
|
|||||||
Reference in New Issue
Block a user