From 3fb75ccf03862dba6f565c61cfcba2ebb076aadb Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 17:50:56 +0000 Subject: [PATCH] fix general founding and rebellion parity --- .../ai/generalAi/general/politicsActions.ts | 4 +- .../src/turn/reservedTurnHandler.ts | 6 + .../test/reservedTurnExecution.test.ts | 17 +- .../src/actions/turn/general/che_거병.ts | 54 +++-- .../src/actions/turn/general/che_건국.ts | 127 ++++++------ .../src/actions/turn/general/che_모반시도.ts | 11 +- .../actions/turn/general/che_무작위건국.ts | 81 +++----- .../logic/src/actions/turn/general/cr_건국.ts | 96 ++++----- .../actions/turn/general/foundingShared.ts | 59 ++++++ packages/logic/src/constraints/general.ts | 2 +- packages/logic/src/constraints/nation.ts | 23 +-- .../logic/test/scenarios/blankStart.test.ts | 11 +- .../general/migratedGeneralCommands.test.ts | 9 +- .../src/turn-differential/canonical.ts | 1 + .../src/turn-differential/coreCommandTrace.ts | 7 +- ...rnCommandGeneralMatrix.integration.test.ts | 186 ++++++++++++++++++ 16 files changed, 473 insertions(+), 221 deletions(-) create mode 100644 packages/logic/src/actions/turn/general/foundingShared.ts diff --git a/app/game-engine/src/turn/ai/generalAi/general/politicsActions.ts b/app/game-engine/src/turn/ai/generalAi/general/politicsActions.ts index 4919b7d..b9f094a 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/politicsActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/politicsActions.ts @@ -154,12 +154,10 @@ export const do거병 = (ai: GeneralAI) => { }; export const do건국 = (ai: GeneralAI) => { - const mapName = ai.scenarioConfig.environment.mapName ?? 'sammo'; - const prefix = mapName.endsWith('_') ? mapName : `${mapName}_`; const nationType = ai.aiConst.availableNationTypes.length > 0 ? (ai.rng.choice(ai.aiConst.availableNationTypes) as string) - : `${prefix}def`; + : 'che_도적'; const colorType = ai.rng.nextRangeInt(0, 32); const nationName = `㉿${Array.from(ai.general.name).slice(1).join('')}`; diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index fdd7a86..d4d7b1d 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -1261,6 +1261,12 @@ export const createReservedTurnHandler = async (options: { if (resolution.created?.nations) { const newNations = resolution.created.nations as Nation[]; createdNations.push(...newNations); + if (actionKey === 'che_거병') { + for (const nation of newNations) { + options.reservedTurns.ensureNationTurns(nation.id, 12); + options.reservedTurns.ensureNationTurns(nation.id, 11); + } + } if (worldOverlay) { for (const nation of newNations) { worldOverlay.syncNation(nation); diff --git a/app/game-engine/test/reservedTurnExecution.test.ts b/app/game-engine/test/reservedTurnExecution.test.ts index 40f753b..69c114e 100644 --- a/app/game-engine/test/reservedTurnExecution.test.ts +++ b/app/game-engine/test/reservedTurnExecution.test.ts @@ -787,7 +787,7 @@ describe('Reserved Turn Execution Integration', () => { generalId: 1, turnIdx: 1, actionCode: 'che_건국', - arg: { nationName: 'NewEmpire', nationType: 'che_def', colorType: 0 }, + arg: { nationName: 'NewEmpire', nationType: 'che_도적', colorType: 0 }, }, { generalId: 2, turnIdx: 1, actionCode: 'che_농지개간', arg: {} }, // 2 @@ -798,7 +798,7 @@ describe('Reserved Turn Execution Integration', () => { generalId: 1, turnIdx: 3, actionCode: 'che_건국', - arg: { nationName: 'NewEmpire', nationType: 'che_def', colorType: 0 }, + arg: { nationName: 'NewEmpire', nationType: 'che_도적', colorType: 0 }, }, { generalId: 2, turnIdx: 3, actionCode: 'che_농지개간', arg: {} }, // 4 @@ -809,7 +809,7 @@ describe('Reserved Turn Execution Integration', () => { generalId: 1, turnIdx: 5, actionCode: 'che_건국', - arg: { nationName: 'NewEmpire', nationType: 'che_def', colorType: 0 }, + arg: { nationName: 'NewEmpire', nationType: 'che_도적', colorType: 0 }, }, { generalId: 2, turnIdx: 5, actionCode: 'che_농지개간', arg: {} }, ]; @@ -1058,7 +1058,7 @@ describe('Reserved Turn Execution Integration', () => { generalId: 1, turnIdx: 0, actionCode: 'che_건국', - arg: { nationName: 'Empire', nationType: 'che_def', colorType: 0 }, + arg: { nationName: 'Empire', nationType: 'che_도적', colorType: 0 }, }, ]; @@ -1102,7 +1102,7 @@ describe('Reserved Turn Execution Integration', () => { turnsG1[0] = { action: 'che_거병', args: {} }; turnsG1[1] = { action: 'che_건국', - args: { nationName: 'Empire', nationType: 'che_def', colorType: 0 }, + args: { nationName: 'Empire', nationType: 'che_도적', colorType: 0 }, }; await reservedTurnStore.flushChanges(); @@ -1112,6 +1112,11 @@ describe('Reserved Turn Execution Integration', () => { maxGenerals: 100, catchUpCap: 10, }); + const uprisingNationId = world.getGeneralById(1)!.nationId; + expect(reservedTurnStore.peekDirtyState().nationInitializationKeys).toEqual([ + `${uprisingNationId}:12`, + `${uprisingNationId}:11`, + ]); world.consumeDirtyState(); // clear logs // Execute Founding attempt (Turn 2) @@ -1145,7 +1150,7 @@ describe('Reserved Turn Execution Integration', () => { const turnsG1_v2 = reservedTurnStore.getGeneralTurns(1); turnsG1_v2[0] = { action: 'che_건국', - args: { nationName: 'Empire', nationType: 'che_def', colorType: 0 }, + args: { nationName: 'Empire', nationType: 'che_도적', colorType: 0 }, }; await reservedTurnStore.flushChanges(); diff --git a/packages/logic/src/actions/turn/general/che_거병.ts b/packages/logic/src/actions/turn/general/che_거병.ts index 9acb127..68dedd6 100644 --- a/packages/logic/src/actions/turn/general/che_거병.ts +++ b/packages/logic/src/actions/turn/general/che_거병.ts @@ -13,11 +13,12 @@ import { createDiplomacyPatchEffect, createNationAddEffect, } from '@sammo-ts/logic/actions/engine.js'; -import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { ActionContextBuilder, ActionContextBase } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; +import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js'; export interface UprisingArgs {} @@ -29,6 +30,19 @@ export interface UprisingContext extends ActionContextBase { } const ACTION_NAME = '거병'; +const truncateLegacyWidth = (value: string, maxWidth: number): string => { + let result = ''; + let width = 0; + for (const character of value) { + const characterWidth = getLegacyStringWidth(character); + if (width + characterWidth > maxWidth) { + break; + } + result += character; + width += characterWidth; + } + return result; +}; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -65,8 +79,7 @@ export class ActionDefinition< const nations = uprisingCtx.listNations ? uprisingCtx.listNations() : []; if (nations.some((n) => n.name === nationName)) { - nationName = '㉥' + nationName; - if (nationName.length > 18) nationName = nationName.substring(0, 18); + nationName = truncateLegacyWidth('㉥' + nationName, 18); } if (nations.some((n) => n.name === nationName)) { @@ -88,7 +101,7 @@ export class ActionDefinition< color: '#330000', typeCode: 'che_중립', level: 0, - capitalCityId: null, + capitalCityId: 0, chiefGeneralId: general.id, gold: 0, rice: uprisingCtx.baseRice, @@ -110,24 +123,29 @@ export class ActionDefinition< category: LogCategory.ACTION, format: LogFormat.MONTH, }); - context.addLog(`${general.name}${josaYi} ${cityName}에 거병하였습니다.`, { - category: LogCategory.USER, - format: LogFormat.PLAIN, + context.addLog(`${general.name}${josaYi} ${cityName}에 거병하였습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, }); - context.addLog(`【거병】${general.name}${josaYi} 세력을 결성하였습니다.`, { + context.addLog( + `【거병】${general.name}${josaYi} 세력을 결성하였습니다.`, + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + } + ); + context.addLog(`${cityName}에서 거병`, { category: LogCategory.HISTORY, - format: LogFormat.PLAIN, - }); - context.addLog(`${cityName}에서 거병`, { - category: LogCategory.HISTORY, - format: LogFormat.PLAIN, - }); - context.addLog(`${general.name}${josaYi} ${cityName}에서 거병`, { - category: LogCategory.HISTORY, - format: LogFormat.PLAIN, + format: LogFormat.YEAR_MONTH, }); - tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); + tryApplyUniqueLottery(context, { + acquireType: '아이템', + reason: ACTION_NAME, + nationName, + }); const effects: GeneralActionEffect[] = [ createNationAddEffect(newNation), diff --git a/packages/logic/src/actions/turn/general/che_건국.ts b/packages/logic/src/actions/turn/general/che_건국.ts index d099d2d..e7a20dc 100644 --- a/packages/logic/src/actions/turn/general/che_건국.ts +++ b/packages/logic/src/actions/turn/general/che_건국.ts @@ -19,57 +19,26 @@ import { createNationPatchEffect, } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; -import { z } from 'zod'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; import { JosaUtil } from '@sammo-ts/common'; +import { + FOUNDING_ARGS_SCHEMA, + getNationTypeDisplayName, + NATION_COLORS, + type FoundingArgs, +} from './foundingShared.js'; const ACTION_NAME = '건국'; -const ARGS_SCHEMA = z.object({ - nationName: z.string().min(1), - nationType: z.string().min(1), - colorType: z.number(), -}); -export type FoundingArgs = z.infer; - -const NATION_COLORS = [ - '#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', -]; +interface FoundingResolveContext + extends GeneralActionResolveContext { + currentYearMonth?: number; + initYearMonth?: number; +} export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -81,7 +50,7 @@ export class ActionDefinition< } parseArgs(raw: unknown): FoundingArgs | null { - return parseArgsWithSchema(ARGS_SCHEMA, raw); + return parseArgsWithSchema(FOUNDING_ARGS_SCHEMA, raw); } buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] { @@ -106,15 +75,20 @@ export class ActionDefinition< } resolve( - context: GeneralActionResolveContext, + context: FoundingResolveContext, args: FoundingArgs ): GeneralActionOutcome { const general = context.general; const nation = context.nation!; const cityId = general.cityId!; - if (args.colorType < 0 || args.colorType >= NATION_COLORS.length) { - throw new Error('Invalid color type'); + if ((context.currentYearMonth ?? 0) <= (context.initYearMonth ?? 0)) { + context.addLog('다음 턴부터 건국할 수 있습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + return { effects: [], alternative: { commandKey: 'che_인재탐색', args: {} } }; } const color = NATION_COLORS[args.colorType]; @@ -123,33 +97,42 @@ export class ActionDefinition< const josaGeneralYi = JosaUtil.pick(general.name, '이'); const city = context.city; - context.addLog(`${args.nationName}${josaNationUl} 건국하였습니다.`, { - category: LogCategory.USER, - format: LogFormat.PLAIN, - }); context.addLog(`${args.nationName}${josaNationUl} 건국하였습니다.`, { category: LogCategory.ACTION, format: LogFormat.MONTH, }); - context.addLog(`${general.name}${josaGeneralYi} ${city?.name}에 국가를 건설하였습니다.`, { - category: LogCategory.ACTION, + context.addLog(`${general.name}${josaGeneralYi} ${city?.name}에 국가를 건설하였습니다.`, { + category: LogCategory.SUMMARY, scope: LogScope.SYSTEM, - format: LogFormat.PLAIN, + format: LogFormat.MONTH, }); - context.addLog(`【건국】${args.nationType} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, { + context.addLog( + `【건국】${getNationTypeDisplayName(args.nationType)} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, + { + category: LogCategory.HISTORY, + scope: LogScope.SYSTEM, + format: LogFormat.YEAR_MONTH, + } + ); + context.addLog(`${args.nationName}${josaNationUl} 건국`, { category: LogCategory.HISTORY, - format: LogFormat.PLAIN, - }); - context.addLog(`${args.nationName}${josaNationUl} 건국`, { - category: LogCategory.HISTORY, - format: LogFormat.PLAIN, - }); - context.addLog(`${general.name}${josaGeneralYi} ${args.nationName}${josaNationUl} 건국`, { - category: LogCategory.HISTORY, - format: LogFormat.PLAIN, + scope: LogScope.GENERAL, + format: LogFormat.YEAR_MONTH, }); + context.addLog( + `${general.name}${josaGeneralYi} ${args.nationName}${josaNationUl} 건국`, + { + category: LogCategory.HISTORY, + scope: LogScope.NATION, + format: LogFormat.YEAR_MONTH, + } + ); - tryApplyUniqueLottery(context, { acquireType: '건국', reason: ACTION_NAME }); + tryApplyUniqueLottery(context, { + acquireType: '건국', + reason: ACTION_NAME, + nationName: args.nationName, + }); const effects = [ createNationPatchEffect( @@ -169,6 +152,7 @@ export class ActionDefinition< createCityPatchEffect( { nationId: nation.id, + conflict: {}, }, cityId ), @@ -183,7 +167,14 @@ export class ActionDefinition< } // 예약 턴 실행은 기본 컨텍스트만 사용한다. -export const actionContextBuilder = defaultActionContextBuilder; +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const init = resolveInitYearMonth(options.world, options.scenarioMeta); + return { + ...base, + currentYearMonth: options.world.currentYear * 12 + options.world.currentMonth - 1, + initYearMonth: init.year * 12 + init.month - 1, + }; +}; export const commandSpec: GeneralTurnCommandSpec = { key: 'che_건국', @@ -194,6 +185,6 @@ export const commandSpec: GeneralTurnCommandSpec = { nationType: 'string', colorType: 'number', }, - argsSchema: ARGS_SCHEMA, + argsSchema: FOUNDING_ARGS_SCHEMA, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), }; diff --git a/packages/logic/src/actions/turn/general/che_모반시도.ts b/packages/logic/src/actions/turn/general/che_모반시도.ts index 32db33e..2b27aef 100644 --- a/packages/logic/src/actions/turn/general/che_모반시도.ts +++ b/packages/logic/src/actions/turn/general/che_모반시도.ts @@ -14,7 +14,11 @@ import type { GeneralActionOutcome, GeneralActionResolveContext, } from '@sammo-ts/logic/actions/engine.js'; -import { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { + createGeneralPatchEffect, + createLogEffect, + 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 { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; @@ -118,7 +122,7 @@ export class ActionDefinition< createGeneralPatchEffect( { officerLevel: 1, - experience: Math.floor(lord.experience * 0.7), + experience: Math.round(lord.experience * 0.7), meta: { ...lord.meta, officer_city: 0, @@ -126,7 +130,8 @@ export class ActionDefinition< }, }, lord.id - ) + ), + createNationPatchEffect({ chiefGeneralId: general.id }, nation.id) ); return { effects }; diff --git a/packages/logic/src/actions/turn/general/che_무작위건국.ts b/packages/logic/src/actions/turn/general/che_무작위건국.ts index 542aa80..580c3ac 100644 --- a/packages/logic/src/actions/turn/general/che_무작위건국.ts +++ b/packages/logic/src/actions/turn/general/che_무작위건국.ts @@ -21,7 +21,6 @@ import { createNationPatchEffect, } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; -import { z } from 'zod'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; @@ -29,15 +28,15 @@ import { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContext import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; import { JosaUtil } from '@sammo-ts/common'; +import { + FOUNDING_ARGS_SCHEMA, + getNationTypeDisplayName, + NATION_COLORS, + type FoundingArgs, +} from './foundingShared.js'; const ACTION_NAME = '무작위 도시 건국'; const ACTION_KEY = 'che_무작위건국'; -const ARGS_SCHEMA = z.object({ - nationName: z.string().min(1), - nationType: z.string().min(1), - colorType: z.number(), -}); -export type FoundingArgs = z.infer; export interface RandomFoundingResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -48,41 +47,14 @@ export interface RandomFoundingResolveContext< initYearMonth?: number; } -const NATION_COLORS = [ - '#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', -]; +type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & { + nextIntInclusive?: (maxInclusive: number) => number; +}; + +const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: number): number => { + const inclusive = rng as InclusiveRandomGenerator; + return inclusive.nextIntInclusive ? inclusive.nextIntInclusive(length - 1) : rng.nextInt(0, length); +}; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -94,7 +66,7 @@ export class ActionDefinition< } parseArgs(raw: unknown): FoundingArgs | null { - return parseArgsWithSchema(ARGS_SCHEMA, raw); + return parseArgsWithSchema(FOUNDING_ARGS_SCHEMA, raw); } buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] { @@ -131,10 +103,6 @@ export class ActionDefinition< return { effects: [], alternative: { commandKey: 'che_인재탐색', args: {} } }; } - if (args.colorType < 0 || args.colorType >= NATION_COLORS.length) { - throw new Error('Invalid color type'); - } - const candidates = (context.allCities ?? []).filter( (city) => city.nationId === 0 && [5, 6].includes(city.level) ); @@ -147,7 +115,7 @@ export class ActionDefinition< return { effects: [], alternative: { commandKey: 'che_해산', args: {} } }; } - const picked = candidates[context.rng.nextInt(0, candidates.length)]!; + const picked = candidates[legacyChoiceIndex(context.rng, candidates.length)]!; const cityId = picked.id; const josaNationUl = JosaUtil.pick(args.nationName, '을'); @@ -161,25 +129,33 @@ export class ActionDefinition< }); context.addLog(`${general.name}${josaGeneralYi} ${picked.name}에 국가를 건설하였습니다.`, { scope: LogScope.SYSTEM, - category: LogCategory.ACTION, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, }); context.addLog( - `【건국】${args.nationType} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, + `【건국】${getNationTypeDisplayName(args.nationType)} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, { scope: LogScope.SYSTEM, category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, } ); context.addLog(`${args.nationName}${josaNationUl} 건국`, { scope: LogScope.GENERAL, category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, }); context.addLog(`${general.name}${josaGeneralYi} ${args.nationName}${josaNationUl} 건국`, { scope: LogScope.NATION, category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, }); - tryApplyUniqueLottery(context, { acquireType: '건국', reason: ACTION_NAME }); + tryApplyUniqueLottery(context, { + acquireType: '건국', + reason: ACTION_NAME, + nationName: args.nationName, + }); const effects: Array> = [ createNationPatchEffect( @@ -200,6 +176,7 @@ export class ActionDefinition< createCityPatchEffect( { nationId: nation.id, + conflict: {}, }, cityId ), @@ -248,6 +225,6 @@ export const commandSpec: GeneralTurnCommandSpec = { nationType: 'string', colorType: 'number', }, - argsSchema: ARGS_SCHEMA, + argsSchema: FOUNDING_ARGS_SCHEMA, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), }; diff --git a/packages/logic/src/actions/turn/general/cr_건국.ts b/packages/logic/src/actions/turn/general/cr_건국.ts index 20b60d5..58a883a 100644 --- a/packages/logic/src/actions/turn/general/cr_건국.ts +++ b/packages/logic/src/actions/turn/general/cr_건국.ts @@ -18,58 +18,27 @@ import { createNationPatchEffect, } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; -import { z } from 'zod'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; import { JosaUtil } from '@sammo-ts/common'; +import { + FOUNDING_ARGS_SCHEMA, + getNationTypeDisplayName, + NATION_COLORS, + type FoundingArgs, +} from './foundingShared.js'; const ACTION_NAME = '건국'; const ACTION_KEY = 'cr_건국'; -const ARGS_SCHEMA = z.object({ - nationName: z.string().min(1), - nationType: z.string().min(1), - colorType: z.number(), -}); -export type FoundingArgs = z.infer; - -const NATION_COLORS = [ - '#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', -]; +interface FoundingResolveContext + extends GeneralActionResolveContext { + currentYearMonth?: number; + initYearMonth?: number; +} export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -81,7 +50,7 @@ export class ActionDefinition< } parseArgs(raw: unknown): FoundingArgs | null { - return parseArgsWithSchema(ARGS_SCHEMA, raw); + return parseArgsWithSchema(FOUNDING_ARGS_SCHEMA, raw); } buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] { @@ -101,7 +70,7 @@ export class ActionDefinition< } resolve( - context: GeneralActionResolveContext, + context: FoundingResolveContext, args: FoundingArgs ): GeneralActionOutcome { const general = context.general; @@ -111,8 +80,13 @@ export class ActionDefinition< } const cityId = general.cityId; - if (args.colorType < 0 || args.colorType >= NATION_COLORS.length) { - throw new Error('Invalid color type'); + if ((context.currentYearMonth ?? 0) <= (context.initYearMonth ?? 0)) { + context.addLog('다음 턴부터 건국할 수 있습니다.', { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + }); + return { effects: [], alternative: { commandKey: 'che_인재탐색', args: {} } }; } const color = NATION_COLORS[args.colorType]; @@ -126,26 +100,34 @@ export class ActionDefinition< format: LogFormat.MONTH, }); context.addLog(`${general.name}${josaGeneralYi} ${cityName}에 국가를 건설하였습니다.`, { - category: LogCategory.ACTION, + category: LogCategory.SUMMARY, scope: LogScope.SYSTEM, + format: LogFormat.MONTH, }); context.addLog( - `【건국】${args.nationType} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, + `【건국】${getNationTypeDisplayName(args.nationType)} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, { category: LogCategory.HISTORY, scope: LogScope.SYSTEM, + format: LogFormat.YEAR_MONTH, } ); context.addLog(`${args.nationName}${josaNationUl} 건국`, { category: LogCategory.HISTORY, scope: LogScope.GENERAL, + format: LogFormat.YEAR_MONTH, }); context.addLog(`${general.name}${josaGeneralYi} ${args.nationName}${josaNationUl} 건국`, { category: LogCategory.HISTORY, scope: LogScope.NATION, + format: LogFormat.YEAR_MONTH, }); - tryApplyUniqueLottery(context, { acquireType: '건국', reason: ACTION_NAME }); + tryApplyUniqueLottery(context, { + acquireType: '건국', + reason: ACTION_NAME, + nationName: args.nationName, + }); const effects = [ createNationPatchEffect( @@ -165,6 +147,7 @@ export class ActionDefinition< createCityPatchEffect( { nationId: nation.id, + conflict: {}, }, cityId ), @@ -178,7 +161,14 @@ export class ActionDefinition< } } -export const actionContextBuilder = defaultActionContextBuilder; +export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const init = resolveInitYearMonth(options.world, options.scenarioMeta); + return { + ...base, + currentYearMonth: options.world.currentYear * 12 + options.world.currentMonth - 1, + initYearMonth: init.year * 12 + init.month - 1, + }; +}; export const commandSpec: GeneralTurnCommandSpec = { key: ACTION_KEY, @@ -189,6 +179,6 @@ export const commandSpec: GeneralTurnCommandSpec = { nationType: 'string', colorType: 'number', }, - argsSchema: ARGS_SCHEMA, + argsSchema: FOUNDING_ARGS_SCHEMA, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), }; diff --git a/packages/logic/src/actions/turn/general/foundingShared.ts b/packages/logic/src/actions/turn/general/foundingShared.ts new file mode 100644 index 0000000..9e67998 --- /dev/null +++ b/packages/logic/src/actions/turn/general/foundingShared.ts @@ -0,0 +1,59 @@ +import { NATION_TRAIT_KEYS } from '@sammo-ts/logic/triggers/special/nation/index.js'; +import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js'; +import { z } from 'zod'; + +export const NATION_COLORS = [ + '#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 SELECTABLE_NATION_TYPES = new Set(NATION_TRAIT_KEYS.filter((key) => key !== 'che_중립')); + +export const FOUNDING_ARGS_SCHEMA = z.object({ + nationName: z + .string() + .min(1) + .refine((value) => getLegacyStringWidth(value) <= 18), + nationType: z.string().refine((value) => SELECTABLE_NATION_TYPES.has(value)), + colorType: z + .number() + .int() + .min(0) + .max(NATION_COLORS.length - 1), +}); + +export type FoundingArgs = z.infer; + +export const getNationTypeDisplayName = (nationType: string): string => + nationType.startsWith('che_') ? nationType.slice(4) : nationType; diff --git a/packages/logic/src/constraints/general.ts b/packages/logic/src/constraints/general.ts index 9e52cfe..51f1268 100644 --- a/packages/logic/src/constraints/general.ts +++ b/packages/logic/src/constraints/general.ts @@ -268,7 +268,7 @@ export const noPenalty = (penaltyKey: string): Constraint => ({ return unknownOrDeny(ctx, [generalReq], '장수 정보가 없습니다.'); } - const penalty = general.meta.penalty; + const penalty = (general as General & { penalty?: unknown }).penalty ?? general.meta.penalty; if (!penalty || typeof penalty !== 'object' || Array.isArray(penalty)) { return allow(); } diff --git a/packages/logic/src/constraints/nation.ts b/packages/logic/src/constraints/nation.ts index fe7d2c9..417b5b9 100644 --- a/packages/logic/src/constraints/nation.ts +++ b/packages/logic/src/constraints/nation.ts @@ -265,24 +265,16 @@ export const differentDestNation = (): Constraint => ({ export const reqNationGeneralCount = (min: number): Constraint => ({ name: 'reqNationGeneralCount', requires: (ctx) => { - const reqs: RequirementKey[] = [{ kind: 'generalList' }]; + const reqs: RequirementKey[] = []; if (ctx.nationId !== undefined) { reqs.push({ kind: 'nation', id: ctx.nationId }); } else { reqs.push({ kind: 'general', id: ctx.actorId }); + reqs.push({ kind: 'nationList' }); } return reqs; }, test: (ctx, view) => { - const listReq: RequirementKey = { kind: 'generalList' }; - if (!view.has(listReq)) { - return unknownOrDeny(ctx, [listReq], '장수가 없습니다.'); - } - const generals = view.get(listReq) as General[] | null; - if (!generals) { - return unknownOrDeny(ctx, [listReq], '장수가 없습니다.'); - } - let baseNationId = ctx.nationId; if (baseNationId === undefined) { const general = readGeneral(ctx, view); @@ -296,7 +288,16 @@ export const reqNationGeneralCount = (min: number): Constraint => ({ baseNationId = general.nationId; } - const count = generals.filter((g) => g.nationId === baseNationId).length; + const nationReq: RequirementKey = + ctx.nationId !== undefined ? { kind: 'nation', id: baseNationId } : { kind: 'nationList' }; + const nation = + ctx.nationId !== undefined + ? (view.get(nationReq) as Nation | null) + : ((view.get(nationReq) as Nation[] | null)?.find((entry) => entry.id === baseNationId) ?? null); + if (!nation) { + return unknownOrDeny(ctx, [nationReq], '국가 정보가 없습니다.'); + } + const count = typeof nation.meta.gennum === 'number' ? nation.meta.gennum : 0; if (count >= min) { return allow(); } diff --git a/packages/logic/test/scenarios/blankStart.test.ts b/packages/logic/test/scenarios/blankStart.test.ts index cd22920..f8052ca 100644 --- a/packages/logic/test/scenarios/blankStart.test.ts +++ b/packages/logic/test/scenarios/blankStart.test.ts @@ -144,7 +144,7 @@ describe('Blank Start Scenario', () => { const FOUNDING_ARGS = { nationName: 'NewChosen', - nationType: 'che_def', + nationType: 'che_도적', colorType: 1, }; @@ -218,6 +218,7 @@ describe('Blank Start Scenario', () => { nationId: newNationId, officerLevel: 1, } as General; + world.getNation(newNationId)!.meta.gennum = 2; // --- Step 3: Gen 0 performs Founding --- // First, check name duplicate @@ -232,7 +233,7 @@ describe('Blank Start Scenario', () => { power: 0, level: 1, typeCode: 'che_def', - meta: {}, + meta: { gennum: 2 }, }; world.snapshot.nations.push(duplicateNation); const ctxDuplicate = createConstraintContext(world.getGeneral(gen0.id)!, 189, { @@ -263,6 +264,10 @@ describe('Blank Start Scenario', () => { commandKey: 'che_건국', resolver: foundNationDef, args: FOUNDING_ARGS, + context: { + currentYearMonth: 189 * 12 + 2, + initYearMonth: 189 * 12, + }, }, ]); @@ -293,7 +298,7 @@ describe('Blank Start Scenario', () => { power: 0, level: 0, typeCode: 'che_def', - meta: {}, + meta: { gennum: 2 }, }; world.snapshot.nations.push(newNation); const gen0Idx = world.snapshot.generals.findIndex((g) => g.id === gen0.id); diff --git a/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts b/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts index 0afb5f7..6df80dc 100644 --- a/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts +++ b/packages/logic/test/scenarios/general/migratedGeneralCommands.test.ts @@ -195,6 +195,7 @@ describe('migrated general commands', () => { expect(updatedChief.officerLevel).toBe(12); expect(updatedLord.officerLevel).toBe(1); expect(updatedLord.experience).toBe(700); + expect(world.getNation(nation.id)?.chiefGeneralId).toBe(chief.id); }); it('che_선양: 제약 없는 대상에게 선양한다', async () => { @@ -316,7 +317,7 @@ describe('migrated general commands', () => { capitalCityId: null, level: 0, typeCode: 'None', - meta: {}, + meta: { gennum: 2 }, }); const city = makeCity({ id: 1, nationId: 0, level: 5 }); @@ -330,7 +331,11 @@ describe('migrated general commands', () => { generalId: lord.id, commandKey: 'cr_건국', resolver: foundNationSpec.createDefinition(SYSTEM_ENV), - args: { nationName: '신국', nationType: 'che_def', colorType: 1 }, + args: { nationName: '신국', nationType: 'che_도적', colorType: 1 }, + context: { + currentYearMonth: 201 * 12, + initYearMonth: 200 * 12, + }, }, ]); diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index bfc02fc..851151f 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -155,6 +155,7 @@ export const projectCoreDatabaseSnapshot = (rows: { dex5: readNumber(meta, 'dex5'), specAge: readNumber(meta, 'specage'), specAge2: readNumber(meta, 'specage2'), + makeLimit: readNumber(meta, 'makelimit'), penalty: asRecord(row.penalty), killTurn: readNumber(meta, 'killturn'), mySet: readNumber(meta, 'myset'), diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 0e5d75b..535ac34 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -254,6 +254,7 @@ const buildGeneral = (row: Record, fallbackTurnTime: Date): Tur ...meta, specage: readNumber(row, 'specAge', readNumber(meta, 'specage')), specage2: readNumber(row, 'specAge2', readNumber(meta, 'specage2')), + makelimit: readNumber(row, 'makeLimit', readNumber(meta, 'makelimit')), killturn: readNumber(row, 'killTurn', readNumber(meta, 'killturn', 24)), leadership_exp: readNumber(row, 'leadershipExp', readNumber(meta, 'leadership_exp')), strength_exp: readNumber(row, 'strengthExp', readNumber(meta, 'strength_exp')), @@ -573,6 +574,7 @@ const projectWorld = ( dex5: toDatabaseInt(readNumber(general.meta, 'dex5')), specAge: toDatabaseInt(readNumber(general.meta, 'specage')), specAge2: toDatabaseInt(readNumber(general.meta, 'specage2')), + makeLimit: toDatabaseInt(readNumber(general.meta, 'makelimit')), penalty: asRecord(general.penalty), killTurn: readNumber(general.meta, 'killturn'), mySet: readNumber(general.meta, 'myset'), @@ -721,7 +723,10 @@ export const runCoreTurnCommandTrace = async ( ...(request.observe?.generalIds ?? []), ]), cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))), - nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))), + nationIds: new Set([ + ...referenceBefore.nations.map((row) => readNumber(row, 'id')), + ...(request.observe?.nationIds ?? []), + ]), initialGeneralIds: new Set(referenceBefore.generals.map((row) => readNumber(row, 'id'))), generalCooldowns: request.observe?.generalCooldowns ?? [], nationCooldowns: request.observe?.nationCooldowns ?? [], diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 903649e..00c8346 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -2240,6 +2240,192 @@ integration('general resignation, retirement, and abdication boundary, state, an ); }); +type GeneralFoundingRebellionBoundaryCase = { + name: string; + action: 'che_거병' | 'che_모반시도' | 'che_건국' | 'cr_건국' | 'che_무작위건국'; + args?: Record; + actorPatch?: Record; + fixturePatches?: FixturePatches; + completed: boolean; + expectedActionKey?: string; + compareLogs?: boolean; +}; + +const foundingArgs = { nationName: '신국', nationType: 'che_도적', colorType: 1 }; +const wanderingFoundingFixture = (overrides: FixturePatches = {}): FixturePatches => ({ + ...overrides, + world: { startYear: 180, initYear: 180, initMonth: 1, year: 181, ...overrides.world }, + nations: { + ...overrides.nations, + 1: { level: 0, capitalCityId: 0, typeCode: 'None', generalCount: 2, ...overrides.nations?.[1] }, + }, + cities: { + ...overrides.cities, + 3: { nationId: 0, level: 5, ...overrides.cities?.[3] }, + }, +}); + +const generalFoundingRebellionBoundaryCases: GeneralFoundingRebellionBoundaryCase[] = [ + { + name: 'uprising rejects a no-founding penalty from the general column', + action: 'che_거병', + actorPatch: { nationId: 0, officerLevel: 0, penalty: { noFoundNation: true } }, + fixturePatches: { world: { startYear: 180, year: 181 } }, + completed: false, + }, + { + name: 'uprising rejects a remaining join-action cooldown', + action: 'che_거병', + actorPatch: { nationId: 0, officerLevel: 0, makeLimit: 1 }, + fixturePatches: { world: { startYear: 180, year: 181 } }, + completed: false, + }, + { + name: 'uprising trims a duplicate full-width nation name and preserves logs', + action: 'che_거병', + actorPatch: { name: '가나다라마바사아자차', nationId: 0, officerLevel: 0 }, + fixturePatches: { + world: { startYear: 180, year: 181 }, + nations: { 1: { name: '가나다라마바사아자차' } }, + }, + completed: true, + compareLogs: true, + }, + { + name: 'rebellion rejects an active monarch', + action: 'che_모반시도', + actorPatch: { officerLevel: 11 }, + fixturePatches: { generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 999_999 } } }, + completed: false, + }, + { + name: 'rebellion rejects an npc monarch', + action: 'che_모반시도', + actorPatch: { officerLevel: 11 }, + fixturePatches: { generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 0, npcState: 2 } } }, + completed: false, + }, + { + name: 'rebellion rounds the former monarch experience and transfers the chief cache', + action: 'che_모반시도', + actorPatch: { officerLevel: 11 }, + fixturePatches: { + generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 0, experience: 1001 } }, + }, + completed: true, + compareLogs: true, + }, + ...(['che_건국', 'cr_건국', 'che_무작위건국'] as const).map((action) => ({ + name: `${action} rejects a fractional color index`, + action, + args: { ...foundingArgs, colorType: 1.5 }, + fixturePatches: wanderingFoundingFixture(), + completed: false, + })), + { + name: 'founding rejects a nation name wider than eighteen legacy columns', + action: 'che_건국', + args: { ...foundingArgs, nationName: '가나다라마바사아자차' }, + fixturePatches: wanderingFoundingFixture(), + completed: false, + }, + { + name: 'founding rejects an unavailable nation type', + action: 'che_건국', + args: { ...foundingArgs, nationType: 'che_없는국가형' }, + fixturePatches: wanderingFoundingFixture(), + completed: false, + }, + ...(['che_건국', 'cr_건국', 'che_무작위건국'] as const).map((action) => ({ + name: `${action} uses the cached nation general count`, + action, + args: foundingArgs, + fixturePatches: wanderingFoundingFixture({ nations: { 1: { generalCount: 1 } } }), + completed: false, + })), + { + name: 'founding continues into talent scout during the initial month', + action: 'che_건국', + args: foundingArgs, + fixturePatches: wanderingFoundingFixture({ + world: { startYear: 180, initYear: 181, initMonth: 1, year: 181 }, + }), + completed: false, + expectedActionKey: 'che_인재탐색', + }, + { + name: 'restricted founding continues into talent scout during the initial month', + action: 'cr_건국', + args: foundingArgs, + fixturePatches: wanderingFoundingFixture({ + world: { startYear: 180, initYear: 181, initMonth: 1, year: 181 }, + }), + completed: false, + expectedActionKey: 'che_인재탐색', + }, + ...(['che_건국', 'cr_건국', 'che_무작위건국'] as const).map((action) => ({ + name: `${action} clears the founding city conflict and preserves success logs`, + action, + args: foundingArgs, + fixturePatches: wanderingFoundingFixture({ + cities: { 3: { conflict: { 2: 7 } } }, + randomFoundingCandidateCityIds: action === 'che_무작위건국' ? [3] : undefined, + }), + completed: true, + compareLogs: true, + })), +]; + +integration('general uprising, rebellion, and founding boundary, state, RNG, and log parity', () => { + it.each(generalFoundingRebellionBoundaryCases)( + '$name', + async ({ + name, + action, + args, + actorPatch, + fixturePatches, + completed, + expectedActionKey, + compareLogs, + }) => { + const request = buildRequest(action, args, actorPatch, fixturePatches); + request.setup!.world!.hiddenSeed = `general-founding-rebellion-${name}`; + if (action === 'che_거병') { + request.observe!.nationIds!.push(3); + } + request.observe!.includeGlobalHistoryLogs = true; + request.observe!.includeNationHistoryLogs = true; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const actionKey = expectedActionKey ?? (completed ? action : '휴식'); + + expect(reference.execution.outcome).toMatchObject({ completed }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey, + usedFallback: !completed && actionKey === '휴식', + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + + if (compareLogs) { + expect(semanticLogSignatures(core.after.logs)).toEqual( + semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) + ); + } + }, + 120_000 + ); +}); + type SabotageProbabilityClampCase = { name: string; action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취';