fix(engine): 삭턴 사망 군주의 승계 순서를 Ref와 일치
This commit is contained in:
@@ -2498,7 +2498,13 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
candidate.npcState !== 5
|
candidate.npcState !== 5
|
||||||
);
|
);
|
||||||
let successor: TurnGeneral | undefined;
|
let successor: TurnGeneral | undefined;
|
||||||
const fiction = readMetaNumber(worldMeta, 'fiction', 0);
|
// 설치된 상성 모드는 world config가 소유한다. meta의 오래된 값으로
|
||||||
|
// 가상 모드 군주까지 NPC 상성 승계를 실행하지 않는다.
|
||||||
|
const fiction = readMetaNumber(
|
||||||
|
options.getWorld()?.getWorldConfig() ?? {},
|
||||||
|
'fiction',
|
||||||
|
options.scenarioMeta?.fiction ?? readMetaNumber(worldMeta, 'fiction', 0)
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
fiction === 0 &&
|
fiction === 0 &&
|
||||||
currentGeneral.npcState > 0 &&
|
currentGeneral.npcState > 0 &&
|
||||||
@@ -2514,11 +2520,17 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
const distance = Math.abs((candidate.affinity ?? 0) - (currentGeneral.affinity ?? 0));
|
const distance = Math.abs((candidate.affinity ?? 0) - (currentGeneral.affinity ?? 0));
|
||||||
return distance > 75 ? 150 - distance : distance;
|
return distance > 75 ? 150 - distance : distance;
|
||||||
};
|
};
|
||||||
const minDistance = Math.min(...npcCandidates.map(affinityDistance));
|
npcCandidates.sort(
|
||||||
const nearest = npcCandidates.filter(
|
(left, right) => affinityDistance(left) - affinityDistance(right) || left.id - right.id
|
||||||
(candidate) => affinityDistance(candidate) === minDistance
|
|
||||||
);
|
);
|
||||||
if (nearest.length > 0) {
|
// Ref nextRuler의 !$candidate['npcmatch2'] == $minNPCMatch는
|
||||||
|
// !가 먼저 평가된다. 거리 0이면 동률만, 양수이면 정렬된 전체
|
||||||
|
// 후보를 추첨하는 실제 계승 계약을 보존한다.
|
||||||
|
const hasExactMatch = npcCandidates[0] && affinityDistance(npcCandidates[0]) === 0;
|
||||||
|
const eligible = hasExactMatch
|
||||||
|
? npcCandidates.filter((candidate) => affinityDistance(candidate) === 0)
|
||||||
|
: npcCandidates;
|
||||||
|
if (eligible.length > 0) {
|
||||||
const rng = new RandUtil(
|
const rng = new RandUtil(
|
||||||
new LiteHashDRBG(
|
new LiteHashDRBG(
|
||||||
serializeSeed(
|
serializeSeed(
|
||||||
@@ -2530,7 +2542,7 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
successor = rng.choice(nearest);
|
successor = rng.choice(eligible);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
successor ??= candidates
|
successor ??= candidates
|
||||||
|
|||||||
@@ -408,6 +408,28 @@ describe('legacy general turn lifecycle', () => {
|
|||||||
expect(harness.world.peekDirtyState().deletedGenerals).toContain(1);
|
expect(harness.world.peekDirtyState().deletedGenerals).toContain(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ name: 'installed fiction overrides stale meta', config: 1, scenario: 0, legacy: 0 },
|
||||||
|
{ name: 'scenario fiction without installed override', config: undefined, scenario: 1, legacy: 0 },
|
||||||
|
])('uses chief order for fictional NPC ruler: $name', async ({ config, scenario, legacy }) => {
|
||||||
|
const leader = makeGeneral({ officerLevel: 12, npcState: 2, meta: { killturn: 1 } });
|
||||||
|
const closeNpc = makeGeneral({ id: 2, npcState: 2, affinity: 50, officerLevel: 1 });
|
||||||
|
const chief = makeGeneral({ id: 3, npcState: 0, officerLevel: 11 });
|
||||||
|
const troopLeader = makeGeneral({ id: 4, npcState: 5, officerLevel: 11, dedication: 9999 });
|
||||||
|
const snapshot = makeSnapshot([leader, closeNpc, chief, troopLeader]);
|
||||||
|
snapshot.worldConfig = config === undefined ? {} : { fiction: config };
|
||||||
|
snapshot.scenarioMeta!.fiction = scenario;
|
||||||
|
const state = makeState();
|
||||||
|
state.meta = { ...state.meta, fiction: legacy };
|
||||||
|
const harness = await createTurnTestHarness({ snapshot, state, schedule, map });
|
||||||
|
await harness.runOneTick();
|
||||||
|
expect(harness.world.getGeneralById(1)).toBeNull();
|
||||||
|
expect(harness.world.getGeneralById(3)!.officerLevel).toBe(12);
|
||||||
|
expect(harness.world.getGeneralById(2)!.officerLevel).toBe(1);
|
||||||
|
expect(harness.world.getGeneralById(4)!.officerLevel).toBe(11);
|
||||||
|
expect(harness.world.getNationById(1)!.chiefGeneralId).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
it('dissolves a dying ruler nation when only troop-leader NPCs remain', async () => {
|
it('dissolves a dying ruler nation when only troop-leader NPCs remain', async () => {
|
||||||
const leader = makeGeneral({ officerLevel: 12, meta: { killturn: 1 } });
|
const leader = makeGeneral({ officerLevel: 12, meta: { killturn: 1 } });
|
||||||
const troopLeader = makeGeneral({
|
const troopLeader = makeGeneral({
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface TurnCommandFixtureRequest {
|
|||||||
develCost?: number;
|
develCost?: number;
|
||||||
isUnited?: 0 | 1 | 2 | 3;
|
isUnited?: 0 | 1 | 2 | 3;
|
||||||
hiddenSeed?: string;
|
hiddenSeed?: string;
|
||||||
|
fiction?: 0 | 1;
|
||||||
scenarioEffect?: string | null;
|
scenarioEffect?: string | null;
|
||||||
staticEventHandlers?: Record<string, string[]>;
|
staticEventHandlers?: Record<string, string[]>;
|
||||||
freezeClock?: boolean;
|
freezeClock?: boolean;
|
||||||
@@ -548,7 +549,7 @@ export const buildCoreTurnCommandWorldInput = (
|
|||||||
title: '턴 명령 차등',
|
title: '턴 명령 차등',
|
||||||
startYear: request.setup?.world?.startYear ?? Math.max(1, year - 5),
|
startYear: request.setup?.world?.startYear ?? Math.max(1, year - 5),
|
||||||
life: null,
|
life: null,
|
||||||
fiction: 0,
|
fiction: request.setup?.world?.fiction ?? 0,
|
||||||
history: [],
|
history: [],
|
||||||
ignoreDefaultEvents: false,
|
ignoreDefaultEvents: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -168,6 +168,83 @@ const readFixture = (relativePath: string): TurnCommandFixtureRequest => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
integration('core ↔ legacy command-boundary differential', () => {
|
integration('core ↔ legacy command-boundary differential', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'fictional NPC chief priority',
|
||||||
|
expected: 4,
|
||||||
|
fiction: 1,
|
||||||
|
npc: 2,
|
||||||
|
affinities: [50, 90, 120],
|
||||||
|
levels: [1, 11, 10],
|
||||||
|
},
|
||||||
|
{ name: 'positive affinity distances', expected: 5, npc: 2, affinities: [60, 90, 120], levels: [1, 1, 1] },
|
||||||
|
{
|
||||||
|
name: 'affinity order differs from general id',
|
||||||
|
expected: 3,
|
||||||
|
npc: 2,
|
||||||
|
affinities: [120, 60, 90],
|
||||||
|
levels: [1, 1, 1],
|
||||||
|
},
|
||||||
|
{ name: 'zero affinity distance', expected: 3, npc: 2, affinities: [50, 90, 120], levels: [1, 1, 1] },
|
||||||
|
{ name: 'human ruler chief priority', expected: 4, npc: 0, affinities: [60, 90, 120], levels: [9, 11, 10] },
|
||||||
|
{ name: 'human ruler dedication fallback', expected: 5, npc: 0, affinities: [60, 90, 120], levels: [1, 1, 1] },
|
||||||
|
])('matches ruler succession: $name', async ({ npc, affinities, levels, fiction, expected }) => {
|
||||||
|
const request = readFixture('fixtures/turn-differential/live-sortie-conquest.json');
|
||||||
|
request.setup!.world!.hiddenSeed = 'succession-0';
|
||||||
|
request.setup!.world!.fiction = fiction === 1 ? 1 : 0;
|
||||||
|
request.actorGeneralId = 2;
|
||||||
|
request.action = '휴식';
|
||||||
|
request.args = {};
|
||||||
|
request.includeLifecycle = true;
|
||||||
|
request.setup!.generals![1] = {
|
||||||
|
...request.setup!.generals![1],
|
||||||
|
npcState: npc,
|
||||||
|
affinity: 50,
|
||||||
|
killTurn: 1,
|
||||||
|
deadYear: 185,
|
||||||
|
};
|
||||||
|
request.setup!.generals!.push(
|
||||||
|
...affinities.map((affinity, index) => ({
|
||||||
|
id: index + 3,
|
||||||
|
name: `후계${index}`,
|
||||||
|
nationId: 2,
|
||||||
|
cityId: 70,
|
||||||
|
npcState: 2,
|
||||||
|
affinity,
|
||||||
|
officerLevel: levels[index],
|
||||||
|
dedication: (index + 1) * 100,
|
||||||
|
killTurn: 24,
|
||||||
|
crew: 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
request.observe = {
|
||||||
|
...request.observe,
|
||||||
|
generalIds: [1, 2, 3, 4, 5],
|
||||||
|
nationIds: [1, 2],
|
||||||
|
cityIds: [3, 70],
|
||||||
|
includeGlobalHistoryLogs: true,
|
||||||
|
};
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
const rulers = (snapshot: CanonicalTurnSnapshot) =>
|
||||||
|
snapshot.generals
|
||||||
|
.filter((general) => general.nationId === 2 && general.officerLevel === 12)
|
||||||
|
.map((general) => general.id);
|
||||||
|
expect(reference.after.generals.find((general) => general.id === 2)).toBeUndefined();
|
||||||
|
expect(core.after.generals.find((general) => general.id === 2)).toBeUndefined();
|
||||||
|
expect(reference.before.generals.find((general) => general.id === 2)?.affinity).toBe(50);
|
||||||
|
expect(
|
||||||
|
reference.before.generals.filter((general) => Number(general.id) >= 3).map((general) => general.affinity)
|
||||||
|
).toEqual(affinities);
|
||||||
|
expect(rulers(reference.after)).toEqual([expected]);
|
||||||
|
expect(rulers(core.after)).toEqual(rulers(reference.after));
|
||||||
|
expect(rulers(core.after)).toHaveLength(1);
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
});
|
||||||
|
|
||||||
it('dissolves a successorless ruler nation at the death boundary', async () => {
|
it('dissolves a successorless ruler nation at the death boundary', async () => {
|
||||||
const request = readFixture('fixtures/turn-differential/live-sortie-conquest.json');
|
const request = readFixture('fixtures/turn-differential/live-sortie-conquest.json');
|
||||||
request.actorGeneralId = 2;
|
request.actorGeneralId = 2;
|
||||||
|
|||||||
Reference in New Issue
Block a user