diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index d07a57a..b3ab6f3 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -32,12 +32,13 @@ import { resolveUniqueConfig, rollUniqueLottery, getNextTurnAt, + getBillByLevel, type ItemModule, type UniqueLotteryRunner, } from '@sammo-ts/logic'; import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; -import { asRecord, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { asRecord, JosaUtil, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import type { ConstraintContext, StateView } from '@sammo-ts/logic'; @@ -105,7 +106,8 @@ const applyLegacyGeneralProgression = ( general: TurnGeneral, previousGeneral: TurnGeneral, actionKey: string, - env: TurnCommandEnv + env: TurnCommandEnv, + logs: LogEntryDraft[] ): TurnGeneral => { const maxStatLevel = env.maxStatLevel ?? 255; const maxDedicationLevel = env.maxDedicationLevel ?? 30; @@ -126,10 +128,40 @@ const applyLegacyGeneralProgression = ( const forceRefreshLevel = actionKey === 'che_하야'; const preserveLevel = actionKey === 'che_은퇴' || actionKey === 'che_선양'; if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) { + const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0); meta.explevel = expLevel; + if (expLevel !== previousExpLevel) { + const josaRo = JosaUtil.pick(String(expLevel), '로'); + logs.push({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + text: + expLevel > previousExpLevel + ? `Lv ${expLevel}${josaRo} 레벨업!` + : `Lv ${expLevel}${josaRo} 레벨다운!`, + }); + } } if (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) { + const previousDedicationLevel = readMetaNumber(previousGeneral.meta, 'dedlevel', 0); meta.dedlevel = dedicationLevel; + if (dedicationLevel !== previousDedicationLevel) { + const dedicationLevelText = + dedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - dedicationLevel + 1}품관`; + const billText = getBillByLevel(dedicationLevel).toLocaleString('en-US'); + const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로'); + const josaRoBill = JosaUtil.pick(billText, '로'); + logs.push({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + text: + dedicationLevel > previousDedicationLevel + ? `${dedicationLevelText}${josaRoDedication} 승급하여 봉록이 ${billText}${josaRoBill} 상승했습니다!` + : `${dedicationLevelText}${josaRoDedication} 강등되어 봉록이 ${billText}${josaRoBill} 하락했습니다!`, + }); + } } if (!LEGACY_STAT_CHANGE_GENERAL_ACTIONS.has(actionKey)) { @@ -935,6 +967,16 @@ export const createReservedTurnHandler = async (options: { general: currentGeneral, city: currentCity, nation: currentNation, + ...(worldView + ? { + worldView: { + listGenerals: () => worldView.listGenerals(), + listGeneralsByCity: (cityId: number) => + worldView.listGenerals().filter((general) => general.cityId === cityId), + listNations: () => worldView.listNations(), + }, + } + : {}), rng: actionRng, uniqueLottery, }; @@ -1059,7 +1101,8 @@ export const createReservedTurnHandler = async (options: { currentGeneral, generalBeforeExecution, actionKey, - env + env, + logs ); } if ( diff --git a/app/game-engine/test/generalTurnLifecycle.test.ts b/app/game-engine/test/generalTurnLifecycle.test.ts index 107e121..15b4389 100644 --- a/app/game-engine/test/generalTurnLifecycle.test.ts +++ b/app/game-engine/test/generalTurnLifecycle.test.ts @@ -159,6 +159,29 @@ const makeState = (meta: Record = {}): TurnWorldState => ({ }); describe('legacy general turn lifecycle', () => { + it('emits legacy plain logs when command gains cross experience and dedication levels', async () => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([ + makeGeneral({ + injury: 10, + experience: 995, + dedication: 899, + meta: { killturn: 24, explevel: 9, dedlevel: 3 }, + }), + ]), + state: makeState(), + schedule, + map, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_요양', args: {} }; + + await harness.runOneTick(); + + const logTexts = harness.world.peekDirtyState().logs.map((log) => log.text); + expect(logTexts).toContain('Lv 10으로 레벨업!'); + expect(logTexts).toContain('27품관으로 승급하여 봉록이 1,200으로 상승했습니다!'); + }); + it('runs preprocess before commands and restores crew population when rice is insufficient', async () => { const harness = await createTurnTestHarness({ snapshot: makeSnapshot([makeGeneral({ injury: 25, crew: 200, rice: 1 })]), diff --git a/packages/logic/src/actions/turn/actionContext.ts b/packages/logic/src/actions/turn/actionContext.ts index 9a07b34..4c38ac6 100644 --- a/packages/logic/src/actions/turn/actionContext.ts +++ b/packages/logic/src/actions/turn/actionContext.ts @@ -3,6 +3,7 @@ import type { UniqueLotteryRunner } from '@sammo-ts/logic/rewards/uniqueLottery. import type { ScenarioConfig } from '@sammo-ts/logic/scenario/types.js'; import type { ScenarioMeta } from '@sammo-ts/logic/world/types.js'; import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; +import type { GeneralWorldView } from '@sammo-ts/logic/triggers/general.js'; export interface ActionRandomSource { nextFloat1(): number; @@ -18,6 +19,7 @@ export type ActionContextBase = { general: ActionContextGeneral; city?: City; nation?: Nation | null; + worldView?: GeneralWorldView; rng: ActionRandomSource; uniqueLottery?: UniqueLotteryRunner; }; diff --git a/packages/logic/src/actions/turn/nation/che_국기변경.ts b/packages/logic/src/actions/turn/nation/che_국기변경.ts index fd4d05e..a15b5c3 100644 --- a/packages/logic/src/actions/turn/nation/che_국기변경.ts +++ b/packages/logic/src/actions/turn/nation/che_국기변경.ts @@ -53,12 +53,28 @@ const NATION_COLORS = [ '#A9A9A9', ]; +const resolveNationColorIndex = (value: number | string | boolean): number | null => { + let index: number; + if (typeof value === 'boolean') { + index = value ? 1 : 0; + } else if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return null; + } + index = Math.trunc(value); + } else { + if (!/^(?:0|[1-9]\d*|-[1-9]\d*)$/.test(value)) { + return null; + } + index = Number(value); + } + return Number.isSafeInteger(index) && index >= 0 && index < NATION_COLORS.length ? index : null; +}; + const ARGS_SCHEMA = z.object({ colorType: z - .number() - .int() - .min(0) - .max(NATION_COLORS.length - 1), + .union([z.number(), z.string(), z.boolean()]) + .refine((value) => resolveNationColorIndex(value) !== null), }); export type ChangeFlagArgs = z.infer; @@ -100,7 +116,11 @@ export class ActionDefinition< return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] }; } - const color = NATION_COLORS[args.colorType]; + const colorIndex = resolveNationColorIndex(args.colorType); + if (colorIndex === null) { + return { effects: [] }; + } + const color = NATION_COLORS[colorIndex]; const generalName = general.name; const nationName = nation.name; @@ -120,11 +140,11 @@ export class ActionDefinition< ), // Global Action Log createLogEffect( - `${generalName}${josaYi} 국기를 변경하였습니다.`, + `${generalName}${josaYi} 국기를 변경하였습니다`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, } ), // Global History Log @@ -138,7 +158,7 @@ export class ActionDefinition< ), // Actor Nation History Log createLogEffect( - `${generalName}${josaYi} 국기를 변경하였습니다.`, + `${generalName}${josaYi} 국기를 변경하였습니다`, { scope: LogScope.NATION, nationId: nation.id, diff --git a/packages/logic/src/actions/turn/nation/che_국호변경.ts b/packages/logic/src/actions/turn/nation/che_국호변경.ts index 3c1a540..d6a2693 100644 --- a/packages/logic/src/actions/turn/nation/che_국호변경.ts +++ b/packages/logic/src/actions/turn/nation/che_국호변경.ts @@ -14,9 +14,17 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import type { NationTurnCommandSpec } from './index.js'; import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js'; const ARGS_SCHEMA = z.object({ - nationName: z.string().trim().min(1).max(8), + nationName: z.string().superRefine((nationName, ctx) => { + if (nationName === '' || getLegacyStringWidth(nationName) > 18) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: '국호는 전각 9자 또는 반각 18자 이하여야 합니다.', + }); + } + }), }); export type ChangeNationNameArgs = z.infer; @@ -65,8 +73,21 @@ export class ActionDefinition< const oldNationName = nation.name; const newNationName = args.nationName; + if (context.worldView?.listNations?.().some((candidate) => candidate.name === newNationName)) { + return { + completed: false, + effects: [ + createLogEffect(`이미 같은 국호를 가진 곳이 있습니다. ${ACTION_NAME} 실패`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }), + ], + }; + } + const josaYi = JosaUtil.pick(generalName, '이'); - const josaYiNation = JosaUtil.pick(newNationName, '이'); + const josaYiNation = JosaUtil.pick(oldNationName, '이'); const josaRo = JosaUtil.pick(newNationName, '로'); const effects: Array> = [ @@ -83,8 +104,8 @@ export class ActionDefinition< // Global Action Log createLogEffect(`${generalName}${josaYi} 국호를 ${newNationName}${josaRo} 변경합니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, }), // Global History Log createLogEffect( diff --git a/packages/logic/src/triggers/general.ts b/packages/logic/src/triggers/general.ts index 1c2bddc..c69dd81 100644 --- a/packages/logic/src/triggers/general.ts +++ b/packages/logic/src/triggers/general.ts @@ -6,6 +6,7 @@ import { TriggerCaller, type Trigger } from './core.js'; export interface GeneralWorldView { listGenerals(): General[]; listGeneralsByCity?(cityId: number): General[]; + listNations?(): Nation[]; } export interface GeneralActionLogSink { diff --git a/packages/logic/test/actions/turn/nation.test.ts b/packages/logic/test/actions/turn/nation.test.ts index 4b0e833..c0483c6 100644 --- a/packages/logic/test/actions/turn/nation.test.ts +++ b/packages/logic/test/actions/turn/nation.test.ts @@ -3,7 +3,14 @@ import type { City, General, Nation } from '../../../src/domain/entities.js'; import { resolveGeneralAction } from '../../../src/actions/engine.js'; import { ActionDefinition as DeclareWarAction } from '../../../src/actions/turn/nation/che_선전포고.js'; import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn/nation/che_천도.js'; -import { ActionDefinition as ChangeNationNameAction } from '../../../src/actions/turn/nation/che_국호변경.js'; +import { + ActionDefinition as ChangeNationNameAction, + commandSpec as changeNationNameCommandSpec, +} from '../../../src/actions/turn/nation/che_국호변경.js'; +import { + ActionDefinition as ChangeNationFlagAction, + commandSpec as changeNationFlagCommandSpec, +} from '../../../src/actions/turn/nation/che_국기변경.js'; import { ActionDefinition as ExpandCityAction } from '../../../src/actions/turn/nation/che_증축.js'; import { ActionDefinition as LastStandAction } from '../../../src/actions/turn/nation/che_필사즉생.js'; import { ActionDefinition as DeceptionAction } from '../../../src/actions/turn/nation/che_허보.js'; @@ -97,6 +104,8 @@ const buildNation = (id: number, name = 'Nation'): Nation => ({ const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 60 }], }; +const changeNationNameArgsSchema = changeNationNameCommandSpec.argsSchema!; +const changeNationFlagArgsSchema = changeNationFlagCommandSpec.argsSchema!; describe('Nation Actions', () => { describe('che_선전포고 (Declare War)', () => { @@ -312,6 +321,12 @@ describe('Nation Actions', () => { }); describe('che_국호변경 (Change Nation Name)', () => { + it('uses the legacy display width without trimming the name', () => { + expect(changeNationNameArgsSchema.safeParse({ nationName: '가나다라마바사아자' }).success).toBe(true); + expect(changeNationNameArgsSchema.safeParse({ nationName: ' ' }).success).toBe(true); + expect(changeNationNameArgsSchema.safeParse({ nationName: '가나다라마바사아자차' }).success).toBe(false); + }); + it('changes nation name', () => { const nation = buildNation(1, 'OldName'); const general = buildGeneral(1, 1, 1); @@ -333,6 +348,75 @@ describe('Nation Actions', () => { }) ); }); + + it('returns a failed original action when any nation already has the name', () => { + const nation = buildNation(1, 'OldName'); + const duplicateNation = buildNation(2, 'Duplicate'); + const general = buildGeneral(1, 1, 1); + const definition = new ChangeNationNameAction(); + + const resolution = definition.resolve( + { + general, + nation, + worldView: { + listGenerals: () => [general], + listNations: () => [nation, duplicateNation], + }, + rng: {} as any, + addLog: () => {}, + }, + { nationName: duplicateNation.name } + ); + + expect(resolution.completed).toBe(false); + expect(resolution.effects).toContainEqual( + expect.objectContaining({ + type: 'log', + entry: expect.objectContaining({ + text: '이미 같은 국호를 가진 곳이 있습니다. 국호변경 실패', + }), + }) + ); + }); + }); + + describe('che_국기변경 (Change Nation Flag)', () => { + it('accepts the same scalar array keys as PHP and preserves the raw argument', () => { + for (const colorType of [0, '1', true, false, 1.9, -0.9, 32.9]) { + const parsed = changeNationFlagArgsSchema.safeParse({ colorType }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.colorType).toBe(colorType); + } + } + for (const colorType of ['01', '1.5', 33, -1, null]) { + expect(changeNationFlagArgsSchema.safeParse({ colorType }).success).toBe(false); + } + }); + + it('resolves a numeric string to the matching legacy color', () => { + const nation = buildNation(1); + const general = buildGeneral(1, 1, 1); + const definition = new ChangeNationFlagAction(); + + const resolution = definition.resolve( + { + general, + nation, + rng: {} as any, + addLog: () => {}, + }, + { colorType: '1' } + ); + + expect(resolution.effects).toContainEqual( + expect.objectContaining({ + type: 'nation:patch', + patch: expect.objectContaining({ color: '#800000' }), + }) + ); + }); }); describe('che_증축 (City Expansion)', () => { diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index 1e74f4d..ecc3b2f 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -25,6 +25,27 @@ const readNationMeta = (row: { meta?: unknown } | undefined): Record + String(value) + .replace(/^(?:●<\/>|◆<\/>|★<\/>)(?:(?:\d+년 )?\d+월:|\d+년:)?/, '') + .replace(/ <1>\d{2}:\d{2}<\/>$/, ''); + +const semanticLogSignatures = (logs: Array>): string[] => + logs + .map((entry) => + JSON.stringify({ + scope: String(entry.scope).toLowerCase(), + category: String(entry.category).toLowerCase(), + generalId: Number(entry.generalId) || null, + nationId: Number(entry.nationId) || null, + text: normalizeStoredLogText(entry.text), + }) + ) + .sort(); + +const nationCommandLogs = (logs: Array>): Array> => + logs.filter((entry) => normalizeStoredLogText(entry.text) !== '아무것도 실행하지 않았습니다.'); + const ignoredLifecyclePaths = [ /^generalTurns/, /^nationTurns/, @@ -679,6 +700,173 @@ integration('nation command success matrix', () => { ); }); +const nationNameBoundaryCases: Array<{ + name: string; + nationName: string; + completed: boolean; + duplicate?: boolean; +}> = [ + { name: 'accepts nine full-width characters at width eighteen', nationName: '가나다라마바사아자', completed: true }, + { name: 'accepts eighteen half-width characters', nationName: '123456789012345678', completed: true }, + { name: 'preserves surrounding spaces', nationName: ' 신국 ', completed: true }, + { name: 'accepts a single space', nationName: ' ', completed: true }, + { name: 'rejects ten full-width characters at width twenty', nationName: '가나다라마바사아자차', completed: false }, + { name: 'rejects nineteen half-width characters', nationName: '1234567890123456789', completed: false }, + { name: 'rejects another nation duplicate', nationName: '타국', completed: false, duplicate: true }, + { name: 'rejects the current nation duplicate', nationName: '아국', completed: false, duplicate: true }, +]; + +const nationColors = [ + '#FF0000', + '#800000', + '#A0522D', + '#FF6347', + '#FFA500', + '#FFDAB9', + '#FFD700', + '#FFFF00', + '#7CFC00', + '#00FF00', + '#808000', + '#008000', + '#2E8B57', + '#008080', + '#20B2AA', + '#6495ED', + '#7FFFD4', + '#AFEEEE', + '#87CEEB', + '#00FFFF', + '#00BFFF', + '#0000FF', + '#000080', + '#483D8B', + '#7B68EE', + '#BA55D3', + '#800080', + '#FF00FF', + '#FFC0CB', + '#F5F5DC', + '#E0FFFF', + '#FFFFFF', + '#A9A9A9', +] as const; + +const nationFlagBoundaryCases: Array<{ + name: string; + colorType: unknown; + completed: boolean; + expectedIndex?: number; +}> = [ + { name: 'accepts integer zero', colorType: 0, completed: true, expectedIndex: 0 }, + { name: 'accepts an integer numeric string', colorType: '1', completed: true, expectedIndex: 1 }, + { name: 'accepts boolean true as key one', colorType: true, completed: true, expectedIndex: 1 }, + { name: 'accepts boolean false as key zero', colorType: false, completed: true, expectedIndex: 0 }, + { name: 'truncates a positive fractional key', colorType: 1.9, completed: true, expectedIndex: 1 }, + { name: 'truncates a negative fraction to zero', colorType: -0.9, completed: true, expectedIndex: 0 }, + { name: 'accepts the upper fractional key', colorType: 32.9, completed: true, expectedIndex: 32 }, + { name: 'rejects a leading-zero numeric string', colorType: '01', completed: false }, + { name: 'rejects a fractional numeric string', colorType: '1.5', completed: false }, + { name: 'rejects the first out-of-range integer', colorType: 33, completed: false }, + { name: 'rejects a negative integer', colorType: -1, completed: false }, + { name: 'rejects null', colorType: null, completed: false }, +]; + +const addedReferenceLogs = ( + before: { watermarks: { logId: number; historyLogId: number } }, + afterLogs: Array> +): Array> => + afterLogs.filter((entry) => { + const scope = String(entry.scope).toLowerCase(); + const category = String(entry.category).toLowerCase(); + const watermark = + scope === 'nation' || (scope === 'system' && category === 'history') + ? before.watermarks.historyLogId + : before.watermarks.logId; + return (Number(entry.id) || 0) > watermark; + }); + +integration('nation name boundary parity', () => { + it.each(nationNameBoundaryCases)( + '$name', + async ({ nationName, completed, duplicate }) => { + const request = buildRequest('che_국호변경', { nationName }); + request.observe!.includeNationHistoryLogs = true; + request.observe!.includeGlobalHistoryLogs = true; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed }); + if (duplicate) { + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_국호변경', + actionKey: 'che_국호변경', + usedFallback: false, + completed: false, + }); + } else { + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_국호변경', + actionKey: completed ? 'che_국호변경' : '휴식', + usedFallback: !completed, + }); + } + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + if (completed) { + expect(core.after.nations.find((entry) => entry.id === 1)?.name).toBe(nationName); + expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + } + }, + 120_000 + ); +}); + +integration('nation flag boundary parity', () => { + it.each(nationFlagBoundaryCases)( + '$name', + async ({ colorType, completed, expectedIndex }) => { + const request = buildRequest('che_국기변경', { colorType }); + request.observe!.includeNationHistoryLogs = true; + request.observe!.includeGlobalHistoryLogs = true; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_국기변경', + actionKey: completed ? 'che_국기변경' : '휴식', + usedFallback: !completed, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + if (completed) { + expect(core.after.nations.find((entry) => entry.id === 1)?.color).toBe(nationColors[expectedIndex!]); + expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + } + }, + 120_000 + ); +}); + type VolunteerRecruitOutcome = 'fallback' | 'intermediate' | 'completed'; const volunteerRecruitBoundaryCases: Array<{