fix: match legacy research state boundaries
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js';
|
import { allow, compareValues, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js';
|
||||||
import { beChief, occupiedCity, reqNationGold, reqNationRice } from '@sammo-ts/logic/constraints/presets.js';
|
import { beChief, occupiedCity, reqNationGold, reqNationRice } from '@sammo-ts/logic/constraints/presets.js';
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
||||||
@@ -21,6 +21,14 @@ export interface EventResearchConfig {
|
|||||||
category?: string;
|
category?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLegacyIncompleteResearchValue = (value: unknown): boolean => {
|
||||||
|
if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
|
||||||
|
// PHP compares arrays above scalar values, so `array < 1` is false.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return compareValues(value ?? 0, '<', 1);
|
||||||
|
};
|
||||||
|
|
||||||
const reqNationAuxValue = (auxKey: string, actionName: string): Constraint => ({
|
const reqNationAuxValue = (auxKey: string, actionName: string): Constraint => ({
|
||||||
name: 'reqNationAuxValue',
|
name: 'reqNationAuxValue',
|
||||||
requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []),
|
requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []),
|
||||||
@@ -32,8 +40,7 @@ const reqNationAuxValue = (auxKey: string, actionName: string): Constraint => ({
|
|||||||
if (!nation) {
|
if (!nation) {
|
||||||
return unknownOrDeny(ctx, [{ kind: 'nation', id: ctx.nationId }], '국가 정보가 없습니다.');
|
return unknownOrDeny(ctx, [{ kind: 'nation', id: ctx.nationId }], '국가 정보가 없습니다.');
|
||||||
}
|
}
|
||||||
const current = typeof nation.meta?.[auxKey] === 'number' ? Number(nation.meta?.[auxKey]) : 0;
|
if (!isLegacyIncompleteResearchValue(nation.meta?.[auxKey])) {
|
||||||
if (current >= 1) {
|
|
||||||
return { kind: 'deny', reason: `${actionName}가 이미 완료되었습니다.` };
|
return { kind: 'deny', reason: `${actionName}가 이미 완료되었습니다.` };
|
||||||
}
|
}
|
||||||
return allow();
|
return allow();
|
||||||
|
|||||||
@@ -99,6 +99,71 @@ const researchCase = (action: string, command: string, term: number): NationMatr
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const researchConfigs: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
command: string;
|
||||||
|
auxKey: string;
|
||||||
|
preReqTurn: number;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
event_원융노병연구: {
|
||||||
|
command: '원융노병 연구',
|
||||||
|
auxKey: 'can_원융노병사용',
|
||||||
|
preReqTurn: 23,
|
||||||
|
cost: 100_000,
|
||||||
|
},
|
||||||
|
event_화시병연구: {
|
||||||
|
command: '화시병 연구',
|
||||||
|
auxKey: 'can_화시병사용',
|
||||||
|
preReqTurn: 11,
|
||||||
|
cost: 50_000,
|
||||||
|
},
|
||||||
|
event_음귀병연구: {
|
||||||
|
command: '음귀병 연구',
|
||||||
|
auxKey: 'can_음귀병사용',
|
||||||
|
preReqTurn: 11,
|
||||||
|
cost: 50_000,
|
||||||
|
},
|
||||||
|
event_대검병연구: {
|
||||||
|
command: '대검병 연구',
|
||||||
|
auxKey: 'can_대검병사용',
|
||||||
|
preReqTurn: 11,
|
||||||
|
cost: 50_000,
|
||||||
|
},
|
||||||
|
event_화륜차연구: {
|
||||||
|
command: '화륜차 연구',
|
||||||
|
auxKey: 'can_화륜차사용',
|
||||||
|
preReqTurn: 23,
|
||||||
|
cost: 100_000,
|
||||||
|
},
|
||||||
|
event_산저병연구: {
|
||||||
|
command: '산저병 연구',
|
||||||
|
auxKey: 'can_산저병사용',
|
||||||
|
preReqTurn: 11,
|
||||||
|
cost: 50_000,
|
||||||
|
},
|
||||||
|
event_극병연구: {
|
||||||
|
command: '극병 연구',
|
||||||
|
auxKey: 'can_극병사용',
|
||||||
|
preReqTurn: 23,
|
||||||
|
cost: 100_000,
|
||||||
|
},
|
||||||
|
event_상병연구: {
|
||||||
|
command: '상병 연구',
|
||||||
|
auxKey: 'can_상병사용',
|
||||||
|
preReqTurn: 23,
|
||||||
|
cost: 100_000,
|
||||||
|
},
|
||||||
|
event_무희연구: {
|
||||||
|
command: '무희 연구',
|
||||||
|
auxKey: 'can_무희사용',
|
||||||
|
preReqTurn: 23,
|
||||||
|
cost: 100_000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const buildRequest = (
|
const buildRequest = (
|
||||||
action: string,
|
action: string,
|
||||||
args?: Record<string, unknown>,
|
args?: Record<string, unknown>,
|
||||||
@@ -516,6 +581,31 @@ integration('nation command success matrix', () => {
|
|||||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
})
|
})
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
|
const researchConfig = researchConfigs[action];
|
||||||
|
if (researchConfig) {
|
||||||
|
for (const snapshot of [reference, core]) {
|
||||||
|
const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1);
|
||||||
|
const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1);
|
||||||
|
const generalBefore = snapshot.before.generals.find((entry) => entry.id === 1);
|
||||||
|
const generalAfter = snapshot.after.generals.find((entry) => entry.id === 1);
|
||||||
|
const expectedProgress = 5 * (researchConfig.preReqTurn + 1);
|
||||||
|
|
||||||
|
expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(
|
||||||
|
researchConfig.cost
|
||||||
|
);
|
||||||
|
expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(
|
||||||
|
researchConfig.cost
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
readNumericField(generalAfter, 'experience') - readNumericField(generalBefore, 'experience')
|
||||||
|
).toBe(expectedProgress);
|
||||||
|
expect(
|
||||||
|
readNumericField(generalAfter, 'dedication') - readNumericField(generalBefore, 'dedication')
|
||||||
|
).toBe(expectedProgress);
|
||||||
|
expect(readNumericField(readNationMeta(nationAfter), researchConfig.auxKey)).toBe(1);
|
||||||
|
expect(snapshot.rng).toEqual([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
120_000
|
120_000
|
||||||
);
|
);
|
||||||
@@ -2401,3 +2491,213 @@ integration('nation random capital constraints, candidates, RNG, and city reset
|
|||||||
120_000
|
120_000
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type ResearchBoundaryOutcome = 'fallback' | 'intermediate' | 'completed';
|
||||||
|
|
||||||
|
const researchBoundaryCases: Array<{
|
||||||
|
name: string;
|
||||||
|
action: 'event_화시병연구' | 'event_원융노병연구';
|
||||||
|
term: number;
|
||||||
|
gold?: number;
|
||||||
|
rice?: number;
|
||||||
|
auxValue?: unknown;
|
||||||
|
setAuxValue?: boolean;
|
||||||
|
fixturePatches?: FixturePatches;
|
||||||
|
outcome: ResearchBoundaryOutcome;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'keeps the short research at its last intermediate turn',
|
||||||
|
action: 'event_화시병연구',
|
||||||
|
term: 10,
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'keeps the long research at its last intermediate turn',
|
||||||
|
action: 'event_원융노병연구',
|
||||||
|
term: 22,
|
||||||
|
outcome: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allows the exact short-research reserves without supply',
|
||||||
|
action: 'event_화시병연구',
|
||||||
|
term: 11,
|
||||||
|
fixturePatches: { cities: { 3: { supplyState: 0 } } },
|
||||||
|
outcome: 'completed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allows the exact long-research reserves',
|
||||||
|
action: 'event_원융노병연구',
|
||||||
|
term: 23,
|
||||||
|
outcome: 'completed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects one less than the short gold reserve',
|
||||||
|
action: 'event_화시병연구',
|
||||||
|
term: 11,
|
||||||
|
gold: 49_999,
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects one less than the short rice reserve',
|
||||||
|
action: 'event_화시병연구',
|
||||||
|
term: 11,
|
||||||
|
rice: 51_999,
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects one less than the long gold reserve',
|
||||||
|
action: 'event_원융노병연구',
|
||||||
|
term: 23,
|
||||||
|
gold: 99_999,
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects one less than the long rice reserve',
|
||||||
|
action: 'event_원융노병연구',
|
||||||
|
term: 23,
|
||||||
|
rice: 101_999,
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a numeric completed flag',
|
||||||
|
action: 'event_화시병연구',
|
||||||
|
term: 11,
|
||||||
|
auxValue: 1,
|
||||||
|
setAuxValue: true,
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a numeric-string completed flag',
|
||||||
|
action: 'event_화시병연구',
|
||||||
|
term: 11,
|
||||||
|
auxValue: '1',
|
||||||
|
setAuxValue: true,
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a boolean completed flag',
|
||||||
|
action: 'event_원융노병연구',
|
||||||
|
term: 23,
|
||||||
|
auxValue: true,
|
||||||
|
setAuxValue: true,
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects an array-shaped completed flag',
|
||||||
|
action: 'event_원융노병연구',
|
||||||
|
term: 23,
|
||||||
|
auxValue: [],
|
||||||
|
setAuxValue: true,
|
||||||
|
outcome: 'fallback',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'accepts a numeric-string incomplete flag',
|
||||||
|
action: 'event_원융노병연구',
|
||||||
|
term: 23,
|
||||||
|
auxValue: '0',
|
||||||
|
setAuxValue: true,
|
||||||
|
outcome: 'completed',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
integration('nation event research turn, reserve, and duplicate-state boundaries', () => {
|
||||||
|
it.each(researchBoundaryCases)(
|
||||||
|
'$name matches legacy completion, resources, flags, logs, RNG, and semantic delta',
|
||||||
|
async ({ action, term, gold, rice, auxValue, setAuxValue = false, fixturePatches, outcome }) => {
|
||||||
|
const config = researchConfigs[action]!;
|
||||||
|
const requiredGold = config.cost;
|
||||||
|
const requiredRice = config.cost + 2_000;
|
||||||
|
const request = buildRequest(action, undefined, {
|
||||||
|
...fixturePatches,
|
||||||
|
nations: {
|
||||||
|
...fixturePatches?.nations,
|
||||||
|
1: {
|
||||||
|
...fixturePatches?.nations?.[1],
|
||||||
|
gold: gold ?? requiredGold,
|
||||||
|
rice: rice ?? requiredRice,
|
||||||
|
meta: {
|
||||||
|
...(setAuxValue ? { [config.auxKey]: auxValue } : {}),
|
||||||
|
},
|
||||||
|
turnLastByOfficerLevel: {
|
||||||
|
12: {
|
||||||
|
command: config.command,
|
||||||
|
term,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
const completed = outcome === 'completed';
|
||||||
|
const intermediate = outcome === 'intermediate';
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: action,
|
||||||
|
actionKey: outcome === 'fallback' ? '휴식' : action,
|
||||||
|
usedFallback: outcome === 'fallback',
|
||||||
|
...(outcome === 'fallback' ? {} : { completed }),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const snapshot of [reference, core]) {
|
||||||
|
const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1);
|
||||||
|
const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1);
|
||||||
|
const generalBefore = snapshot.before.generals.find((entry) => entry.id === 1);
|
||||||
|
const generalAfter = snapshot.after.generals.find((entry) => entry.id === 1);
|
||||||
|
const expectedCost = completed ? config.cost : 0;
|
||||||
|
const expectedProgress = completed ? 5 * (config.preReqTurn + 1) : 0;
|
||||||
|
|
||||||
|
expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(
|
||||||
|
expectedCost
|
||||||
|
);
|
||||||
|
expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(
|
||||||
|
expectedCost
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
readNumericField(generalAfter, 'experience') - readNumericField(generalBefore, 'experience')
|
||||||
|
).toBe(expectedProgress);
|
||||||
|
expect(
|
||||||
|
readNumericField(generalAfter, 'dedication') - readNumericField(generalBefore, 'dedication')
|
||||||
|
).toBe(expectedProgress);
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
expect(readNumericField(readNationMeta(nationAfter), config.auxKey)).toBe(1);
|
||||||
|
expect(readNationResource(nationAfter, 'gold')).toBe(0);
|
||||||
|
expect(readNationResource(nationAfter, 'rice')).toBe(2_000);
|
||||||
|
expect(snapshot.after.logs.map((entry) => entry.text)).toEqual(
|
||||||
|
expect.arrayContaining([expect.stringContaining(`<M>${config.command}</> 완료`)])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
expect(readNationMeta(nationAfter)[config.auxKey]).toEqual(
|
||||||
|
readNationMeta(nationBefore)[config.auxKey]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intermediate) {
|
||||||
|
expect(reference.execution.outcome).toMatchObject({
|
||||||
|
lastTurn: {
|
||||||
|
command: config.command,
|
||||||
|
term: config.preReqTurn,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({
|
||||||
|
command: config.command,
|
||||||
|
term: config.preReqTurn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(reference.rng).toEqual([]);
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user