fix inheritance accounting for unowned generals

This commit is contained in:
2026-07-27 09:16:25 +00:00
parent 9614ba69f6
commit 12ac9c5f4d
4 changed files with 133 additions and 119 deletions
@@ -1123,7 +1123,9 @@ export const createReservedTurnHandler = async (options: {
kind === 'nation' && kind === 'nation' &&
!usedFallback && !usedFallback &&
resolution.completed && resolution.completed &&
definition.countsAsInheritanceActiveAction definition.countsAsInheritanceActiveAction &&
Boolean(currentGeneral.userId) &&
currentGeneral.npcState < 2
) { ) {
const meta = { ...currentGeneral.meta }; const meta = { ...currentGeneral.meta };
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0; const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
@@ -1135,7 +1137,9 @@ export const createReservedTurnHandler = async (options: {
kind === 'general' && kind === 'general' &&
!usedFallback && !usedFallback &&
resolution.completed && resolution.completed &&
executionDefinition.getInheritanceActiveActionAmount executionDefinition.getInheritanceActiveActionAmount &&
Boolean(currentGeneral.userId) &&
currentGeneral.npcState < 2
) { ) {
const amount = executionDefinition.getInheritanceActiveActionAmount(actionContext, actionArgs); const amount = executionDefinition.getInheritanceActiveActionAmount(actionContext, actionArgs);
if (Number.isFinite(amount) && amount !== 0) { if (Number.isFinite(amount) && amount !== 0) {
@@ -7,8 +7,9 @@ import { createTurnTestHarness } from './helpers/turnTestHarness.js';
const mockDate = new Date('0189-01-01T00:00:00Z'); const mockDate = new Date('0189-01-01T00:00:00Z');
const createChief = (): TurnGeneral => ({ const createChief = (userId: string | null): TurnGeneral => ({
id: 1, id: 1,
userId,
name: '군주', name: '군주',
nationId: 1, nationId: 1,
cityId: 1, cityId: 1,
@@ -38,131 +39,137 @@ const createChief = (): TurnGeneral => ({
}); });
describe('레거시 사령부 턴 실행 호환성', () => { describe('레거시 사령부 턴 실행 호환성', () => {
it('화시병 연구는 선행 11턴을 누적한 뒤 12번째 턴에만 완료한다', async () => { it.each([
const cities = buildLargeTestCities(); { ownerLabel: '소유 장수', userId: 'test-user', expectedActiveAction: 1 },
for (const city of cities) { { ownerLabel: '무소유 장수', userId: null, expectedActiveAction: undefined },
city.nationId = city.id === 1 ? 1 : city.id === 2 ? 2 : 0; ])(
} '화시병 연구는 $ownerLabel에서 12번째 턴에 완료하고 소유자에게만 능동 행동을 준다',
const snapshot: TurnWorldSnapshot = { async ({ userId, expectedActiveAction }) => {
generals: [createChief()], const cities = buildLargeTestCities();
cities, for (const city of cities) {
nations: [ city.nationId = city.id === 1 ? 1 : city.id === 2 ? 2 : 0;
{ }
id: 1, const snapshot: TurnWorldSnapshot = {
name: '테스트국', generals: [createChief(userId)],
color: '#aa0000', cities,
capitalCityId: 1, nations: [
chiefGeneralId: 1, {
gold: 1_000_000, id: 1,
rice: 1_000_000, name: '테스트국',
power: 0, color: '#aa0000',
level: 1, capitalCityId: 1,
typeCode: 'large_test_map_def', chiefGeneralId: 1,
meta: { can_화시병사용: 0 }, gold: 1_000_000,
rice: 1_000_000,
power: 0,
level: 1,
typeCode: 'large_test_map_def',
meta: { can_화시병사용: 0 },
},
{
id: 2,
name: '상대국',
color: '#0000aa',
capitalCityId: 2,
chiefGeneralId: null,
gold: 1_000_000,
rice: 1_000_000,
power: 0,
level: 1,
typeCode: 'large_test_map_def',
meta: {},
},
],
troops: [],
diplomacy: [
{ fromNationId: 1, toNationId: 2, state: 0, term: 1200, dead: 0, meta: {} },
{ fromNationId: 2, toNationId: 1, state: 0, term: 1200, dead: 0, meta: {} },
],
events: [],
initialEvents: [],
map: LARGE_TEST_MAP,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {
openingPartYear: 3,
develCost: 10,
baseGold: 1000,
baseRice: 1000,
maxResourceActionAmount: 10000,
maxTechLevel: 12000,
},
environment: { mapName: 'large_test_map', unitSet: 'default' },
}, },
{ scenarioMeta: { startYear: 189 } as never,
id: 2, unitSet: {} as never,
name: '상대국', };
color: '#0000aa', const state: TurnWorldState = {
capitalCityId: 2, id: 1,
chiefGeneralId: null, currentYear: 189,
gold: 1_000_000, currentMonth: 1,
rice: 1_000_000, tickSeconds: 600,
power: 0, lastTurnTime: mockDate,
level: 1, meta: { seed: 1 },
typeCode: 'large_test_map_def', };
meta: {}, const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const resolvedActions: Array<{ requestedAction: string; actionKey: string; blockedReason?: string }> = [];
const { world, reservedTurnStore, runOneTick } = await createTurnTestHarness({
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
reservedTurnStoreOptions: { maxGeneralTurns: 12, maxNationTurns: 12 },
onActionResolved: (event) => {
if (event.kind === 'nation') {
resolvedActions.push(event);
}
}, },
], });
troops: [], const turns = reservedTurnStore.getNationTurns(1, 12);
diplomacy: [ turns.fill({ action: 'event_화시병연구', args: {} });
{ fromNationId: 1, toNationId: 2, state: 0, term: 1200, dead: 0, meta: {} },
{ fromNationId: 2, toNationId: 1, state: 0, term: 1200, dead: 0, meta: {} }, for (let index = 1; index <= 11; index += 1) {
], await runOneTick();
events: [], const nation = world.getNationById(1)!;
initialEvents: [], expect(nation.meta.can_화시병사용 ?? 0).toBe(0);
map: LARGE_TEST_MAP, expect(nation.meta.turn_last_12).toMatchObject({
scenarioConfig: { command: '화시병 연구',
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, term: index,
iconPath: '', });
map: {}, }
const: {
openingPartYear: 3,
develCost: 10,
baseGold: 1000,
baseRice: 1000,
maxResourceActionAmount: 10000,
maxTechLevel: 12000,
},
environment: { mapName: 'large_test_map', unitSet: 'default' },
},
scenarioMeta: { startYear: 189 } as never,
unitSet: {} as never,
};
const state: TurnWorldState = {
id: 1,
currentYear: 189,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: mockDate,
meta: { seed: 1 },
};
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const resolvedActions: Array<{ requestedAction: string; actionKey: string; blockedReason?: string }> = [];
const { world, reservedTurnStore, runOneTick } = await createTurnTestHarness({
snapshot,
state,
schedule,
map: LARGE_TEST_MAP,
reservedTurnStoreOptions: { maxGeneralTurns: 12, maxNationTurns: 12 },
onActionResolved: (event) => {
if (event.kind === 'nation') {
resolvedActions.push(event);
}
},
});
const turns = reservedTurnStore.getNationTurns(1, 12);
turns.fill({ action: 'event_화시병연구', args: {} });
for (let index = 1; index <= 11; index += 1) {
await runOneTick(); await runOneTick();
const nation = world.getNationById(1)!; const nation = world.getNationById(1)!;
expect(nation.meta.can_화시병사용 ?? 0).toBe(0); expect(nation.meta.can_화시병사용).toBe(1);
expect(nation.meta.turn_last_12).toMatchObject({ expect(nation.meta.turn_last_12).toMatchObject({
command: '화시병 연구', command: '화시병 연구',
term: index, term: 0,
}); });
expect(world.getGeneralById(1)!.meta.inherit_active_action).toBe(expectedActiveAction);
expect(world.getGeneralById(1)!.experience).toBe(60);
expect(world.getGeneralById(1)!.dedication).toBe(60);
reservedTurnStore.getNationTurns(1, 12)[0] = {
action: 'che_종전제의',
args: { destNationId: 2 },
};
await runOneTick();
expect(resolvedActions.at(-1)?.blockedReason).toBeUndefined();
expect(resolvedActions.at(-1)).toMatchObject({
requestedAction: 'che_종전제의',
actionKey: 'che_종전제의',
});
expect(world.peekDirtyState().messages).toContainEqual(
expect.objectContaining({
msgType: 'diplomacy',
dest: expect.objectContaining({ nationId: 2 }),
option: { action: 'stopWar', deletable: false },
})
);
} }
);
await runOneTick();
const nation = world.getNationById(1)!;
expect(nation.meta.can_화시병사용).toBe(1);
expect(nation.meta.turn_last_12).toMatchObject({
command: '화시병 연구',
term: 0,
});
expect(world.getGeneralById(1)!.meta.inherit_active_action).toBe(1);
expect(world.getGeneralById(1)!.experience).toBe(60);
expect(world.getGeneralById(1)!.dedication).toBe(60);
reservedTurnStore.getNationTurns(1, 12)[0] = {
action: 'che_종전제의',
args: { destNationId: 2 },
};
await runOneTick();
expect(resolvedActions.at(-1)?.blockedReason).toBeUndefined();
expect(resolvedActions.at(-1)).toMatchObject({
requestedAction: 'che_종전제의',
actionKey: 'che_종전제의',
});
expect(world.peekDirtyState().messages).toContainEqual(
expect.objectContaining({
msgType: 'diplomacy',
dest: expect.objectContaining({ nationId: 2 }),
option: { action: 'stopWar', deletable: false },
})
);
});
it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', async () => { it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', async () => {
const updates: Array<{ id: number; patch: Record<string, unknown> }> = []; const updates: Array<{ id: number; patch: Record<string, unknown> }> = [];
@@ -145,6 +145,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
atmos: row.atmos, atmos: row.atmos,
age: row.age, age: row.age,
npcState: row.npcState, npcState: row.npcState,
hasOwner: typeof row.userId === 'string' && row.userId.length > 0,
turnTime: row.turnTime instanceof Date ? serializeDate(row.turnTime) : row.turnTime, turnTime: row.turnTime instanceof Date ? serializeDate(row.turnTime) : row.turnTime,
recentWarTime: row.recentWarTime instanceof Date ? serializeDate(row.recentWarTime) : row.recentWarTime, recentWarTime: row.recentWarTime instanceof Date ? serializeDate(row.recentWarTime) : row.recentWarTime,
lastTurn: row.lastTurn, lastTurn: row.lastTurn,
@@ -249,6 +249,7 @@ const buildGeneral = (row: Record<string, unknown>, fallbackTurnTime: Date): Tur
atmos: readNumber(row, 'atmos'), atmos: readNumber(row, 'atmos'),
age: readNumber(row, 'age', 30), age: readNumber(row, 'age', 30),
npcState: readNumber(row, 'npcState'), npcState: readNumber(row, 'npcState'),
userId: row.hasOwner === true ? 'turn-differential-owner' : null,
penalty: row.penalty, penalty: row.penalty,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { meta: {
@@ -585,6 +586,7 @@ const projectWorld = (
atmos: toDatabaseInt(general.atmos), atmos: toDatabaseInt(general.atmos),
age: general.age, age: general.age,
npcState: general.npcState, npcState: general.npcState,
hasOwner: Boolean(general.userId),
turnTime: general.turnTime.toISOString(), turnTime: general.turnTime.toISOString(),
recentWarTime: general.recentWarTime?.toISOString() ?? null, recentWarTime: general.recentWarTime?.toISOString() ?? null,
lastTurn: general.lastTurn ?? null, lastTurn: general.lastTurn ?? null,