From b7b1785b8caf12d8f77e909ee911a8561dc5576e Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 06:06:07 +0000 Subject: [PATCH] fix(turn): preserve seizure NPC public messages --- app/game-api/src/turns/commandTable.ts | 1 + .../src/turn/reservedTurnCommands.ts | 2 + packages/logic/src/actions/turn/commandEnv.ts | 1 + .../logic/src/actions/turn/nation/che_몰수.ts | 63 ++++++++++++++++++- .../src/turn-differential/coreCommandTrace.ts | 1 + ...urnCommandNationMatrix.integration.test.ts | 46 ++++++++++++++ 6 files changed, 113 insertions(+), 1 deletion(-) diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index f91041d..2e5c85b 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -205,6 +205,7 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0), generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0), generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500), + npcSeizureMessageProb: resolveNumber(constValues, ['npcSeizureMessageProb'], 0.01), maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0), }; }; diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index b3d5691..142eec8 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -37,6 +37,7 @@ const DEFAULT_BASE_GOLD = 0; const DEFAULT_BASE_RICE = 2000; const DEFAULT_GENERAL_MINIMUM_GOLD = 0; const DEFAULT_GENERAL_MINIMUM_RICE = 500; +const DEFAULT_NPC_SEIZURE_MESSAGE_PROB = 0.01; const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000; const normalizeCode = (value: string | null | undefined): string | null => { @@ -136,6 +137,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE), generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], DEFAULT_GENERAL_MINIMUM_GOLD), generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], DEFAULT_GENERAL_MINIMUM_RICE), + npcSeizureMessageProb: resolveNumber(constValues, ['npcSeizureMessageProb'], DEFAULT_NPC_SEIZURE_MESSAGE_PROB), maxResourceActionAmount: resolveNumber( constValues, ['maxResourceActionAmount'], diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index 01c8ebc..62f6814 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -53,6 +53,7 @@ export interface TurnCommandEnv { baseRice: number; generalMinimumGold?: number; generalMinimumRice?: number; + npcSeizureMessageProb?: number; maxResourceActionAmount: number; itemCatalog?: Record; generalActionModules?: Array; diff --git a/packages/logic/src/actions/turn/nation/che_몰수.ts b/packages/logic/src/actions/turn/nation/che_몰수.ts index 55e186e..00d3b97 100644 --- a/packages/logic/src/actions/turn/nation/che_몰수.ts +++ b/packages/logic/src/actions/turn/nation/che_몰수.ts @@ -15,7 +15,12 @@ import type { GeneralActionOutcome, GeneralActionResolveContext, } from '@sammo-ts/logic/actions/engine.js'; -import { createLogEffect, createNationPatchEffect, createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js'; +import { + createGeneralPatchEffect, + createLogEffect, + createMessageEffect, + createNationPatchEffect, +} from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { JosaUtil } from '@sammo-ts/common'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; @@ -37,9 +42,40 @@ export interface SeizureResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, > extends GeneralActionResolveContext { destGeneral: General; + messageTime: Date; } const ACTION_NAME = '몰수'; +const NPC_SEIZURE_MESSAGE_PROB = 0.01; +const NPC_SEIZURE_MESSAGES = [ + '몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...', + '사유재산까지 몰수해가면서 이 나라가 잘 될거라 믿습니까? 정말 이해할 수가 없군요...', + '내 돈 내놔라! 내 돈! 몰수가 웬 말이냐!', + '몰수해간 내 자금... 언젠가 몰래 다시 빼내올 것이다...', + '몰수로 인한 사기 저하는 몰수로 얻은 물자보다 더 손해란걸 모른단 말인가!', +] as const; + +type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & { + nextIntInclusive?: (maxInclusive: number) => number; +}; + +const pickLegacyNpcMessage = (rng: GeneralActionResolveContext['rng']): string => { + const inclusive = rng as InclusiveRandomGenerator; + const index = inclusive.nextIntInclusive + ? inclusive.nextIntInclusive(NPC_SEIZURE_MESSAGES.length - 1) + : rng.nextInt(0, NPC_SEIZURE_MESSAGES.length); + return NPC_SEIZURE_MESSAGES[index]!; +}; + +const resolveGeneralIcon = (general: General): string => { + const runtimePicture = (general as General & { picture?: unknown }).picture; + const rawPicture = runtimePicture ?? general.meta.picture; + const picture = + (typeof rawPicture === 'string' && rawPicture !== '') || typeof rawPicture === 'number' + ? String(rawPicture) + : 'default.jpg'; + return `/image/icons/${picture}`; +}; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -149,6 +185,30 @@ export class ActionDefinition< }), ]; + if ( + destGeneral.npcState >= 2 && + context.rng.nextBool(this.env.npcSeizureMessageProb ?? NPC_SEIZURE_MESSAGE_PROB) + ) { + const target = { + generalId: destGeneral.id, + generalName: destGeneral.name, + nationId: nation.id, + nationName: nation.name, + color: nation.color, + icon: resolveGeneralIcon(destGeneral), + }; + effects.push( + createMessageEffect({ + msgType: 'public', + src: target, + dest: target, + text: pickLegacyNpcMessage(context.rng), + time: context.messageTime, + validUntil: new Date('9999-12-31T00:00:00.000Z'), + }) + ); + } + return { effects }; } } @@ -166,6 +226,7 @@ export const actionContextBuilder: ActionContextBuilder = (base, op return { ...base, destGeneral, + messageTime: base.general.turnTime, }; }; diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 8035dce..52588b5 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -350,6 +350,7 @@ const buildWorldInput = ( baseRice: 2_000, generalMinimumGold: 0, generalMinimumRice: 500, + npcSeizureMessageProb: 0.01, maxResourceActionAmount: 10_000, maxTechLevel: 12, maxLevel: 255, diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index e2566a1..545f654 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -12,6 +12,7 @@ const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRo const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); const readGold = (row: { gold?: unknown } | undefined): number => (typeof row?.gold === 'number' ? row.gold : 0); +const NPC_SEIZURE_MESSAGE_TEXT = '몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...'; const ignoredLifecyclePaths = [ /^generalTurns/, @@ -627,3 +628,48 @@ integration('nation command resource balance and target boundaries', () => { 120_000 ); }); + +integration('nation seizure NPC public message parity', () => { + it('matches the legacy fixed-seed RNG and public message side effect', async () => { + const request = buildRequest( + 'che_몰수', + { isGold: true, amount: 100, destGeneralID: 3 }, + { + world: { hiddenSeed: 'seizure-message-37' }, + generals: { 3: { name: '몰수NPC', npcState: 2 } }, + } + ); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(reference.rng).toHaveLength(2); + expect(reference.rng.map((call) => call.operation)).toEqual(['nextFloat1', 'nextInt']); + expect(core.rng).toEqual(reference.rng); + const referenceMessages = reference.after.messages.slice(reference.before.messages.length); + expect(referenceMessages).toHaveLength(1); + expect(core.after.messages).toHaveLength(1); + expect(referenceMessages[0]).toMatchObject({ + mailbox: 9999, + type: 'public', + sourceId: 3, + destinationId: 9999, + payload: { + src: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' }, + dest: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' }, + text: NPC_SEIZURE_MESSAGE_TEXT, + }, + }); + expect(core.after.messages[0]).toMatchObject({ + payload: { + msgType: 'public', + src: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' }, + dest: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' }, + text: NPC_SEIZURE_MESSAGE_TEXT, + }, + }); + }, 120_000); +});