From c4c4ee6e0fd78b4e0631c2d0ef87dd6c8e0cb2f2 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 4 Aug 2026 13:30:51 +0000 Subject: [PATCH] fix: restore legacy gameplay messages and record details --- app/game-api/src/router/general/index.ts | 27 +++++++++-- app/game-api/src/router/vote/index.ts | 15 ++++-- .../test/inGameMenuPermissions.test.ts | 8 ++-- app/game-api/test/mainRecordsRouter.test.ts | 8 ++-- app/game-api/test/voteRouter.test.ts | 12 +++++ app/game-engine/src/turn/ai/generalAi/core.ts | 46 ++++++++++++++----- .../src/turn/reservedTurnHandler.ts | 25 +++++++++- .../test/npcGeneralDomesticTurn.test.ts | 11 ++++- app/game-engine/test/npcMessage.test.ts | 20 ++++++++ .../common/src/logging/formatLegacyLogHtml.ts | 1 + .../test/format-legacy-log-html.test.ts | 3 ++ packages/logic/src/war/engine.ts | 2 +- 12 files changed, 147 insertions(+), 31 deletions(-) create mode 100644 app/game-engine/test/npcMessage.test.ts diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index c66911b..01256fb 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -230,6 +230,12 @@ export const generalRouter = router({ injury: true, experience: true, dedication: true, + age: true, + turnTime: true, + crewTypeId: true, + personalCode: true, + specialCode: true, + special2Code: true, weaponCode: true, horseCode: true, bookCode: true, @@ -309,6 +315,19 @@ export const generalRouter = router({ injury: general.injury, experience: general.experience, dedication: general.dedication, + age: general.age, + turnTime: general.turnTime.toISOString(), + crewTypeId: general.crewTypeId, + traits: { + personal: general.personalCode, + specialWar: general.specialCode, + specialDomestic: general.special2Code, + }, + progression: { + experienceLevel: readNumber(metaRecord.explevel, 0), + dedicationLevel: readNumber(metaRecord.dedlevel, 0), + dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)), + }, items: { horse: normalizeItemCode(general.horseCode), weapon: normalizeItemCode(general.weaponCode), @@ -468,12 +487,12 @@ export const generalRouter = router({ ctx.db.logEntry.findMany({ where: { scope: LogScope.SYSTEM, - category: LogCategory.SUMMARY, + category: { in: [LogCategory.SUMMARY, LogCategory.ACTION] }, id: { gte: input.lastGeneralRecordId }, }, orderBy: { id: 'desc' }, take, - select: { id: true, text: true }, + select: { id: true, text: true, createdAt: true }, }), ctx.db.logEntry.findMany({ where: { @@ -484,7 +503,7 @@ export const generalRouter = router({ }, orderBy: { id: 'desc' }, take, - select: { id: true, text: true }, + select: { id: true, text: true, createdAt: true }, }), ctx.db.logEntry.findMany({ where: { @@ -494,7 +513,7 @@ export const generalRouter = router({ }, orderBy: { id: 'desc' }, take, - select: { id: true, text: true }, + select: { id: true, text: true, createdAt: true }, }), ]); diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index 1ea73de..2905b46 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -186,9 +186,14 @@ const zRevealMode = z.enum(['after_vote', 'after_end']); export const voteRouter = router({ getVoteList: authedProcedure.query(async ({ ctx }) => { const worldState = await ctx.db.worldState.findFirst(); + const worldMeta = asRecord(worldState?.meta ?? {}); const config = asRecord(worldState?.config ?? {}); const constValues = asRecord(config.const); - const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0); + const develCost = resolveNumber( + worldMeta, + ['develcost', 'develCost'], + resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0) + ); const voteReward = develCost * 5; const rows = await ctx.db.$queryRaw(GamePrisma.sql` @@ -404,12 +409,16 @@ export const voteRouter = router({ throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); } + const worldMeta = asRecord(worldState.meta); const config = asRecord(worldState.config); const constValues = asRecord(config.const); - const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0); + const develCost = resolveNumber( + worldMeta, + ['develcost', 'develCost'], + resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0) + ); const voteReward = develCost * 5; - const worldMeta = asRecord(worldState.meta); const scenarioMeta = asRecord(worldMeta.scenarioMeta); const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear); const initYear = readMetaNumber(worldMeta, 'initYear', startYear); diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index a1bb7e3..0479625 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -241,10 +241,10 @@ describe('in-game my information ownership', () => { expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith( 1, expect.objectContaining({ - where: { scope: 'SYSTEM', category: 'SUMMARY', id: { gte: 0 } }, + where: { scope: 'SYSTEM', category: { in: ['SUMMARY', 'ACTION'] }, id: { gte: 0 } }, orderBy: { id: 'desc' }, take: 16, - select: { id: true, text: true }, + select: { id: true, text: true, createdAt: true }, }) ); expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith( @@ -253,7 +253,7 @@ describe('in-game my information ownership', () => { where: { scope: 'GENERAL', category: 'ACTION', generalId: 7, id: { gte: 0 } }, orderBy: { id: 'desc' }, take: 16, - select: { id: true, text: true }, + select: { id: true, text: true, createdAt: true }, }) ); expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith( @@ -262,7 +262,7 @@ describe('in-game my information ownership', () => { where: { scope: 'SYSTEM', category: 'HISTORY', id: { gte: 0 } }, orderBy: { id: 'desc' }, take: 16, - select: { id: true, text: true }, + select: { id: true, text: true, createdAt: true }, }) ); }); diff --git a/app/game-api/test/mainRecordsRouter.test.ts b/app/game-api/test/mainRecordsRouter.test.ts index 157353d..a53923d 100644 --- a/app/game-api/test/mainRecordsRouter.test.ts +++ b/app/game-api/test/mainRecordsRouter.test.ts @@ -24,13 +24,13 @@ const auth: GameSessionTokenPayload = { type LogQuery = { where: { scope: LogScope; - category: LogCategory; + category: LogCategory | { in: LogCategory[] }; generalId?: number; id: { gte: number }; }; orderBy: { id: 'desc' }; take: number; - select: { id: true; text: true }; + select: { id: true; text: true; createdAt: true }; }; const buildContext = (findMany: (query: LogQuery) => Promise>) => @@ -56,7 +56,7 @@ describe('general.getRecentRecords', () => { { id: 20, text: '개인 cursor' }, ]; } - if (query.where.category === LogCategory.SUMMARY) { + if (typeof query.where.category === 'object' && query.where.category.in.includes(LogCategory.SUMMARY)) { return [ { id: 32, text: '장수 최신' }, { id: 20, text: '장수 cursor' }, @@ -89,7 +89,7 @@ describe('general.getRecentRecords', () => { }, orderBy: { id: 'desc' }, take: 16, - select: { id: true, text: true }, + select: { id: true, text: true, createdAt: true }, }); }); diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts index 69ba616..7568010 100644 --- a/app/game-api/test/voteRouter.test.ts +++ b/app/game-api/test/voteRouter.test.ts @@ -92,6 +92,7 @@ const buildContext = (options: { voteRows?: Array<{ selection: number[]; cnt: number }>; pollRow?: typeof poll; configConst?: Record; + metaDevelCost?: number; auctionTargets?: string[]; }) => { const auth = options.auth === undefined ? buildAuth() : options.auth; @@ -136,6 +137,7 @@ const buildContext = (options: { tickSeconds: 3600, config: { const: { develCost: 18, allItems: {}, ...(options.configConst ?? {}) } }, meta: { + ...(options.metaDevelCost === undefined ? {} : { develcost: options.metaDevelCost }), hiddenSeed: 'seed', scenarioId: 200, initYear: 180, @@ -216,6 +218,16 @@ describe('vote router actor and permission boundaries', () => { ); }); + it('uses the current world develcost for the legacy five-times survey reward', async () => { + const fixture = buildContext({ metaDevelCost: 30, configConst: { develCost: 0 } }); + + await expect(appRouter.createCaller(fixture.context).vote.getVoteList()).resolves.toMatchObject({ + voteReward: 150, + }); + await appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] }); + expect(fixture.requestCommand).toHaveBeenCalledWith(expect.objectContaining({ goldReward: 150 })); + }); + it('includes active unique auctions in the API-side reward expectation', async () => { const fixture = buildContext({ configConst: { diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index d3a3310..e75a92c 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -50,6 +50,16 @@ const d징병 = 2; const d직전 = 3; const d전쟁 = 4; +export const selectNpcMessageForTurn = ( + message: unknown, + rng: Pick, + 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, 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 }; diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index ae75706..cf9cca0 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -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 || diff --git a/app/game-engine/test/npcGeneralDomesticTurn.test.ts b/app/game-engine/test/npcGeneralDomesticTurn.test.ts index cbdd1b1..769cd8e 100644 --- a/app/game-engine/test/npcGeneralDomesticTurn.test.ts +++ b/app/game-engine/test/npcGeneralDomesticTurn.test.ts @@ -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 }), + }) + ); }); }); diff --git a/app/game-engine/test/npcMessage.test.ts b/app/game-engine/test/npcMessage.test.ts new file mode 100644 index 0000000..75be676 --- /dev/null +++ b/app/game-engine/test/npcMessage.test.ts @@ -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(); + }); +}); diff --git a/packages/common/src/logging/formatLegacyLogHtml.ts b/packages/common/src/logging/formatLegacyLogHtml.ts index 72a4122..0af8793 100644 --- a/packages/common/src/logging/formatLegacyLogHtml.ts +++ b/packages/common/src/logging/formatLegacyLogHtml.ts @@ -33,6 +33,7 @@ const safeSpanClasses = new Set([ 'war_type_attack', 'war_type_defense', 'war_type_siege', + 'hidden_but_copyable', ]); const escapeText = (value: string): string => diff --git a/packages/common/test/format-legacy-log-html.test.ts b/packages/common/test/format-legacy-log-html.test.ts index 09d7fad..807ff99 100644 --- a/packages/common/test/format-legacy-log-html.test.ts +++ b/packages/common/test/format-legacy-log-html.test.ts @@ -39,6 +39,9 @@ describe('formatLegacyLogHtml', () => { expect(formatLegacyLogHtml('미허용')).toBe( '<span class="unknown">미허용' ); + expect(formatLegacyLogHtml('(전투시드: fixed-seed)')).toBe( + '(전투시드: fixed-seed)' + ); }); it('keeps the fixed hex color form emitted by flag-change logs but rejects other inline CSS', () => { diff --git a/packages/logic/src/war/engine.ts b/packages/logic/src/war/engine.ts index 22c2723..bd7052c 100644 --- a/packages/logic/src/war/engine.ts +++ b/packages/logic/src/war/engine.ts @@ -381,7 +381,7 @@ export const resolveWarBattle = (전투시드: ${input.seed})` : ''; const josaRo = JosaUtil.pick(cityName, '로'); const josaYi = JosaUtil.pick(attackerName, '이');