From 816762ab563922bb63106fe22a97b20f5293d24c Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 26 Aug 2026 15:28:19 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=BC=EC=A1=B1=20=EC=B2=AB?= =?UTF-8?q?=20=ED=84=B4=EC=9D=84=20=EC=A0=95=EC=A7=80=20=EC=8B=9C=EA=B0=81?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 통일 대기 중 실시간 투영 시각이 새 이민족 첫 턴으로 새던 경로를 정지된 월 경계로 교정한다. 잘못 추가한 NPC 포상 비용 100배 변경도 되돌린다. --- .../src/turn/actionableMessageResponse.ts | 5 ++- app/game-engine/src/turn/ai/policies.ts | 11 +++---- .../src/turn/monthlyInvaderAction.ts | 10 ++++-- .../test/monthlyInvaderAction.test.ts | 20 ++++++------ .../test/npcPolicyLifecycle.test.ts | 32 ++----------------- 5 files changed, 29 insertions(+), 49 deletions(-) diff --git a/app/game-engine/src/turn/actionableMessageResponse.ts b/app/game-engine/src/turn/actionableMessageResponse.ts index acaf4bf5..d77ec066 100644 --- a/app/game-engine/src/turn/actionableMessageResponse.ts +++ b/app/game-engine/src/turn/actionableMessageResponse.ts @@ -290,7 +290,10 @@ const respondToRaiseInvader = async (options: { month: state.currentMonth, startyear: asNumber(state.meta.startYear, state.currentYear), currentEventID: 0, - turnTime: now, + // Ref uses the frozen game_env.turntime while unification is paused. + // `now` is the realtime game projection and can be hours ahead after + // a long response wait, delaying every newly summoned invader turn. + turnTime: state.lastTurnTime, }, event ); diff --git a/app/game-engine/src/turn/ai/policies.ts b/app/game-engine/src/turn/ai/policies.ts index 7968bd69..b87c8af3 100644 --- a/app/game-engine/src/turn/ai/policies.ts +++ b/app/game-engine/src/turn/ai/policies.ts @@ -314,10 +314,9 @@ export class AutorunNationPolicy { const stat = scenarioConfig.stat; if (this.reqNpcWarGold === 0 || this.reqNpcWarRice === 0) { const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId); - // Ref costWithTech() receives a troop count, not a leadership value. - // The policy funds four complete recruitments at the NPC stat cap. - const baseGold = crewType ? crewType.cost * getTechCost(tech, env.maxTechLevel) * stat.npcMax * 100 : 0; - const baseRice = crewType ? crewType.rice * getTechCost(tech, env.maxTechLevel) * stat.npcMax * 100 : 0; + // Ref costWithTech(tech, stat * 100) divides the troop count by 100. + const baseGold = crewType ? crewType.cost * getTechCost(tech, env.maxTechLevel) * stat.npcMax : 0; + const baseRice = crewType ? crewType.rice * getTechCost(tech, env.maxTechLevel) * stat.npcMax : 0; if (this.reqNpcWarGold === 0) { this.reqNpcWarGold = roundTo(baseGold * 4, -2); } @@ -328,8 +327,8 @@ export class AutorunNationPolicy { if (this.reqHumanWarUrgentGold === 0 || this.reqHumanWarUrgentRice === 0) { const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId); - const baseGold = crewType ? crewType.cost * getTechCost(tech, env.maxTechLevel) * stat.max * 100 : 0; - const baseRice = crewType ? crewType.rice * getTechCost(tech, env.maxTechLevel) * stat.max * 100 : 0; + const baseGold = crewType ? crewType.cost * getTechCost(tech, env.maxTechLevel) * stat.max : 0; + const baseRice = crewType ? crewType.rice * getTechCost(tech, env.maxTechLevel) * stat.max : 0; if (this.reqHumanWarUrgentGold === 0) { this.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2); } diff --git a/app/game-engine/src/turn/monthlyInvaderAction.ts b/app/game-engine/src/turn/monthlyInvaderAction.ts index 3be311b1..9e97dd88 100644 --- a/app/game-engine/src/turn/monthlyInvaderAction.ts +++ b/app/game-engine/src/turn/monthlyInvaderAction.ts @@ -157,6 +157,7 @@ export const createRaiseInvaderHandler = (options: { world.updateWorldMeta({ isunited: 1, isUnited: 1 }); const totalGeneralCount = npcEachCount * invaderCities.length + world.listGenerals().length; + let creationTurnBase = environment.turnTime; const maxGeneralsPerMinute = options.maxGeneralsPerMinute ?? readNumber(world.getState().meta.maxGeneralsPerMinute, 1_000); const currentTurnMinutes = world.getState().tickSeconds / 60; @@ -166,6 +167,11 @@ export const createRaiseInvaderHandler = (options: { ); if (nextTerm !== undefined) { world.changeTurnTerm(nextTerm); + // Reprojection preserves the frozen monthly boundary by tick but + // changes its displayed Date. New generals must join that frozen + // boundary, not the realtime game clock that kept advancing while + // unification was waiting for a message response. + creationTurnBase = world.getState().lastTurnTime; world.pushLog({ scope: LogScope.SYSTEM, category: LogCategory.HISTORY, @@ -348,7 +354,7 @@ export const createRaiseInvaderHandler = (options: { reservedTurns: options.reservedTurns, env: options.env, rng, - environment, + environment: { ...environment, turnTime: creationTurnBase }, rawName: `${city.name}대왕`, nationId, cityId: city.id, @@ -374,7 +380,7 @@ export const createRaiseInvaderHandler = (options: { reservedTurns: options.reservedTurns, env: options.env, rng, - environment, + environment: { ...environment, turnTime: creationTurnBase }, rawName: `${city.name}장수${index}`, nationId, cityId: city.id, diff --git a/app/game-engine/test/monthlyInvaderAction.test.ts b/app/game-engine/test/monthlyInvaderAction.test.ts index 751ff92e..657e2187 100644 --- a/app/game-engine/test/monthlyInvaderAction.test.ts +++ b/app/game-engine/test/monthlyInvaderAction.test.ts @@ -277,6 +277,9 @@ describe('invader monthly actions', () => { it('moves a level-4 capital and scales turn times when the general limit requires a longer term', async () => { const cities = [buildCity(1, 1, 4), buildCity(2, 1, 3), buildCity(3, 0, 4)]; const harness = buildHarness({ cities }); + // A message can be accepted long after the frozen monthly boundary. + // The realtime projection must not become the new invaders' first turn. + harness.environment.turnTime = new Date('0200-01-01T01:00:00.000Z'); const handler = createRaiseInvaderHandler({ getWorld: () => harness.world, reservedTurns: harness.reservedTurns, @@ -290,6 +293,11 @@ describe('invader monthly actions', () => { expect(harness.world.getGeneralById(1)?.cityId).toBe(2); expect(harness.world.getState().tickSeconds).toBe(30 * 60); expect(harness.world.getGeneralById(1)?.turnTime.toISOString()).toBe('0200-01-01T00:15:00.000Z'); + const invaderTurnTimes = harness.world + .peekDirtyState() + .createdGenerals.map((general) => general.turnTime.getTime()); + expect(Math.min(...invaderTurnTimes)).toBeGreaterThanOrEqual(new Date('0200-01-01T00:00:00.000Z').getTime()); + expect(Math.max(...invaderTurnTimes)).toBeLessThan(new Date('0200-01-01T00:30:00.000Z').getTime()); expect(harness.world.peekDirtyState().logs[0]?.text).toBe('★턴시간이 30분으로 변경됩니다.'); }); @@ -418,10 +426,7 @@ describe('invader monthly actions', () => { action: [['InvaderEnding']], meta: {}, }; - const invaderNations = [ - buildNation(2, 1, 'ⓞ남만족'), - buildNation(3, 2, 'ⓞ산월족'), - ]; + const invaderNations = [buildNation(2, 1, 'ⓞ남만족'), buildNation(3, 2, 'ⓞ산월족')]; const harness = buildHarness({ cities: [buildCity(1, 2, 4), buildCity(2, 3, 4)], nations: [buildNeutralNation(), ...invaderNations], @@ -464,12 +469,7 @@ describe('invader monthly actions', () => { }; const harness = buildHarness({ cities: [buildCity(1, 1, 3), buildCity(2, 2, 4), buildCity(3, 3, 4)], - nations: [ - buildNeutralNation(), - buildNation(1), - buildNation(2, 2, 'ⓞ남만족'), - buildNation(3, 3, 'ⓞ산월족'), - ], + nations: [buildNeutralNation(), buildNation(1), buildNation(2, 2, 'ⓞ남만족'), buildNation(3, 3, 'ⓞ산월족')], events: [endingEvent], meta: { isunited: 1, refreshLimit: 3 }, }); diff --git a/app/game-engine/test/npcPolicyLifecycle.test.ts b/app/game-engine/test/npcPolicyLifecycle.test.ts index 98272554..009eb8e6 100644 --- a/app/game-engine/test/npcPolicyLifecycle.test.ts +++ b/app/game-engine/test/npcPolicyLifecycle.test.ts @@ -176,32 +176,6 @@ const unitSet: UnitSetDefinition = { }; describe('NPC policy lifecycle', () => { - it('keeps hard-invader starting resources below the Ref-compatible NPC war funding target', () => { - const hardInvaderNation = { - ...snapshot.nations[0]!, - name: 'ⓞ강족', - meta: { ...snapshot.nations[0]!.meta, tech: 15_000 }, - }; - const hardInvaderScenario = structuredClone(snapshot.scenarioConfig); - hardInvaderScenario.stat.npcMax = 80; - const hardInvaderGeneral = { ...general, npcState: 9, gold: 99_999, rice: 99_999 }; - const policy = new AutorunNationPolicy({ - general: hardInvaderGeneral, - aiOptions: null, - nationPolicy: null, - serverPolicy: null, - nation: hardInvaderNation, - env: { ...commandEnv, maxTechLevel: 12 }, - scenarioConfig: hardInvaderScenario, - unitSet, - }); - - expect(policy.reqNpcWarGold).toBe(806_400); - expect(policy.reqNpcWarRice).toBe(806_400); - expect(hardInvaderGeneral.gold).toBeLessThan(policy.reqNpcWarGold); - expect(hardInvaderGeneral.rice).toBeLessThan(policy.reqNpcWarRice); - }); - it('applies CAS-protected semantic policy changes and the next AI instance consumes them without scheduler changes', () => { const world = new InMemoryTurnWorld(state, snapshot, { schedule }); const first = applyNpcPolicyMutation({ @@ -280,10 +254,8 @@ describe('NPC policy lifecycle', () => { expect(policy.reqNationGold).toBe(4_321); expect(policy.priority).toEqual(['천도']); expect(policy.reqNpcDevelGold).toBe(540); - expect(policy.reqNpcWarGold).toBe(391_500); - expect(policy.reqNpcWarRice).toBe(391_500); - expect(policy.reqHumanWarUrgentGold).toBe(626_400); - expect(policy.reqHumanWarUrgentRice).toBe(626_400); + expect(policy.reqNpcWarGold).toBe(3_900); + expect(policy.reqNpcWarRice).toBe(3_900); const beforeConflict = structuredClone(world.getNationById(1)?.meta); expect(