merge: 최신 main을 턴 선택기 눌림 수정에 반영

This commit is contained in:
2026-08-17 01:30:09 +00:00
4 changed files with 583 additions and 14 deletions
+131 -7
View File
@@ -238,7 +238,12 @@ export class GeneralAI {
private devRate: Record<string, number> | null = null;
private categorizedCities = false;
private categorizedGenerals = false;
private promotionPatches: Array<{ generalId: number; officerLevel: number; officerCity: number }> = [];
private promotionPatches: Array<{
generalId: number;
officerLevel: number;
officerCity: number;
permission?: string;
}> = [];
private promotionNationMeta: Record<string, unknown> | null = null;
private readonly initialGeneralMeta: Record<string, unknown>;
@@ -433,7 +438,7 @@ export class GeneralAI {
}
consumePromotionPatches(): {
generals: Array<{ generalId: number; officerLevel: number; officerCity: number }>;
generals: Array<{ generalId: number; officerLevel: number; officerCity: number; permission?: string }>;
nationMeta: Record<string, unknown> | null;
} {
const result = {
@@ -1068,6 +1073,7 @@ export class GeneralAI {
}
const minChiefLevel = this.nation.level >= 6 ? 5 : this.nation.level >= 4 ? 7 : this.nation.level >= 2 ? 9 : 11;
let chiefSet = readMetaNumber(asRecord(this.nation.meta), 'chief_set', 0);
const initialChiefSet = chiefSet;
const generals = this.worldRef
.listGenerals()
.filter((candidate) => candidate.nationId === this.nation!.id)
@@ -1080,12 +1086,102 @@ export class GeneralAI {
});
const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel]));
let userChiefCount = 0;
const worldKillturn = readMetaNumber(asRecord(this.world.meta), 'killturn', 0);
const minUserKillturn = worldKillturn - Math.trunc(240 / this.turnTermMinutes);
const minNpcKillturn = 36;
for (let chiefLevel = minChiefLevel; chiefLevel <= 12; chiefLevel += 1) {
const chief = this.chiefGenerals[chiefLevel];
if (!chief) {
continue;
}
const penalty = asRecord(chief.penalty);
const killturn = readRequiredMetaNumber(asRecord(chief.meta), 'killturn', `generalId=${chief.id}`);
if (chief.npcState < 2 && killturn >= minUserKillturn && penalty.noAmbassador !== true) {
userChiefCount += 1;
chief.meta = { ...chief.meta, permission: 'ambassador' };
this.promotionPatches.push({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
permission: 'ambassador',
});
}
}
const minBelong = Math.min(readMetaNumber(asRecord(this.general.meta), 'belong', 0) - 1, 3);
const availableUserChiefCount = Object.values(this.userGenerals).filter((candidate) => {
const penalty = asRecord(candidate.penalty);
const killturn = readRequiredMetaNumber(asRecord(candidate.meta), 'killturn', `generalId=${candidate.id}`);
return (
killturn >= minUserKillturn &&
readMetaNumber(asRecord(candidate.meta), 'belong', 0) >= minBelong &&
penalty.noChief !== true
);
}).length;
if (userChiefCount === 0 && availableUserChiefCount > 0 && (chiefSet & (1 << 11)) === 0) {
const userCandidates = Object.values(this.userGenerals).sort((left, right) => {
const leftPenalty = asRecord(left.penalty);
const rightPenalty = asRecord(right.penalty);
if ((leftPenalty.noChief === true) !== (rightPenalty.noChief === true)) {
return leftPenalty.noChief === true ? 1 : -1;
}
if ((leftPenalty.noAmbassador === true) !== (rightPenalty.noAmbassador === true)) {
return leftPenalty.noAmbassador === true ? 1 : -1;
}
return right.stats.leadership - left.stats.leadership;
});
for (const candidate of userCandidates) {
const penalty = asRecord(candidate.penalty);
const killturn = readRequiredMetaNumber(
asRecord(candidate.meta),
'killturn',
`generalId=${candidate.id}`
);
if (
penalty.noChief === true ||
killturn < minUserKillturn ||
readMetaNumber(asRecord(candidate.meta), 'belong', 0) < minBelong ||
candidate.officerLevel > 4
) {
continue;
}
const permission = penalty.noAmbassador === true ? undefined : 'ambassador';
candidate.officerLevel = 11;
candidate.meta = {
...candidate.meta,
officer_city: 0,
...(permission ? { permission } : {}),
};
this.promotionPatches.push({
generalId: candidate.id,
officerLevel: 11,
officerCity: 0,
...(permission ? { permission } : {}),
});
effectiveOfficerLevel.set(candidate.id, 11);
chiefSet |= 1 << 11;
userChiefCount += 1;
break;
}
}
for (let chiefLevel = 11; chiefLevel >= minChiefLevel; chiefLevel -= 1) {
if ((chiefSet & (1 << chiefLevel)) !== 0 || this.general.officerLevel === chiefLevel) {
continue;
}
const oldChief = generals.find((candidate) => candidate.officerLevel === chiefLevel);
if (oldChief) {
const oldChiefKillturn = readRequiredMetaNumber(
asRecord(oldChief.meta),
'killturn',
`generalId=${oldChief.id}`
);
if (oldChief.npcState < 2 && oldChiefKillturn >= minChiefLevel) {
continue;
}
const newChiefProbability = this.rng.nextBool(0.1) ? 1 : 0;
// GeneralAI.php performs a second nextBool(0) call on the
// rejection path. Preserve that consumption for the shared
@@ -1095,7 +1191,7 @@ export class GeneralAI {
}
}
const nextChief = generals.find((candidate) => {
if ((effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel) > 4 || candidate.npcState < 2) {
if ((effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel) > 4) {
return false;
}
const killturn = readRequiredMetaNumber(
@@ -1103,7 +1199,13 @@ export class GeneralAI {
'killturn',
`generalId=${candidate.id}`
);
if (killturn < 36) {
if (candidate.npcState < 2 && killturn < minUserKillturn) {
return false;
}
if (candidate.npcState >= 2 && killturn < minNpcKillturn) {
return false;
}
if (asRecord(candidate.penalty).noChief === true) {
return false;
}
if (chiefLevel !== 11 && chiefLevel % 2 === 0 && candidate.stats.strength < this.aiConst.chiefStatMin) {
@@ -1116,6 +1218,9 @@ export class GeneralAI {
) {
return false;
}
if (candidate.npcState < 2 && userChiefCount >= 3) {
return false;
}
return true;
});
if (!nextChief) {
@@ -1124,18 +1229,37 @@ export class GeneralAI {
if (oldChief) {
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
}
this.promotionPatches.push({ generalId: nextChief.id, officerLevel: chiefLevel, officerCity: 0 });
const permission =
nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true ? 'ambassador' : undefined;
if (nextChief.npcState < 2) {
userChiefCount += 1;
}
this.promotionPatches.push({
generalId: nextChief.id,
officerLevel: chiefLevel,
officerCity: 0,
...(permission ? { permission } : {}),
});
if (process.env.CORE_AI_TRACE_SEQUENCE === '1') {
process.stdout.write(
`AI_PROMOTION_TRACE ${JSON.stringify({ engine: 'core', mode: 'lord', actor: this.general.id, chiefLevel, picked: nextChief.id })}\n`
);
}
nextChief.meta = {
...nextChief.meta,
officer_city: 0,
...(permission ? { permission } : {}),
};
effectiveOfficerLevel.set(nextChief.id, chiefLevel);
chiefSet |= 1 << chiefLevel;
}
if (this.promotionPatches.length > 0) {
this.promotionNationMeta = { ...this.nation.meta, chief_set: chiefSet };
if (chiefSet !== initialChiefSet) {
this.promotionNationMeta = {
...this.nation.meta,
...(this.promotionNationMeta ?? {}),
chief_set: chiefSet,
};
}
}
@@ -1690,7 +1690,13 @@ export const createReservedTurnHandler = async (options: {
const patch = {
officerLevel: entry.officerLevel,
...(promotedGeneral
? { meta: { ...promotedGeneral.meta, officer_city: entry.officerCity } }
? {
meta: {
...promotedGeneral.meta,
officer_city: entry.officerCity,
...(entry.permission ? { permission: entry.permission } : {}),
},
}
: {}),
};
patches.generals.push({ id: entry.generalId, patch });
@@ -3,6 +3,7 @@ import type { City, General, Nation } from '@sammo-ts/logic';
import { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
import { GeneralAI } from '../src/turn/ai/generalAi.js';
import type { TurnGeneral } from '../src/turn/types.js';
import {
calculateRecentWarTurn,
resolveLegacyAiStats,
@@ -23,10 +24,7 @@ import {
doNPC전방발령,
doNPC후방발령,
} from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js';
import {
do부대구출발령,
do부대후방발령,
} from '../src/turn/ai/generalAi/nation/assignments/troopAssignments.js';
import { do부대구출발령, do부대후방발령 } from '../src/turn/ai/generalAi/nation/assignments/troopAssignments.js';
type Candidate = {
action: string;
@@ -397,6 +395,311 @@ const makeAi = (
} as unknown as GeneralAI;
};
const makePromotionGeneral = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
...baseGeneral(),
...overrides,
stats: { ...baseGeneral().stats, ...overrides.stats },
meta: { ...baseGeneral().meta, belong: 1, ...overrides.meta },
});
const makePromotionAi = (options: {
ruler: TurnGeneral;
generals: TurnGeneral[];
nation?: Partial<Nation>;
userGenerals?: TurnGeneral[];
chiefGenerals?: TurnGeneral[];
npcWarGenerals?: TurnGeneral[];
npcCivilGenerals?: TurnGeneral[];
userWarGenerals?: TurnGeneral[];
userCivilGenerals?: TurnGeneral[];
rng?: ScriptedRng;
currentMonth?: number;
}): GeneralAI => {
const nation = {
...baseNation(),
level: 1,
...options.nation,
meta: { chief_set: 0, ...options.nation?.meta },
};
const asGeneralRecord = (entries: TurnGeneral[] = []): Record<number, TurnGeneral> =>
Object.fromEntries(entries.map((general) => [general.id, general]));
const asChiefRecord = (entries: TurnGeneral[] = []): Record<number, TurnGeneral> =>
Object.fromEntries(entries.map((general) => [general.officerLevel, general]));
return Object.assign(Object.create(GeneralAI.prototype), {
general: options.ruler,
nation,
world: {
id: 1,
currentYear: 190,
currentMonth: options.currentMonth ?? 3,
tickSeconds: 600,
lastTurnTime: new Date('0190-03-01T00:00:00Z'),
meta: { killturn: 100 },
},
worldRef: {
listGenerals: () => options.generals,
},
turnTermMinutes: 10,
aiConst: { chiefStatMin: 70 },
rng: options.rng ?? makeRng(),
userGenerals: asGeneralRecord(options.userGenerals),
chiefGenerals: asChiefRecord(options.chiefGenerals),
npcWarGenerals: asGeneralRecord(options.npcWarGenerals),
npcCivilGenerals: asGeneralRecord(options.npcCivilGenerals),
userWarGenerals: asGeneralRecord(options.userWarGenerals),
userCivilGenerals: asGeneralRecord(options.userCivilGenerals),
promotionPatches: [],
promotionNationMeta: null,
}) as GeneralAI;
};
const chooseNpcPromotion = (ai: GeneralAI): void =>
(ai as unknown as { chooseNpcPromotion: () => void }).chooseNpcPromotion();
const chooseNonLordPromotion = (ai: GeneralAI): void =>
(ai as unknown as { chooseNonLordPromotion: () => void }).chooseNonLordPromotion();
describe('legacy NPC user-chief promotion parity', () => {
it('appoints the first active user as advisor when the NPC ruler tenure threshold is already met', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 2 },
});
const user = makePromotionGeneral({
id: 2,
name: '신규유저',
npcState: 0,
stats: { leadership: 40, strength: 40, intelligence: 40 },
meta: { killturn: 100, belong: 1 },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, user],
userGenerals: [user],
chiefGenerals: [ruler],
});
chooseNpcPromotion(ai);
expect(ai.consumePromotionPatches()).toEqual({
generals: [{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' }],
nationMeta: expect.objectContaining({ chief_set: 1 << 11 }),
});
});
it('waits for belong 3 before forcing a user over a stronger NPC under an established NPC ruler', () => {
const run = (belong: number) => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 4 },
});
const npc = makePromotionGeneral({
id: 2,
name: '강한NPC',
npcState: 2,
stats: { leadership: 100, strength: 100, intelligence: 100 },
meta: { killturn: 100, belong: 4 },
});
const user = makePromotionGeneral({
id: 3,
name: '유저후보',
npcState: 0,
stats: { leadership: 80, strength: 80, intelligence: 80 },
meta: { killturn: 100, belong },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, npc, user],
userGenerals: [user],
chiefGenerals: [ruler],
});
chooseNpcPromotion(ai);
return ai.consumePromotionPatches().generals;
};
expect(run(1)).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0 }]);
expect(run(3)).toEqual([{ generalId: 3, officerLevel: 11, officerCity: 0, permission: 'ambassador' }]);
});
it('prefers an ambassador-eligible user over a higher-leadership no-ambassador user', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 2 },
});
const blockedAmbassador = makePromotionGeneral({
id: 2,
npcState: 0,
stats: { leadership: 95, strength: 80, intelligence: 80 },
meta: { killturn: 100, belong: 1 },
penalty: { noAmbassador: true },
});
const eligible = makePromotionGeneral({
id: 3,
npcState: 0,
stats: { leadership: 70, strength: 80, intelligence: 80 },
meta: { killturn: 100, belong: 1 },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, blockedAmbassador, eligible],
userGenerals: [blockedAmbassador, eligible],
chiefGenerals: [ruler],
});
chooseNpcPromotion(ai);
expect(ai.consumePromotionPatches().generals).toEqual([
{ generalId: 3, officerLevel: 11, officerCity: 0, permission: 'ambassador' },
]);
});
it('does not appoint a user carrying the no-chief penalty', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 2 },
});
const blocked = makePromotionGeneral({
id: 2,
npcState: 0,
stats: { leadership: 100, strength: 100, intelligence: 100 },
meta: { killturn: 100, belong: 1 },
penalty: { noChief: true },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, blocked],
userGenerals: [blocked],
chiefGenerals: [ruler],
});
chooseNpcPromotion(ai);
expect(ai.consumePromotionPatches()).toEqual({ generals: [], nationMeta: null });
});
it('does not appoint a fourth user chief in the ordinary NPC-ruler fill pass', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 4 },
});
const existingChiefs = [11, 10, 9].map((officerLevel, index) =>
makePromotionGeneral({
id: index + 2,
npcState: 0,
officerLevel,
meta: { killturn: 100, belong: 4, officer_city: 0 },
})
);
const candidate = makePromotionGeneral({
id: 5,
npcState: 0,
stats: { leadership: 100, strength: 100, intelligence: 100 },
meta: { killturn: 100, belong: 4 },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, ...existingChiefs, candidate],
nation: { level: 6 },
userGenerals: [...existingChiefs, candidate],
chiefGenerals: [ruler, ...existingChiefs],
});
chooseNpcPromotion(ai);
const promotion = ai.consumePromotionPatches();
const result = promotion.generals;
expect(result.filter((entry) => entry.generalId === candidate.id)).toEqual([]);
expect(result).toHaveLength(3);
expect(promotion.nationMeta).toBeNull();
expect(result).toEqual(
expect.arrayContaining(
existingChiefs.map((chief) => ({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: 0,
permission: 'ambassador',
}))
)
);
});
it('lets an NPC non-ruler fill an open seat with a user immediately when no NPC pool exists', () => {
const actor = makePromotionGeneral({
id: 1,
officerLevel: 10,
npcState: 2,
meta: { killturn: 100, belong: 4 },
});
const user = makePromotionGeneral({
id: 2,
npcState: 0,
stats: { leadership: 40, strength: 40, intelligence: 40 },
meta: { killturn: 100, belong: 1 },
});
const ai = makePromotionAi({
ruler: actor,
generals: [actor, user],
userWarGenerals: [user],
chiefGenerals: [actor],
});
chooseNonLordPromotion(ai);
expect(ai.consumePromotionPatches().generals).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0 }]);
});
it('runs automatic appointments only on the quarterly NPC nation turn', () => {
const run = (currentMonth: number) => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 2 },
});
const user = makePromotionGeneral({
id: 2,
npcState: 0,
meta: { killturn: 100, belong: 1 },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, user],
userGenerals: [user],
chiefGenerals: [ruler],
currentMonth,
});
Object.assign(ai as unknown as Record<string, unknown>, {
updateInstance: () => undefined,
categorizeNationCities: () => undefined,
categorizeNationGeneral: () => undefined,
nationPolicy: { priority: [] },
buildNationCandidate: (action: string, args: Record<string, unknown>, reason: string) => ({
action,
args,
reason,
}),
});
ai.chooseNationTurn({ action: '휴식', args: {} });
return ai.consumePromotionPatches().generals;
};
expect(run(2)).toEqual([]);
expect(run(3)).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' }]);
});
});
/**
* Expected branches are extracted from ref/sam hwe/sammo/GeneralAI.php
* at ng_compare@fe9ae978. These tests intentionally assert final command
@@ -1082,8 +1385,7 @@ describe('legacy NPC AI final-decision parity', () => {
nation: { rice: 100_000 },
generalActionModules: singleActionModuleStack({
eventHandlers: {},
onCalcStat: (_context, statName, value) =>
statName === 'leadership' ? Number(value) + 30 : value,
onCalcStat: (_context, statName, value) => (statName === 'leadership' ? Number(value) + 30 : value),
}),
});
ai.maxResourceActionAmount = 100_000;
@@ -299,4 +299,141 @@ describe('NPC 일반 내정 턴', () => {
})
);
});
it('NPC 군주 국가턴에서 신규 유저의 수뇌 직책과 외교 권한을 월드 상태에 반영한다', async () => {
const buildGeneral = (overrides: Partial<TurnGeneral>): TurnGeneral => {
const base: TurnGeneral = {
id: 1,
name: 'NPC군주',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 80, strength: 80, intelligence: 80 },
turnTime: mockDate,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 100, belong: 2 },
officerLevel: 12,
experience: 0,
dedication: 0,
injury: 0,
gold: 2_000,
rice: 2_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 2,
};
return {
...base,
...overrides,
stats: { ...base.stats, ...overrides.stats },
meta: { ...base.meta, ...overrides.meta },
};
};
const ruler = buildGeneral({});
const user = buildGeneral({
id: 2,
name: '신규유저',
npcState: 0,
officerLevel: 1,
meta: { killturn: 100, belong: 1 },
});
const city = {
id: 1,
name: '소성A',
nationId: 1,
level: 1,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 500,
defenceMax: 1_000,
wall: 500,
wallMax: 1_000,
meta: { trust: 98 },
};
const nation = {
id: 1,
name: 'NPC국가',
color: '#FF0000',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 50_000,
rice: 50_000,
power: 0,
level: 1,
typeCode: 'che_def',
meta: { chief_set: 0 },
};
const snapshot: TurnWorldSnapshot = {
generals: [ruler, user],
cities: [city] as any,
nations: [nation] as any,
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: MINIMAL_MAP as any,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: { npcMessageFreqByDay: 144 },
environment: { mapName: 'npc_domestic_map', unitSet: 'default' },
},
scenarioMeta: { startYear: 189 } as any,
unitSet: {} as any,
};
const state: TurnWorldState = {
id: 1,
currentYear: 189,
currentMonth: 3,
tickSeconds: 600,
lastTurnTime: mockDate,
meta: { seed: 1, killturn: 100 },
};
const reservedTurnStore = new InMemoryReservedTurnStore(createMockPrisma() as any, {
maxGeneralTurns: 10,
maxNationTurns: 10,
});
await reservedTurnStore.loadAll();
const wrapper = { world: null as InMemoryTurnWorld | null };
const handler = await createReservedTurnHandler({
reservedTurns: reservedTurnStore,
scenarioConfig: snapshot.scenarioConfig,
scenarioMeta: snapshot.scenarioMeta,
map: MINIMAL_MAP as any,
unitSet: snapshot.unitSet,
getWorld: () => wrapper.world,
});
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: handler,
});
wrapper.world = world;
world.executeGeneralTurn(ruler);
expect(world.getGeneralById(user.id)).toMatchObject({
officerLevel: 11,
meta: expect.objectContaining({ officer_city: 0, permission: 'ambassador' }),
});
expect(world.getNationById(nation.id)?.meta).toMatchObject({ chief_set: 1 << 11 });
});
});