fix: restore legacy gameplay messages and record details

This commit is contained in:
2026-08-04 13:30:51 +00:00
parent 8b8a285bc7
commit c4c4ee6e0f
12 changed files with 147 additions and 31 deletions
+34 -12
View File
@@ -50,6 +50,16 @@ const d징병 = 2;
const d직전 = 3;
const d전쟁 = 4;
export const selectNpcMessageForTurn = (
message: unknown,
rng: Pick<RandUtil, 'nextBool'>,
frequencyPerDay: number,
turnTermMinutes: number
): string | null => {
if (!message) return null;
return rng.nextBool((frequencyPerDay * turnTermMinutes) / (60 * 24)) ? String(message) : null;
};
export const resolveLegacyAiStats = (
general: Pick<TurnGeneral, 'injury' | 'officerLevel' | 'stats'>,
nation: Nation | null | undefined,
@@ -97,6 +107,7 @@ export class GeneralAI {
public readonly env: ConstraintEnv;
public readonly startYear: number;
public readonly turnTermMinutes: number;
private pendingNpcMessage: string | null = null;
public readonly aiConst: {
baseGold: number;
@@ -213,9 +224,16 @@ export class GeneralAI {
return (...args: unknown[]) => {
const result = Reflect.apply(value, receiver, args);
if (
['nextFloat1', 'nextRangeInt', 'nextInt', 'nextBit', 'nextBool', 'choice', 'choiceUsingWeight', 'choiceUsingWeightPair'].includes(
String(property)
)
[
'nextFloat1',
'nextRangeInt',
'nextInt',
'nextBit',
'nextBool',
'choice',
'choiceUsingWeight',
'choiceUsingWeightPair',
].includes(String(property))
) {
process.stdout.write(
`AI_RNG_TRACE ${JSON.stringify({
@@ -330,11 +348,7 @@ export class GeneralAI {
// Ref refreshes the cached AI state after these selected nation
// commands, before choosing the general command with the same
// RNG. The refresh includes another mixed-general type draw.
if (
['유저장긴급포상', 'NPC긴급포상', '선전포고', '천도'].includes(
actionName
)
) {
if (['유저장긴급포상', 'NPC긴급포상', '선전포고', '천도'].includes(actionName)) {
this.reqUpdateInstance = true;
}
return result;
@@ -377,6 +391,12 @@ export class GeneralAI {
return { set, unset };
}
consumeNpcMessage(): string | null {
const message = this.pendingNpcMessage;
this.pendingNpcMessage = null;
return message;
}
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
this.updateInstance();
if (!this.worldRef) {
@@ -384,10 +404,12 @@ export class GeneralAI {
}
const generalMeta = asRecord(this.general.meta);
const npcMessage = generalMeta.npcmsg ?? generalMeta.text;
if (npcMessage && this.rng.nextBool((this.aiConst.npcMessageFreqByDay * this.turnTermMinutes) / (60 * 24))) {
// 메시지 영속화는 turn handler가 담당한다. 여기서는 레거시와 같은 RNG 소비를 보존한다.
}
this.pendingNpcMessage = selectNpcMessageForTurn(
generalMeta.npcmsg ?? generalMeta.text,
this.rng,
this.aiConst.npcMessageFreqByDay,
this.turnTermMinutes
);
if (this.general.npcState >= 2) {
this.general.meta = { ...this.general.meta, defence_train: 80 };
@@ -1048,7 +1048,10 @@ export const createReservedTurnHandler = async (options: {
}
const actionContext = specificContext ?? baseContext;
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) {
const tracedContext = actionContext as ActionContextBase & { destCity?: City; destGeneral?: TurnGeneral };
const tracedContext = actionContext as ActionContextBase & {
destCity?: City;
destGeneral?: TurnGeneral;
};
process.stdout.write(
`AI_ACTION_INPUT_TRACE ${JSON.stringify({ generalId: currentGeneral.id, kind, actionKey, actionArgs, destCityId: tracedContext.destCity?.id, destGeneralId: tracedContext.destGeneral?.id })}\n`
);
@@ -1731,6 +1734,26 @@ export const createReservedTurnHandler = async (options: {
nationFallback,
});
const candidate = ai.chooseGeneralTurn(generalCommand);
const npcMessage = ai.consumeNpcMessage();
if (npcMessage) {
const messageTarget = {
generalId: currentGeneral.id,
generalName: currentGeneral.name,
nationId: currentGeneral.nationId,
nationName: currentNation?.name ?? '재야',
color: currentNation?.color ?? '#000000',
icon: currentGeneral.picture ?? '',
};
messages.push({
msgType: 'public',
src: messageTarget,
dest: messageTarget,
text: npcMessage,
time: new Date(context.world.lastTurnTime),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
option: {},
});
}
if (candidate) {
generalAutorunMode =
candidate.action !== generalCommand.action ||
@@ -117,7 +117,7 @@ describe('NPC 일반 내정 턴', () => {
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 999 },
meta: { killturn: 999, text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다' },
officerLevel: 4,
experience: 0,
dedication: 0,
@@ -222,7 +222,7 @@ describe('NPC 일반 내정 턴', () => {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
const: { npcMessageFreqByDay: 144 },
environment: { mapName: 'npc_domestic_map', unitSet: 'default' },
},
scenarioMeta: {
@@ -291,5 +291,12 @@ describe('NPC 일반 내정 턴', () => {
security: 1063,
});
expect(world.getGeneralById(1)!.turnTime.getTime()).toBe(addMinutes(mockDate, 10).getTime());
expect(world.peekDirtyState().messages).toContainEqual(
expect.objectContaining({
msgType: 'public',
text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다',
src: expect.objectContaining({ generalId: 1, generalName: 'NPC_무장', nationId: 1 }),
})
);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from 'vitest';
import { selectNpcMessageForTurn } from '../src/turn/ai/generalAi/core.js';
describe('legacy NPC public chatter', () => {
it('uses the per-turn legacy probability and returns the scenario text', () => {
const nextBool = vi.fn(() => true);
expect(selectNpcMessageForTurn('기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다', { nextBool }, 2, 10)).toBe(
'기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다'
);
expect(nextBool).toHaveBeenCalledWith(2 / 144);
});
it('does not consume RNG when a scenario NPC has no message', () => {
const nextBool = vi.fn(() => true);
expect(selectNpcMessageForTurn(null, { nextBool }, 2, 10)).toBeNull();
expect(nextBool).not.toHaveBeenCalled();
});
});