From 91848f3f6fe3272682cda1c30d1c8d8174496590 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 10:25:40 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20NPC=20=EC=9E=90=EB=8F=99=20=EC=84=A0?= =?UTF-8?q?=ED=8F=AC=EC=97=90=EC=84=9C=20=EB=B0=A9=EB=9E=91=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=EA=B5=AD=EC=9D=84=20=EC=A0=9C=EC=99=B8=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref GeneralAI와 같이 level 0 방랑군을 가중 선포 후보에서 제외한다. 정식 유저국과 NPC국은 같은 가중치를 유지하고 후보 및 RNG 순서를 회귀 테스트로 고정한다. --- .../src/turn/ai/generalAi/nation/diplomacy.ts | 6 +- .../generalAiLegacyDecisionParity.test.ts | 149 ++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts index 93e85d09..4de18ed5 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts @@ -175,7 +175,11 @@ export const do선전포고 = (ai: GeneralAI) => { const currentNationId = ai.nation.id; const cities = ai.worldRef.listCities(); const neighbors = ai.worldRef.listNations().filter((nation) => { - if (nation.id <= 0 || nation.id === currentNationId) { + // Ref getAllNationStaticInfo() also contains level-0 wandering nations, + // but automatic declarations explicitly skip them before adjacency and + // weighted target selection. A wandering user ruler must not become an + // NPC war target merely because its occupied city is adjacent. + if (nation.id <= 0 || nation.id === currentNationId || nation.level === 0) { return false; } return isNeighbor(ai.map!, cities, currentNationId, nation.id, true); diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index d1c76704..c82813a0 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -20,6 +20,7 @@ import { do전투준비, do출병 } from '../src/turn/ai/generalAi/general/warAc import { do내정워프, do전방워프, do집합, do후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js'; import { doNPC몰수, doNPC포상, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.js'; import { do천도 } from '../src/turn/ai/generalAi/nation/capital.js'; +import { do선전포고 } from '../src/turn/ai/generalAi/nation/diplomacy.js'; import { doNPC구출발령, doNPC전방발령, @@ -42,6 +43,7 @@ type Candidate = { type ScriptedRng = { bools: boolean[]; choices: unknown[]; + weightedChoices: Array>; weightedPairs: Array>; nextBool: (probability?: number) => boolean; nextFloat1: () => number; @@ -56,6 +58,7 @@ const makeRng = (bools: boolean[] = [], choices: unknown[] = []): ScriptedRng => return { bools: [...bools], choices: scriptedChoices, + weightedChoices: [], weightedPairs: [], nextBool() { return this.bools.shift() ?? false; @@ -79,6 +82,7 @@ const makeRng = (bools: boolean[] = [], choices: unknown[] = []): ScriptedRng => return values[0]!; }, choiceUsingWeight(items: Record): T { + this.weightedChoices.push(Object.fromEntries(Object.entries(items)) as Record); return this.choice( Object.keys(items).map((key) => { const numeric = Number(key); @@ -416,6 +420,71 @@ const makePromotionGeneral = (overrides: Partial): TurnGeneral => ( meta: { ...baseGeneral().meta, belong: 1, ...overrides.meta }, }); +const makeDeclarationAi = ( + targetNations: Nation[], + targetRulers: General[], + rng: ScriptedRng +): GeneralAI => { + const actorNation = { + ...baseNation(), + meta: { ...baseNation().meta, tech: 2_000 }, + }; + const actor = { + ...baseGeneral(), + officerLevel: 12, + npcState: 2, + }; + const targetCities = targetNations.map((nation, index) => ({ + ...baseCity(), + id: index + 2, + name: `대상도시${nation.id}`, + nationId: nation.id, + })); + const cities = [baseCity(), ...targetCities]; + const ai = makeAi({ + general: actor, + nation: actorNation, + nations: [actorNation, ...targetNations], + generals: [actor, ...targetRulers], + rng, + }); + const worldRef = ai.worldRef!; + const mapCityTemplate = ai.map!.cities[0]!; + + Object.assign(ai as unknown as Record, { + worldRef: { + ...worldRef, + listNations: () => [actorNation, ...targetNations], + listGenerals: () => [actor, ...targetRulers], + listCities: () => cities, + getNationById: (id: number) => [actorNation, ...targetNations].find((nation) => nation.id === id) ?? null, + getGeneralById: (id: number) => + [actor, ...targetRulers].find((general) => general.id === id) ?? null, + getCityById: (id: number) => cities.find((city) => city.id === id) ?? null, + }, + map: { + ...ai.map!, + cities: [ + { ...mapCityTemplate, id: 1, connections: targetCities.map((city) => city.id) }, + ...targetCities.map((city) => ({ + ...mapCityTemplate, + id: city.id, + name: city.name, + connections: [1], + })), + ], + }, + frontCities: {}, + npcWarGenerals: { [actor.id]: actor }, + npcCivilGenerals: {}, + userWarGenerals: {}, + userCivilGenerals: {}, + devRate: { pop: 1, all: 1 }, + }); + + return ai; +}; + const makePromotionAi = (options: { ruler: TurnGeneral; generals: TurnGeneral[]; @@ -766,6 +835,86 @@ describe('legacy NPC user-chief promotion parity', () => { * selection and RNG-sensitive gates, not TypeScript implementation details. */ describe('legacy NPC AI final-decision parity', () => { + it('does not declare war on an adjacent level-0 user wandering nation', () => { + const wanderingNation = { + ...baseNation(), + id: 2, + name: '유저방랑군', + capitalCityId: 2, + chiefGeneralId: 2, + level: 0, + }; + const userRuler = { + ...baseGeneral(), + id: 2, + name: '유저방랑군주', + nationId: 2, + cityId: 2, + officerLevel: 12, + npcState: 0, + }; + const rng = makeRng([true], [0]); + const ai = makeDeclarationAi([wanderingNation], [userRuler], rng); + + expect(do선전포고(ai)).toBeNull(); + expect(rng.bools).toEqual([]); + expect(rng.weightedChoices).toEqual([]); + expect(rng.choices).toEqual([0]); + }); + + it('keeps active user and NPC nations equally eligible while excluding the user wandering nation', () => { + const targets = [ + { id: 2, name: '유저방랑군', level: 0, npcState: 0 }, + { id: 3, name: '정식유저국', level: 1, npcState: 0 }, + { id: 4, name: '정식NPC국', level: 1, npcState: 2 }, + ]; + const nations = targets.map( + ({ id, name, level }) => + ({ + ...baseNation(), + id, + name, + capitalCityId: id, + chiefGeneralId: id, + level, + power: 100, + }) satisfies Nation + ); + const rulers = targets.map( + ({ id, name, npcState }) => + ({ + ...baseGeneral(), + id, + name: `${name}군주`, + nationId: id, + cityId: id, + officerLevel: 12, + npcState, + }) satisfies General + ); + + const userTargetRng = makeRng([true], [0]); + const userTargetAi = makeDeclarationAi(nations, rulers, userTargetRng); + expect(do선전포고(userTargetAi)).toMatchObject({ + action: 'che_선전포고', + args: { destNationId: 3 }, + }); + expect(userTargetRng.weightedChoices).toEqual([ + { + '3': 1 / Math.sqrt(101), + '4': 1 / Math.sqrt(101), + }, + ]); + + const npcTargetRng = makeRng([true], [1]); + const npcTargetAi = makeDeclarationAi(nations, rulers, npcTargetRng); + expect(do선전포고(npcTargetAi)).toMatchObject({ + action: 'che_선전포고', + args: { destNationId: 4 }, + }); + expect(npcTargetRng.weightedChoices).toEqual(userTargetRng.weightedChoices); + }); + it('rejects the malformed Ref low-rice donation candidate and continues the priority loop', () => { const rng = makeRng([false], [0]); const ai = makeAi({