diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index e7a4401..fc639ae 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -181,6 +181,7 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0), trainDelta: resolveNumber(constValues, ['trainDelta'], 0), atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0), + trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1), maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], 0), maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], 0), sabotageDefaultProb: resolveNumber(constValues, ['sabotageDefaultProb'], 0), @@ -197,6 +198,9 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']), initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0), maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0), + maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255), + techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5), + initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1), baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0), baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0), maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0), diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index bba692c..77ebba8 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -145,6 +145,7 @@ const buildGeneralUpdate = ( personalCode: toCode(general.role.personality), specialCode: toCode(general.role.specialDomestic), special2Code: toCode(general.role.specialWar), + lastTurn: asJson(general.lastTurn ?? { command: '휴식' }), meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))), turnTime: general.turnTime, recentWarTime: general.recentWarTime ?? null, @@ -180,6 +181,7 @@ const buildGeneralCreate = ( personalCode: toCode(general.role.personality), specialCode: toCode(general.role.specialDomestic), special2Code: toCode(general.role.specialWar), + lastTurn: asJson(general.lastTurn ?? { command: '휴식' }), meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))), turnTime: general.turnTime, recentWarTime: general.recentWarTime ?? null, @@ -214,6 +216,7 @@ const buildCityUpdate = ( defenceMax: city.defenceMax, wall: city.wall, wallMax: city.wallMax, + ...(city.conflict ? { conflict: asJson(city.conflict) } : {}), meta: asJson(meta), }; diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index 6560ea7..28dac5b 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -77,6 +77,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit minAvailableRecruitPop: resolveNumber(constValues, ['minAvailableRecruitPop'], 30000), trainDelta: resolveNumber(constValues, ['trainDelta'], DEFAULT_TRAIN_DELTA), atmosDelta: resolveNumber(constValues, ['atmosDelta'], DEFAULT_ATMOS_DELTA), + trainSideEffectByAtmosTurn: resolveNumber(constValues, ['trainSideEffectByAtmosTurn'], 1), maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], DEFAULT_MAX_TRAIN_BY_COMMAND), maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], DEFAULT_MAX_ATMOS_BY_COMMAND), sabotageDefaultProb: resolveNumber(constValues, ['sabotageDefaultProb'], DEFAULT_SABOTAGE_PROB), @@ -101,6 +102,9 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']), initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], DEFAULT_INITIAL_NATION_GEN_LIMIT), maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_MAX_TECH_LEVEL), + maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255), + techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5), + initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1), baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD), baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE), maxResourceActionAmount: resolveNumber( diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 22eb6b5..127df47 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -17,7 +17,9 @@ import type { import { DEFAULT_TURN_COMMAND_PROFILE, GeneralTurnCommandLoader, + GeneralActionPipeline, NationTurnCommandLoader, + createGeneralTriggerContext, defaultActionContextBuilder, evaluateConstraints, resolveGeneralAction, @@ -101,7 +103,7 @@ const serializeSeed = (...values: Array): string => const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1; -type NationLastTurn = { +type LegacyLastTurn = { command: string; arg?: Record; term?: number; @@ -110,7 +112,7 @@ type NationLastTurn = { const nationLastTurnKey = (officerLevel: number): string => `turn_last_${officerLevel}`; -const normalizeLastTurn = (value: unknown): NationLastTurn => { +const normalizeLastTurn = (value: unknown): LegacyLastTurn => { const raw = asRecord(value); return { command: typeof raw.command === 'string' ? raw.command : '휴식', @@ -135,6 +137,18 @@ const readNextAvailableTurn = (nation: Nation, actionName: string): number | nul return null; }; +const readGeneralNextAvailableTurn = (general: TurnGeneral, actionName: string): number | null => { + const raw = asRecord(general.meta)[`next_execute_${actionName}`]; + if (typeof raw === 'number' && Number.isFinite(raw)) { + return Math.floor(raw); + } + if (typeof raw === 'string') { + const parsed = Number(raw); + return Number.isFinite(parsed) ? Math.floor(parsed) : null; + } + return null; +}; + const readMetaNumber = (meta: Record, key: string, fallback: number): number => { const value = meta[key]; if (typeof value === 'number' && Number.isFinite(value)) { @@ -631,7 +645,8 @@ export const createReservedTurnHandler = async (options: { definitionMap: Map, fallbackDefinition: GeneralActionDefinition, command: ReservedTurnEntry, - applyNextTurnAt: boolean + applyNextTurnAt: boolean, + alternativeDepth = 0 ): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; blockedReason?: string } => { const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition); const rawArgs = extractArgsRecord(command.args); @@ -680,9 +695,12 @@ export const createReservedTurnHandler = async (options: { const meta = result.kind === 'deny' ? { constraintName: result.constraintName } : undefined; logs.push(createActionLog(reason, meta)); } - if (kind === 'nation' && !usedFallback && currentNation) { + if (!usedFallback && (kind === 'general' || currentNation)) { const currentYearMonth = joinYearMonth(context.world.currentYear, context.world.currentMonth); - const nextAvailableTurn = readNextAvailableTurn(currentNation, definition.name); + const nextAvailableTurn = + kind === 'general' + ? readGeneralNextAvailableTurn(currentGeneral, definition.name) + : readNextAvailableTurn(currentNation!, definition.name); if (nextAvailableTurn !== null && currentYearMonth < nextAvailableTurn) { const remainTurn = nextAvailableTurn - currentYearMonth; definition = fallbackDefinition; @@ -698,10 +716,11 @@ export const createReservedTurnHandler = async (options: { const buildRng = (key: string) => { const rngSeed = serializeSeed( seedBase, - key, + kind === 'general' ? 'generalCommand' : 'nationCommand', context.world.currentYear, context.world.currentMonth, - currentGeneral.id + currentGeneral.id, + key ); return new RandUtil(new LiteHashDRBG(rngSeed)); }; @@ -759,19 +778,27 @@ export const createReservedTurnHandler = async (options: { getPreReqTurn?: (context: ActionContextBase, args: unknown) => number; getPostReqTurn?: (context: ActionContextBase, args: unknown) => number; getStackSequence?: (context: ActionContextBase, args: unknown) => number | null; + getProgressText?: ( + context: ActionContextBase, + args: unknown, + term: number, + termMax: number + ) => string; + getInheritanceActiveActionAmount?: (context: ActionContextBase, args: unknown) => number; }; - const preReqTurn = - kind === 'nation' && !usedFallback - ? Math.max(0, Math.floor(executionDefinition.getPreReqTurn?.(actionContext, actionArgs) ?? 0)) - : 0; - const postReqTurn = - kind === 'nation' && !usedFallback - ? Math.max(0, Math.floor(executionDefinition.getPostReqTurn?.(actionContext, actionArgs) ?? 0)) - : 0; + const preReqTurn = !usedFallback + ? Math.max(0, Math.floor(executionDefinition.getPreReqTurn?.(actionContext, actionArgs) ?? 0)) + : 0; + const postReqTurn = !usedFallback + ? Math.max(0, Math.floor(executionDefinition.getPostReqTurn?.(actionContext, actionArgs) ?? 0)) + : 0; - if (kind === 'nation' && !usedFallback && currentNation && preReqTurn > 0) { + if (!usedFallback && preReqTurn > 0 && (kind === 'general' || currentNation)) { const metaKey = nationLastTurnKey(currentGeneral.officerLevel); - const lastTurn = normalizeLastTurn(asRecord(currentNation.meta)[metaKey]); + const lastTurn = + kind === 'general' + ? normalizeLastTurn(currentGeneral.lastTurn) + : normalizeLastTurn(asRecord(currentNation!.meta)[metaKey]); const stackSequence = executionDefinition.getStackSequence?.(actionContext, actionArgs) ?? null; const sequenceChanged = stackSequence !== null && (lastTurn.seq === undefined || lastTurn.seq < stackSequence); @@ -782,26 +809,39 @@ export const createReservedTurnHandler = async (options: { const nextTerm = continuing ? (lastTurn.term ?? 0) + 1 : 1; if (!continuing || (lastTurn.term ?? 0) < preReqTurn) { - const nextLastTurn: NationLastTurn = { + const nextLastTurn: LegacyLastTurn = { command: definition.name, ...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined), term: nextTerm, ...(stackSequence !== null ? { seq: stackSequence } : undefined), }; - const nextNation: Nation = { - ...currentNation, - meta: { - ...currentNation.meta, - [metaKey]: nextLastTurn, - } as Nation['meta'], - }; - currentNation = nextNation; - worldOverlay?.syncNation(nextNation); - logs.push(createActionLog(`${definition.name} 수행중... (${nextTerm}/${preReqTurn + 1})`)); + if (kind === 'general') { + currentGeneral = { + ...currentGeneral, + lastTurn: nextLastTurn, + }; + worldOverlay?.syncGeneral(currentGeneral); + } else { + const nextNation: Nation = { + ...currentNation!, + meta: { + ...currentNation!.meta, + [metaKey]: nextLastTurn, + } as Nation['meta'], + }; + currentNation = nextNation; + worldOverlay?.syncNation(nextNation); + } + const termMax = preReqTurn + 1; + const progressText = + executionDefinition.getProgressText?.(actionContext, actionArgs, nextTerm, termMax) ?? + `${definition.name} 수행중... (${nextTerm}/${termMax})`; + logs.push(createActionLog(progressText)); return { actionKey, usedFallback, blockedReason }; } } + const lastTurnBeforeExecution = JSON.stringify(currentGeneral.lastTurn ?? {}); const resolution = resolveGeneralAction( definition, actionContext, @@ -815,19 +855,59 @@ export const createReservedTurnHandler = async (options: { currentGeneral = resolution.general as TurnGeneral; currentCity = resolution.city ?? currentCity; currentNation = resolution.nation ?? currentNation; - if (kind === 'nation' && !usedFallback && definition.countsAsInheritanceActiveAction) { + if ( + !resolution.alternative && + kind === 'nation' && + !usedFallback && + definition.countsAsInheritanceActiveAction + ) { const meta = { ...currentGeneral.meta }; const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0; meta.inherit_active_action = active + 1; currentGeneral = { ...currentGeneral, meta }; } + if ( + !resolution.alternative && + kind === 'general' && + !usedFallback && + executionDefinition.getInheritanceActiveActionAmount + ) { + const amount = executionDefinition.getInheritanceActiveActionAmount(actionContext, actionArgs); + if (Number.isFinite(amount) && amount !== 0) { + const meta = { ...currentGeneral.meta }; + const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0; + meta.inherit_active_action = active + amount; + currentGeneral = { ...currentGeneral, meta }; + } + } if (!currentNation && resolution.created?.nations) { currentNation = (resolution.created.nations as Nation[]).find((n) => n.id === currentGeneral.nationId) ?? currentNation; } - if (kind === 'nation' && !usedFallback && currentNation) { + if (!resolution.alternative && kind === 'general' && !usedFallback) { + const actionChangedLastTurn = + JSON.stringify(currentGeneral.lastTurn ?? {}) !== lastTurnBeforeExecution; + const nextMeta = { ...currentGeneral.meta }; + if (postReqTurn > 0) { + nextMeta[`next_execute_${definition.name}`] = + joinYearMonth(context.world.currentYear, context.world.currentMonth) + + postReqTurn - + preReqTurn; + } + currentGeneral = { + ...currentGeneral, + meta: nextMeta, + lastTurn: actionChangedLastTurn + ? currentGeneral.lastTurn + : { + command: definition.name, + ...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined), + }, + }; + } + if (!resolution.alternative && kind === 'nation' && !usedFallback && currentNation) { const metaKey = nationLastTurnKey(currentGeneral.officerLevel); const nextMeta: Record = { ...currentNation.meta, @@ -835,7 +915,7 @@ export const createReservedTurnHandler = async (options: { command: definition.name, ...(Object.keys(actionArgsRecord).length > 0 ? { arg: actionArgsRecord } : undefined), term: 0, - } satisfies NationLastTurn, + } satisfies LegacyLastTurn, }; if (postReqTurn > 0) { nextMeta[`next_execute_${definition.name}`] = @@ -951,6 +1031,23 @@ export const createReservedTurnHandler = async (options: { } } + if (resolution.alternative) { + if (alternativeDepth >= 5) { + throw new Error('Command fallback loop limit exceeded'); + } + return runAction( + kind, + definitionMap, + fallbackDefinition, + { + action: resolution.alternative.commandKey, + args: extractArgsRecord(resolution.alternative.args), + }, + applyNextTurnAt, + alternativeDepth + 1 + ); + } + return { nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined, actionKey, @@ -959,7 +1056,98 @@ export const createReservedTurnHandler = async (options: { }; }; - if (currentNation && currentGeneral.officerLevel >= 5) { + const preprocessRng = new RandUtil( + new LiteHashDRBG( + serializeSeed( + buildSeedBase(context.world), + 'preprocess', + context.world.currentYear, + context.world.currentMonth, + currentGeneral.id + ) + ) + ); + currentGeneral = { + ...currentGeneral, + role: { + ...currentGeneral.role, + items: { ...currentGeneral.role.items }, + }, + meta: { ...currentGeneral.meta }, + triggerState: { + ...currentGeneral.triggerState, + flags: { ...currentGeneral.triggerState.flags }, + counters: { ...currentGeneral.triggerState.counters }, + modifiers: { ...currentGeneral.triggerState.modifiers }, + meta: { ...currentGeneral.triggerState.meta }, + }, + }; + if (currentGeneral.npcState < 2) { + const lived = + typeof currentGeneral.meta.inherit_lived_month === 'number' + ? currentGeneral.meta.inherit_lived_month + : 0; + currentGeneral.meta.inherit_lived_month = lived + 1; + } + currentCity = currentCity ? { ...currentCity, meta: { ...currentCity.meta } } : currentCity; + const preTurnPipeline = new GeneralActionPipeline(env.generalActionModules ?? []); + const preTurnContext = createGeneralTriggerContext({ + general: currentGeneral, + nation: currentNation, + worldView: worldView ?? undefined, + rng: preprocessRng, + log: { + push: (message: string) => logs.push(createActionLog(message)), + }, + }); + preTurnPipeline.getPreTurnExecuteTriggerList(preTurnContext).fire(preTurnContext, baseConstraintEnv); + if (currentGeneral.injury > 0 && !preTurnContext.skill.has('pre.부상경감')) { + currentGeneral.injury = Math.max(0, currentGeneral.injury - 10); + preTurnContext.skill.activate('pre.부상경감'); + } + if (currentGeneral.crew >= 100) { + const consumeRice = Math.trunc(currentGeneral.crew / 100); + if (consumeRice <= currentGeneral.rice) { + currentGeneral.rice -= consumeRice; + } else { + const releasedCrew = preTurnPipeline.onCalcDomestic( + preTurnContext, + '징집인구', + 'score', + currentGeneral.crew + ); + if (currentCity) { + currentCity.population += releasedCrew; + } + currentGeneral.crew = 0; + currentGeneral.rice = 0; + logs.push(createActionLog('군량이 모자라 병사들이 소집해제되었습니다!')); + preTurnContext.skill.activate('pre.소집해제'); + } + preTurnContext.skill.activate('pre.병력군량소모'); + } + worldOverlay?.syncGeneral(currentGeneral); + if (currentCity) { + worldOverlay?.syncCity(currentCity); + } + + const blockCode = typeof currentGeneral.meta.block === 'number' ? Math.trunc(currentGeneral.meta.block) : 0; + const isBlocked = blockCode === 2 || blockCode === 3; + if (isBlocked) { + currentGeneral.meta.killturn = Math.max( + 0, + typeof currentGeneral.meta.killturn === 'number' ? currentGeneral.meta.killturn - 1 : 0 + ); + logs.push( + createActionLog( + blockCode === 2 + ? '현재 멀티, 또는 비매너로 인한블럭 대상자입니다.' + : '현재 악성유저로 분류되어 블럭 대상자입니다.' + ) + ); + } + + if (!isBlocked && currentNation && currentGeneral.officerLevel >= 5) { let nationCommand = options.reservedTurns.getNationTurn( currentNation.id, currentGeneral.officerLevel, @@ -1003,9 +1191,13 @@ export const createReservedTurnHandler = async (options: { }); options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1); } + if (isBlocked && currentNation && currentGeneral.officerLevel >= 5) { + options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1); + } let generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0); let generalAiState: ReturnType | undefined; + let generalAutorunMode = false; if (worldView && shouldUseAi(currentGeneral, context.world)) { const ai = new GeneralAI({ general: currentGeneral, @@ -1026,11 +1218,20 @@ export const createReservedTurnHandler = async (options: { }); const candidate = ai.chooseGeneralTurn(generalCommand); if (candidate) { + generalAutorunMode = + candidate.action !== generalCommand.action || + JSON.stringify(candidate.args ?? {}) !== JSON.stringify(generalCommand.args ?? {}); generalCommand = { action: candidate.action, args: candidate.args }; } generalAiState = ai.getDebugState(); } - const generalResult = runAction('general', generalDefinitions, generalFallback, generalCommand, true); + const generalResult = isBlocked + ? { + actionKey: DEFAULT_ACTION, + usedFallback: true, + blockedReason: '블럭 대상자입니다.', + } + : runAction('general', generalDefinitions, generalFallback, generalCommand, true); options.onActionResolved?.({ kind: 'general', generalId: currentGeneral.id, @@ -1044,20 +1245,42 @@ export const createReservedTurnHandler = async (options: { const nextTurnAt = generalResult.nextTurnAt; options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1); - const worldMeta = asRecord(context.world.meta); - if (currentGeneral.npcState < 2 && !(typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0)) { + if (!isBlocked) { const meta = { ...currentGeneral.meta }; - const lived = typeof meta.inherit_lived_month === 'number' ? meta.inherit_lived_month : 0; - const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0; - meta.inherit_lived_month = lived + 1; - if (generalResult.actionKey !== DEFAULT_ACTION) { - meta.inherit_active_action = active + 1; + const currentKillturn = + typeof meta.killturn === 'number' && Number.isFinite(meta.killturn) ? meta.killturn : 0; + const worldKillturn = readMetaNumber(asRecord(context.world.meta), 'killturn', currentKillturn); + const requestedRest = generalCommand.action === DEFAULT_ACTION; + if ( + currentGeneral.npcState >= 2 || + currentKillturn > worldKillturn || + generalAutorunMode || + requestedRest + ) { + meta.killturn = Math.max(0, currentKillturn - 1); } else { - meta.inherit_active_action = active; + meta.killturn = worldKillturn; } currentGeneral = { ...currentGeneral, meta }; worldOverlay?.syncGeneral(currentGeneral); } + currentGeneral = { + ...currentGeneral, + meta: { + ...currentGeneral.meta, + myset: Math.min( + 9, + (typeof currentGeneral.meta.myset === 'number' ? currentGeneral.meta.myset : 0) + 3 + ), + }, + }; + currentGeneral = { + ...currentGeneral, + triggerState: { + ...currentGeneral.triggerState, + flags: {}, + }, + }; const result: GeneralTurnResult = { general: currentGeneral, diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 499e80f..e3b266d 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -8,6 +8,7 @@ import type { Troop, UnitSetDefinition, WorldSnapshot, + GeneralLastTurn, } from '@sammo-ts/logic'; export interface TurnWorldState { @@ -22,6 +23,7 @@ export interface TurnWorldState { export interface TurnGeneral extends General { turnTime: Date; recentWarTime?: Date | null; + lastTurn?: GeneralLastTurn; } export interface TurnDiplomacy { diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 5fa9177..664abb2 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -11,6 +11,7 @@ import { import type { City, GeneralItemSlots, + GeneralLastTurn, Nation, ScenarioConfig, ScenarioMeta, @@ -40,6 +41,17 @@ type JsonRecord = Record; const asTriggerRecord = (value: unknown): Record => isRecord(value) ? (value as Record) : {}; +const normalizeGeneralLastTurn = (value: unknown): GeneralLastTurn => { + const raw = asRecord(value); + const arg = asRecord(raw.arg); + return { + command: typeof raw.command === 'string' ? raw.command : '휴식', + ...(Object.keys(arg).length > 0 ? { arg } : {}), + ...(typeof raw.term === 'number' && Number.isFinite(raw.term) ? { term: Math.floor(raw.term) } : {}), + ...(typeof raw.seq === 'number' && Number.isFinite(raw.seq) ? { seq: Math.floor(raw.seq) } : {}), + }; +}; + const normalizeCode = (value: string | null | undefined): string | null => { if (!value || value === 'None') { return null; @@ -195,6 +207,7 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => { meta: {}, }, itemInventory, + lastTurn: normalizeGeneralLastTurn(row.lastTurn), // meta는 상단에서 보장 처리됨. turnTime: row.turnTime, recentWarTime: row.recentWarTime ?? null, @@ -224,6 +237,7 @@ const mapCityRow = (row: TurnEngineCityRow): City => { defenceMax: row.defenceMax, wall: row.wall, wallMax: row.wallMax, + conflict: asTriggerRecord(row.conflict), meta: { ...meta, trust: row.trust, diff --git a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts new file mode 100644 index 0000000..7b83750 --- /dev/null +++ b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest'; +import type { TurnSchedule } from '@sammo-ts/logic/turn/calendar.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnTestHarness } from './helpers/turnTestHarness.js'; + +const start = new Date('0200-01-01T00:00:00.000Z'); +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; +const map = { + id: 'general-turn-test', + name: '장수턴 테스트', + cities: [ + { + id: 1, + name: '테스트성', + level: 1, + region: 1, + position: { x: 0, y: 0 }, + connections: [], + max: { + population: 50_000, + agriculture: 1_000, + commerce: 1_000, + security: 1_000, + defence: 1_000, + wall: 1_000, + }, + initial: { + population: 10_000, + agriculture: 500, + commerce: 500, + security: 500, + defence: 500, + wall: 500, + }, + }, + ], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, +}; + +const makeGeneral = (patch: Partial = {}): TurnGeneral => ({ + id: 1, + name: '테스트장수', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + experience: 0, + dedication: 0, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 2_000, + rice: 2_000, + crew: 0, + crewTypeId: 1, + train: 40, + atmos: 40, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + turnTime: start, + ...patch, +}); + +const makeSnapshot = (general: TurnGeneral): TurnWorldSnapshot => ({ + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { + develCost: 100, + trainDelta: 30, + atmosDelta: 30, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + initialNationGenLimit: 10, + }, + environment: { mapName: map.id, unitSet: 'test' }, + }, + scenarioMeta: { + title: '장수턴 테스트', + startYear: 200, + life: null, + fiction: 0, + history: [], + ignoreDefaultEvents: false, + }, + map, + unitSet: { id: 'test', name: 'test', crewTypes: [] }, + nations: [ + { + id: 1, + name: '테스트국', + color: '#000000', + capitalCityId: 1, + chiefGeneralId: null, + gold: 10_000, + rice: 10_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: { gennum: 1, tech: 0 }, + }, + ], + cities: [ + { + id: 1, + name: '테스트성', + nationId: 1, + level: 1, + state: 0, + population: 10_000, + populationMax: 50_000, + agriculture: 500, + agricultureMax: 1_000, + commerce: 500, + commerceMax: 1_000, + security: 500, + securityMax: 1_000, + supplyState: 1, + frontState: 0, + defence: 500, + defenceMax: 1_000, + wall: 500, + wallMax: 1_000, + meta: { trust: 50, trade: 100, region: 1 }, + }, + ], + generals: [general], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], +}); + +const makeState = (): TurnWorldState => ({ + id: 1, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: start, + meta: { killturn: 24 }, +}); + +describe('legacy general-turn execution contract', () => { + it('runs injury recovery and troop rice consumption before the reserved command', async () => { + const general = makeGeneral({ injury: 25, crew: 200, rice: 1 }); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot(general), + state: makeState(), + schedule, + map, + collectLogs: true, + }); + await harness.runOneTick(); + + const updated = harness.world.getGeneralById(1)!; + expect(updated.injury).toBe(15); + expect(updated.crew).toBe(0); + expect(updated.rice).toBe(0); + expect(harness.world.getCityById(1)!.population).toBe(10_200); + expect(harness.getCollectedLogs().some((log) => log.text.includes('소집해제'))).toBe(true); + }); + + it('persists pre-turn stacking and applies the inherited 60-turn cooldown', async () => { + const general = makeGeneral({ + role: { + personality: null, + specialDomestic: null, + specialWar: 'che_격노', + items: { horse: null, weapon: null, book: null, item: null }, + }, + }); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot(general), + state: makeState(), + schedule, + map, + collectLogs: true, + }); + const turns = harness.reservedTurnStore.getGeneralTurns(1); + turns[0] = { action: 'che_전투특기초기화', args: {} }; + turns[1] = { action: 'che_전투특기초기화', args: {} }; + + await harness.runOneTick(); + expect(harness.world.getGeneralById(1)!.lastTurn).toEqual({ + command: '전투 특기 초기화', + term: 1, + }); + expect(harness.world.getGeneralById(1)!.role.specialWar).toBe('che_격노'); + + await harness.runOneTick(); + const updated = harness.world.getGeneralById(1)!; + expect(updated.role.specialWar).toBeNull(); + expect(updated.meta['next_execute_전투 특기 초기화']).toBe(2460); + expect(updated.meta.prev_types_special2).toEqual(['che_격노']); + }); + + it('preserves the legacy battle-readiness term reset instead of making its reward reachable', async () => { + const general = makeGeneral({ crew: 1_000, train: 40, atmos: 40 }); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot(general), + state: makeState(), + schedule, + map, + collectLogs: true, + }); + const turns = harness.reservedTurnStore.getGeneralTurns(1); + for (let idx = 0; idx < 4; idx += 1) { + turns[idx] = { action: 'che_전투태세', args: {} }; + } + + for (let idx = 0; idx < 4; idx += 1) { + await harness.runOneTick(); + } + + const updated = harness.world.getGeneralById(1)!; + expect(updated.lastTurn).toEqual({ command: '전투태세', term: 1 }); + expect(updated.experience).toBe(0); + expect(updated.train).toBe(40); + expect(updated.atmos).toBe(40); + }); + + it('skips commands for blocked generals while advancing the queue once', async () => { + const general = makeGeneral({ injury: 20, meta: { killturn: 5, block: 3 } }); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot(general), + state: makeState(), + schedule, + map, + collectLogs: true, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_요양', args: {} }; + await harness.runOneTick(); + + const updated = harness.world.getGeneralById(1)!; + expect(updated.injury).toBe(10); + expect(updated.experience).toBe(0); + expect(updated.meta.killturn).toBe(4); + expect(updated.meta.inherit_lived_month).toBe(1); + expect(updated.meta.myset).toBe(3); + expect(harness.reservedTurnStore.getGeneralTurn(1, 0).action).toBe('휴식'); + expect(harness.getCollectedLogs().some((log) => log.text.includes('악성유저'))).toBe(true); + }); +}); diff --git a/app/game-engine/test/npcGeneralDomesticTurn.test.ts b/app/game-engine/test/npcGeneralDomesticTurn.test.ts index a6e6948..cbdd1b1 100644 --- a/app/game-engine/test/npcGeneralDomesticTurn.test.ts +++ b/app/game-engine/test/npcGeneralDomesticTurn.test.ts @@ -287,7 +287,8 @@ describe('NPC 일반 내정 턴', () => { const afterCity = world.getCityById(1)!; expect(afterCity).toMatchObject({ ...beforeStats, - security: 1050, + // 레거시 내정 수식과 고정 seed의 치명/실패 배율을 적용한 값. + security: 1063, }); expect(world.getGeneralById(1)!.turnTime.getTime()).toBe(addMinutes(mockDate, 10).getTime()); }); diff --git a/app/game-engine/test/reservedTurnExecution.test.ts b/app/game-engine/test/reservedTurnExecution.test.ts index 7f5f71e..60acb10 100644 --- a/app/game-engine/test/reservedTurnExecution.test.ts +++ b/app/game-engine/test/reservedTurnExecution.test.ts @@ -306,11 +306,11 @@ describe('Reserved Turn Execution Integration', () => { // Gen 2 stayed in City 1? expect(finalGen2.cityId).toBe(1); - // City 1 Agric increased (100 -> 300) - expect(finalCity1.agriculture).toBeGreaterThanOrEqual(300); + // 레거시 내정식은 고정 증가량이 아니라 능력치·경험·난수·치명 배율을 사용한다. + expect(finalCity1.agriculture).toBe(206); - // City 1 Commerce increased (100 -> ~178) - expect(finalCity1.commerce).toBeGreaterThanOrEqual(170); + // 레거시 상업 투자식(지력·민심·경험·난수·치명 배율)을 고정 seed로 계산한 결과. + expect(finalCity1.commerce).toBe(124); // Gen 1 reserved turns should be shifted and empty/default const gen1Turns = reservedTurnStore.getGeneralTurns(1); @@ -422,9 +422,7 @@ describe('Reserved Turn Execution Integration', () => { meta: {}, }; - const invalidRows = [ - { generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 'bad' } }, - ]; + const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 'bad' } }]; const mockPrisma = createMockPrisma(invalidRows); const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, { @@ -585,9 +583,7 @@ describe('Reserved Turn Execution Integration', () => { meta: {}, }; - const invalidRows = [ - { generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 1 } }, - ]; + const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 1 } }]; const mockPrisma = createMockPrisma(invalidRows); const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, { diff --git a/package.json b/package.json index 39afe6a..cd3143c 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "build:server": "pnpm --filter ./tools/build-scripts build:server --", "generate:resource-schemas": "pnpm --filter @sammo-ts/tools-scripts generate:resource-schemas", "validate:resources": "pnpm --filter @sammo-ts/tools-scripts validate:resources", - "check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check" + "check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check", + "check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index ffd2b44..42e37a6 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -42,6 +42,7 @@ export interface TurnEngineGeneralRow { atmos: number; age: number; npcState: number; + lastTurn: JsonValue; meta: JsonValue; turnTime: Date; recentWarTime: Date | null; @@ -66,6 +67,7 @@ export interface TurnEngineCityRow { defenceMax: number; wall: number; wallMax: number; + conflict: JsonValue; meta: JsonValue; trust: number; trade: number; @@ -166,6 +168,7 @@ export interface TurnEngineGeneralUpdateInput { personalCode: string; specialCode: string; special2Code: string; + lastTurn: InputJsonValue; meta: InputJsonValue; turnTime: Date; recentWarTime: Date | null; @@ -228,6 +231,7 @@ export interface TurnEngineCityUpdateInput { defenceMax: number; wall: number; wallMax: number; + conflict?: InputJsonValue; meta: InputJsonValue; trust?: number; trade?: number; diff --git a/packages/logic/src/actions/definition.ts b/packages/logic/src/actions/definition.ts index 5db420c..f228a0d 100644 --- a/packages/logic/src/actions/definition.ts +++ b/packages/logic/src/actions/definition.ts @@ -18,6 +18,8 @@ export interface GeneralActionDefinition< getPreReqTurn?(context: Context, args: Args): number; getPostReqTurn?(context: Context, args: Args): number; getStackSequence?(context: Context, args: Args): number | null; + getProgressText?(context: Context, args: Args, term: number, termMax: number): string; readonly countsAsInheritanceActiveAction?: boolean; + getInheritanceActiveActionAmount?(context: Context, args: Args): number; resolve(context: Context, args: Args): GeneralActionOutcome; } diff --git a/packages/logic/src/actions/turn/actionContext.ts b/packages/logic/src/actions/turn/actionContext.ts index 6f423a0..24818e5 100644 --- a/packages/logic/src/actions/turn/actionContext.ts +++ b/packages/logic/src/actions/turn/actionContext.ts @@ -28,6 +28,7 @@ export interface ActionContextWorldState { currentYear: number; currentMonth: number; tickSeconds: number; + meta?: Record; } export interface ActionContextWorldRef { diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index 3f675e2..0a0b823 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -19,6 +19,7 @@ export interface TurnCommandEnv { minAvailableRecruitPop?: number; trainDelta: number; atmosDelta: number; + trainSideEffectByAtmosTurn?: number; maxTrainByCommand: number; maxAtmosByCommand: number; sabotageDefaultProb: number; @@ -35,6 +36,9 @@ export interface TurnCommandEnv { defaultSpecialWar: string | null; initialNationGenLimit: number; maxTechLevel: number; + maxStatLevel?: number; + techLevelIncYear?: number; + initialAllowedTechLevel?: number; baseGold: number; baseRice: number; maxResourceActionAmount: number; diff --git a/packages/logic/src/actions/turn/general/che_강행.ts b/packages/logic/src/actions/turn/general/che_강행.ts index 45202ff..1718890 100644 --- a/packages/logic/src/actions/turn/general/che_강행.ts +++ b/packages/logic/src/actions/turn/general/che_강행.ts @@ -1,11 +1,6 @@ import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; -import { - notSameDestCity, - nearCity, - reqGeneralGold, - reqGeneralRice, -} from '@sammo-ts/logic/constraints/presets.js'; +import { notSameDestCity, nearCity, reqGeneralGold, reqGeneralRice } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, @@ -13,8 +8,8 @@ import type { GeneralActionResolver, GeneralActionEffect, } from '@sammo-ts/logic/actions/engine.js'; -import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js'; -import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js'; +import { createGeneralPatchEffect, createLogEffect } 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 { z } from 'zod'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; @@ -125,16 +120,6 @@ export class ActionResolver< if (isRoamingLeader && moveTargets.length > 1) { for (const target of moveTargets) { if (target.id === general.id) continue; - - // Legacy: "방랑군 세력이 {$destCityName}{$josaRo} 강행했습니다." (LOG_PLAIN) - // NOTE: We need a way to push log to OTHER general's logger. - // In current engine, we return effects. Log effects? - // Currently addLog attaches provided logs to turnLog (which is for the actor). - // To log for OTHERS, we might need specific effect or handle it differently. - // For now, I will omit logs for others or use a special effect if available. - // The legacy TS porting pattern for "others" logs isn't fully standardized yet in shared snippets. - // Assuming createGeneralPatchEffect handles state. Logs for others might be missing in this iteration unless I find `createLogEffect`. - effects.push( createGeneralPatchEffect( { @@ -142,7 +127,13 @@ export class ActionResolver< cityId: destCityId, }, target.id - ) + ), + createLogEffect(`방랑군 세력이 ${destCityName}${josaRo} 강행했습니다.`, { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + generalId: target.id, + }) ); } } @@ -188,11 +179,12 @@ export class ActionDefinition< } export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const rawCost = options.scenarioConfig.const.develCost ?? options.scenarioConfig.const.develcost; return { ...base, moveGenerals: options.worldRef?.listGenerals() ?? [], map: options.map, - startDevelCost: options.scenarioConfig.const.develCost as number | undefined, + startDevelCost: typeof rawCost === 'number' ? rawCost : 0, }; }; diff --git a/packages/logic/src/actions/turn/general/che_거병.ts b/packages/logic/src/actions/turn/general/che_거병.ts index 327feef..9acb127 100644 --- a/packages/logic/src/actions/turn/general/che_거병.ts +++ b/packages/logic/src/actions/turn/general/che_거병.ts @@ -1,10 +1,16 @@ +import { asRecord, JosaUtil } from '@sammo-ts/common'; import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { allowJoinAction, beNeutral, beOpeningPart, noPenalty } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; -import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; +import type { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, +} from '@sammo-ts/logic/actions/engine.js'; import { createGeneralPatchEffect, + createDiplomacyPatchEffect, createNationAddEffect, } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js'; @@ -13,11 +19,13 @@ import type { ActionContextBuilder, ActionContextBase } from '@sammo-ts/logic/ac import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; -export interface UprisingArgs { } +export interface UprisingArgs {} export interface UprisingContext extends ActionContextBase { createNationId: () => number; listNations?: () => Nation[]; + scenarioId: number; + baseRice: number; } const ACTION_NAME = '거병'; @@ -27,6 +35,9 @@ export class ActionDefinition< > implements GeneralActionDefinition { public readonly key = 'che_거병'; public readonly name = ACTION_NAME; + getInheritanceActiveActionAmount(): number { + return 1; + } parseArgs(_raw: unknown): UprisingArgs | null { return {}; @@ -48,7 +59,7 @@ export class ActionDefinition< } const newNationId = uprisingCtx.createNationId(); - const josaYi = '이'; // Mock JodaUtil.pick + const josaYi = JosaUtil.pick(general.name, '이'); let nationName = general.name; const nations = uprisingCtx.listNations ? uprisingCtx.listNations() : []; @@ -80,14 +91,14 @@ export class ActionDefinition< capitalCityId: null, chiefGeneralId: general.id, gold: 0, - rice: 2000, + rice: uprisingCtx.baseRice, power: 0, meta: { rate: 20, bill: 100, strategic_cmd_limit: 12, surlimit: 72, - secretlimit: 3, + secretlimit: uprisingCtx.scenarioId >= 1000 ? 1 : 3, gennum: 1, ...(npcNationPolicy ? { npc_nation_policy: npcNationPolicy } : {}), }, @@ -118,25 +129,45 @@ export class ActionDefinition< tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); - const effects = [ + const effects: GeneralActionEffect[] = [ createNationAddEffect(newNation), createGeneralPatchEffect({ nationId: newNationId, officerLevel: 12, experience: (general.experience || 0) + 100, dedication: (general.dedication || 0) + 100, + meta: { + ...general.meta, + belong: 1, + officer_city: 0, + }, }), ]; + for (const nation of nations) { + if (nation.id === newNationId) { + continue; + } + effects.push( + createDiplomacyPatchEffect(nation.id, newNationId, { state: 2, term: 0 }), + createDiplomacyPatchEffect(newNationId, nation.id, { state: 2, term: 0 }) + ); + } return { effects }; } } export const actionContextBuilder: ActionContextBuilder = (base, options) => { + const worldMeta = asRecord(options.world.meta); + const constValues = asRecord(options.scenarioConfig.const); + const scenarioRaw = worldMeta.scenarioId ?? worldMeta.scenario; + const baseRiceRaw = constValues.baseRice ?? constValues.baserice; return { ...base, createNationId: options.createNationId, listNations: () => options.worldRef?.listNations() ?? [], + scenarioId: typeof scenarioRaw === 'number' && Number.isFinite(scenarioRaw) ? scenarioRaw : 0, + baseRice: typeof baseRiceRaw === 'number' && Number.isFinite(baseRiceRaw) ? baseRiceRaw : 2_000, }; }; diff --git a/packages/logic/src/actions/turn/general/che_건국.ts b/packages/logic/src/actions/turn/general/che_건국.ts index 8b9ef3d..d099d2d 100644 --- a/packages/logic/src/actions/turn/general/che_건국.ts +++ b/packages/logic/src/actions/turn/general/che_건국.ts @@ -76,13 +76,20 @@ export class ActionDefinition< > implements GeneralActionDefinition { public readonly key = 'che_건국'; public readonly name = ACTION_NAME; + getInheritanceActiveActionAmount(): number { + return 1; + } parseArgs(raw: unknown): FoundingArgs | null { return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildMinConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] { - return [beOpeningPart(), reqNationValue('level', '국가규모', '==', 0, '정식 국가가 아니어야합니다.'), noPenalty('noFoundNation')]; + return [ + beOpeningPart(), + reqNationValue('level', '국가규모', '==', 0, '정식 국가가 아니어야합니다.'), + noPenalty('noFoundNation'), + ]; } buildConstraints(_ctx: ConstraintContext, args: FoundingArgs): Constraint[] { diff --git a/packages/logic/src/actions/turn/general/che_군량매매.ts b/packages/logic/src/actions/turn/general/che_군량매매.ts index c50fc38..5bce071 100644 --- a/packages/logic/src/actions/turn/general/che_군량매매.ts +++ b/packages/logic/src/actions/turn/general/che_군량매매.ts @@ -19,6 +19,7 @@ import { parseArgsWithSchema } from '../parseArgs.js'; export interface TradeEnvironment { exchangeFee?: number; + maxResourceActionAmount?: number; } const ACTION_NAME = '군량매매'; @@ -41,7 +42,13 @@ export class ActionDefinition< } parseArgs(raw: unknown): TradeArgs | null { - return parseArgsWithSchema(ARGS_SCHEMA, raw); + const parsed = parseArgsWithSchema(ARGS_SCHEMA, raw); + if (!parsed || !Number.isFinite(parsed.amount)) { + return null; + } + const maxAmount = this.env.maxResourceActionAmount ?? 10_000; + const amount = Math.max(100, Math.min(Math.round(parsed.amount / 100) * 100, maxAmount)); + return { buyRice: parsed.buyRice, amount }; } buildMinConstraints(_ctx: ConstraintContext, _args: TradeArgs): Constraint[] { @@ -69,13 +76,13 @@ export class ActionDefinition< const rate = tradeRate / 100; const fee = this.env.exchangeFee ?? DEFAULT_EXCHANGE_FEE; - let buyAmount = 0; - let sellAmount = 0; - let tax = 0; + let tax: number; if (args.buyRice) { const requestedSell = Math.min(args.amount * rate, general.gold); tax = requestedSell * fee; + let sellAmount: number; + let buyAmount: number; if (requestedSell + tax > general.gold) { sellAmount = general.gold; tax = sellAmount * (fee / (1 + fee)); @@ -85,8 +92,8 @@ export class ActionDefinition< sellAmount = requestedSell + tax; buyAmount = args.amount; } - general.gold = Math.max(0, general.gold - sellAmount); - general.rice += buyAmount; + general.gold = Math.max(0, Math.round(general.gold - sellAmount)); + general.rice = Math.round(general.rice + buyAmount); context.addLog( `군량 ${Math.round(buyAmount).toLocaleString()}을 사서 자금 ${Math.round( sellAmount @@ -94,12 +101,12 @@ export class ActionDefinition< { format: LogFormat.PLAIN } ); } else { - sellAmount = Math.min(args.amount, general.rice); + const sellAmount = Math.min(args.amount, general.rice); const grossBuy = sellAmount * rate; tax = grossBuy * fee; - buyAmount = grossBuy - tax; - general.rice = Math.max(0, general.rice - sellAmount); - general.gold += buyAmount; + const buyAmount = grossBuy - tax; + general.rice = Math.max(0, Math.round(general.rice - sellAmount)); + general.gold = Math.round(general.gold + buyAmount); context.addLog( `군량 ${Math.round(sellAmount).toLocaleString()}을 팔아 자금 ${Math.round( buyAmount @@ -112,12 +119,30 @@ export class ActionDefinition< if (context.nation) { const nation = context.nation; const currentGold = (nation.gold as number) ?? 0; - nation.gold = currentGold + tax; + nation.gold = currentGold + Math.trunc(tax); } // 경험치 및 명성 증가 general.experience += 30; general.dedication += 50; + const weightedStats = [ + ['leadership_exp', general.stats.leadership], + ['strength_exp', general.stats.strength], + ['intel_exp', general.stats.intelligence], + ] as const; + const totalWeight = weightedStats.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0); + let pick = context.rng.nextFloat1() * totalWeight; + let statKey: (typeof weightedStats)[number][0] = weightedStats[weightedStats.length - 1]![0]; + for (const [key, weight] of weightedStats) { + if (pick <= Math.max(0, weight)) { + statKey = key; + break; + } + pick -= Math.max(0, weight); + } + const statRaw = general.meta[statKey]; + const statExp = typeof statRaw === 'number' ? statRaw : 0; + general.meta[statKey] = statExp + 1; tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); @@ -136,5 +161,8 @@ export const commandSpec: GeneralTurnCommandSpec = { amount: 'number', }, argsSchema: ARGS_SCHEMA, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition({ + maxResourceActionAmount: env.maxResourceActionAmount, + }), }; diff --git a/packages/logic/src/actions/turn/general/che_기술연구.ts b/packages/logic/src/actions/turn/general/che_기술연구.ts index e9bc098..f48b0f8 100644 --- a/packages/logic/src/actions/turn/general/che_기술연구.ts +++ b/packages/logic/src/actions/turn/general/che_기술연구.ts @@ -1,5 +1,5 @@ import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; -import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; +import type { Constraint, ConstraintContext, RequirementKey, StateView } from '@sammo-ts/logic/constraints/types.js'; import { notBeNeutral, notWanderingNation, @@ -9,106 +9,153 @@ import { suppliedCity, } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; -import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; +import type { GeneralActionOutcome } from '@sammo-ts/logic/actions/engine.js'; +import type { + ActionContextBase, + ActionContextBuilder, + ActionContextOptions, +} from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; +import { + buildDomesticContextFromView, + CommandResolver, + type DomesticActionContext, + type InvestmentConfig, +} from './che_상업투자.js'; import { JosaUtil } from '@sammo-ts/common'; +import { clamp } from 'es-toolkit'; export interface TechResearchArgs {} -export interface TechResearchEnvironment { - costGold?: number; - techDelta?: number; - maxTechLevel?: number; +interface TechResearchContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends DomesticActionContext { + nation: Nation; + nationGeneralCount: number; } const ACTION_NAME = '기술 연구'; -const DEFAULT_TECH_DELTA = 1; +const CONFIG: InvestmentConfig = { + key: 'che_기술연구', + name: ACTION_NAME, + actionKey: '기술', + statKey: 'intelligence', + statExpKey: 'intel_exp', + cityKey: 'commerce', + cityMaxKey: 'commerceMax', + frontDebuff: 1, +}; -const readTech = (nation: Nation | null | undefined): number => { - if (!nation) { - return 0; - } +const readTech = (nation: Nation): number => { const tech = nation.meta.tech; - return typeof tech === 'number' ? tech : 0; + return typeof tech === 'number' && Number.isFinite(tech) ? tech : 0; }; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition { - public readonly key = 'che_기술연구'; +> implements GeneralActionDefinition> { + public readonly key = CONFIG.key; public readonly name = ACTION_NAME; - private readonly env: TechResearchEnvironment; + private readonly command: CommandResolver; - constructor(env: TechResearchEnvironment = {}) { - this.env = env; + constructor(private readonly env: TurnCommandEnv) { + this.command = new CommandResolver(env.generalActionModules ?? [], env, CONFIG); } parseArgs(_raw: unknown): TechResearchArgs | null { - void _raw; return {}; } - buildConstraints(_ctx: ConstraintContext, _args: TechResearchArgs): Constraint[] { - const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => this.env.costGold ?? 0; + buildConstraints(ctx: ConstraintContext, _args: TechResearchArgs): Constraint[] { + const requirements: RequirementKey[] = []; + if (ctx.cityId !== undefined) requirements.push({ kind: 'city', id: ctx.cityId }); + if (ctx.nationId !== undefined) requirements.push({ kind: 'nation', id: ctx.nationId }); + const getCost = (context: ConstraintContext, view: StateView): number => { + const domesticContext = buildDomesticContextFromView(context, view); + return domesticContext ? this.command.getCost(domesticContext).gold : 0; + }; return [ notBeNeutral(), notWanderingNation(), occupiedCity(), suppliedCity(), - reqGeneralGold(getRequiredGold), - reqGeneralRice(() => 0), + reqGeneralGold(getCost, requirements), + reqGeneralRice(() => 0, requirements), ]; } - resolve( - context: GeneralActionResolveContext, - _args: TechResearchArgs - ): GeneralActionOutcome { - const general = context.general; - const nation = context.nation; - if (!nation) { - context.addLog('국가 정보를 찾지 못했습니다.'); - return { effects: [] }; + resolve(context: TechResearchContext, _args: TechResearchArgs): GeneralActionOutcome { + const result = this.command.resolve(context, context.rng); + let techScore = result.score; + const currentTech = readTech(context.nation); + const relYear = context.relYear ?? 0; + const techLevelIncYear = this.env.techLevelIncYear ?? 5; + const initialAllowedTechLevel = this.env.initialAllowedTechLevel ?? 1; + const relativeMaxTech = clamp( + Math.floor(relYear / techLevelIncYear) + initialAllowedTechLevel, + 1, + this.env.maxTechLevel + ); + const currentTechLevel = clamp(Math.floor(currentTech / 1000), 0, this.env.maxTechLevel); + if (currentTechLevel >= relativeMaxTech) { + techScore /= 4; } - const delta = this.env.techDelta ?? DEFAULT_TECH_DELTA; - const currentTech = readTech(nation); - const maxTech = - typeof this.env.maxTechLevel === 'number' && this.env.maxTechLevel > 0 - ? this.env.maxTechLevel - : currentTech + delta; - const nextTech = Math.min(currentTech + delta, maxTech); - const applied = nextTech - currentTech; - const costGold = this.env.costGold ?? 0; + context.nation.meta = { + ...context.nation.meta, + tech: currentTech + techScore / Math.max(context.nationGeneralCount, this.env.initialNationGenLimit), + }; + context.general.gold = Math.max(0, context.general.gold - result.costGold); + context.general.experience += result.exp; + context.general.dedication += result.dedication; + const intelExp = typeof context.general.meta.intel_exp === 'number' ? context.general.meta.intel_exp : 0; + context.general.meta = { + ...context.general.meta, + intel_exp: intelExp + 1, + max_domestic_critical: result.pick === 'success' ? result.score : 0, + }; - // 직접 수정 (Immer Draft) - nation.meta = { ...nation.meta, tech: nextTech }; - general.gold = Math.max(0, general.gold - costGold); - - const logName = ACTION_NAME.replace(' ', ''); - const josaUl = JosaUtil.pick(logName, '을'); - const scoreText = applied.toLocaleString(); - context.addLog(`${logName}${josaUl} 하여 ${scoreText} 상승했습니다.`); + const scoreText = Math.round(result.score).toLocaleString(); + const josaUl = JosaUtil.pick(ACTION_NAME, '을'); + if (result.pick === 'fail') { + context.addLog( + `${ACTION_NAME}${josaUl} 실패하여 ${scoreText} 상승했습니다.` + ); + } else if (result.pick === 'success') { + context.addLog(`${ACTION_NAME}${josaUl} 성공하여 ${scoreText} 상승했습니다.`); + } else { + context.addLog(`${ACTION_NAME}${josaUl} 하여 ${scoreText} 상승했습니다.`); + } tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); - return { effects: [] }; } } -// 예약 턴 실행은 기본 컨텍스트만 사용한다. -export const actionContextBuilder = defaultActionContextBuilder; +export const actionContextBuilder: ActionContextBuilder = ( + base: ActionContextBase, + options: ActionContextOptions +): (ActionContextBase & Record) | null => { + if (!base.city || !base.nation || !options.worldRef) { + return null; + } + return { + ...base, + city: base.city, + nation: base.nation, + nationGeneralCount: options.worldRef.listGenerals().filter((general) => general.nationId === base.nation!.id) + .length, + relYear: + typeof options.scenarioMeta?.startYear === 'number' + ? options.world.currentYear - options.scenarioMeta.startYear + : 0, + }; +}; export const commandSpec: GeneralTurnCommandSpec = { key: 'che_기술연구', category: '내정', reqArg: false, - - createDefinition: (env: TurnCommandEnv) => - new ActionDefinition({ - costGold: env.develCost, - maxTechLevel: env.maxTechLevel, - }), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_내정특기초기화.ts b/packages/logic/src/actions/turn/general/che_내정특기초기화.ts index dcb21fb..e01eec7 100644 --- a/packages/logic/src/actions/turn/general/che_내정특기초기화.ts +++ b/packages/logic/src/actions/turn/general/che_내정특기초기화.ts @@ -7,6 +7,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { setMetaNumber } from '@sammo-ts/logic/war/utils.js'; +import { DOMESTIC_TRAIT_KEYS } from '@sammo-ts/logic/triggers/special/domestic/index.js'; export interface ResetSpecialDomesticArgs {} @@ -37,6 +38,23 @@ export class ActionDefinition< public readonly key = 'che_내정특기초기화'; public readonly name = ACTION_NAME; + getPreReqTurn(): number { + return 1; + } + + getPostReqTurn(): number { + return 60; + } + + getProgressText( + _context: GeneralActionResolveContext, + _args: ResetSpecialDomesticArgs, + term: number, + termMax: number + ): string { + return `새로운 적성을 찾는 중... (${term}/${termMax})`; + } + parseArgs(_raw: unknown): ResetSpecialDomesticArgs | null { void _raw; return {}; @@ -55,6 +73,15 @@ export class ActionDefinition< _args: ResetSpecialDomesticArgs ): GeneralActionOutcome { const general = context.general; + const previous = general.meta.prev_types_special; + const previousTypes = Array.isArray(previous) + ? previous.filter((value): value is string => typeof value === 'string') + : []; + const nextPreviousTypes = [...previousTypes, general.role.specialDomestic!]; + general.meta.prev_types_special = + nextPreviousTypes.length === DOMESTIC_TRAIT_KEYS.length + ? [general.role.specialDomestic!] + : nextPreviousTypes; general.role.specialDomestic = null; setMetaNumber(general.meta, 'specAge', general.age + 1); context.addLog('새로운 내정 특기를 가질 준비가 되었습니다.'); diff --git a/packages/logic/src/actions/turn/general/che_농지개간.ts b/packages/logic/src/actions/turn/general/che_농지개간.ts index 8c0f668..8839f57 100644 --- a/packages/logic/src/actions/turn/general/che_농지개간.ts +++ b/packages/logic/src/actions/turn/general/che_농지개간.ts @@ -1,34 +1,31 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; -import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; +import { ActionDefinition as DomesticActionDefinition, actionContextBuilder } from './che_상업투자.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends CityDevelopmentActionDefinition { - constructor(env: { develCost?: number; amount?: number } = {}) { - super( - { - key: 'che_농지개간', - name: '농지 개간', - statKey: 'agriculture', - maxKey: 'agricultureMax', - label: '농업', - baseAmount: 100, - }, - env - ); +> extends DomesticActionDefinition { + constructor(env: TurnCommandEnv) { + super(env.generalActionModules ?? [], env, { + key: 'che_농지개간', + name: '농지 개간', + actionKey: '농업', + statKey: 'intelligence', + statExpKey: 'intel_exp', + cityKey: 'agriculture', + cityMaxKey: 'agricultureMax', + frontDebuff: 0.5, + }); } } -// 예약 턴 실행은 기본 컨텍스트만 사용한다. -export const actionContextBuilder = defaultActionContextBuilder; +export { actionContextBuilder }; export const commandSpec: GeneralTurnCommandSpec = { key: 'che_농지개간', category: '내정', reqArg: false, - createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_등용.ts b/packages/logic/src/actions/turn/general/che_등용.ts index 5d49d55..fa79526 100644 --- a/packages/logic/src/actions/turn/general/che_등용.ts +++ b/packages/logic/src/actions/turn/general/che_등용.ts @@ -98,8 +98,7 @@ export class ActionResolver< const env = ctx.env; const develCost = env?.develCost ?? 100; - const extraCost = Math.floor((destGeneral.experience + destGeneral.dedication) / 1000) * 10; - const reqGold = develCost + extraCost; + const reqGold = Math.round(develCost + (destGeneral.experience + destGeneral.dedication) / 1000) * 10; const effects: GeneralActionEffect[] = []; @@ -185,7 +184,7 @@ export class ActionDefinition< (_c, view) => { const dest = view.get({ kind: 'destGeneral', id: args.destGeneralId }) as General | null; if (!dest) return develCost; - return develCost + Math.floor((dest.experience + dest.dedication) / 1000) * 10; + return Math.round(develCost + (dest.experience + dest.dedication) / 1000) * 10; }, [{ kind: 'destGeneral', id: args.destGeneralId }] ), diff --git a/packages/logic/src/actions/turn/general/che_등용수락.ts b/packages/logic/src/actions/turn/general/che_등용수락.ts index 6b0038a..8c739ef 100644 --- a/packages/logic/src/actions/turn/general/che_등용수락.ts +++ b/packages/logic/src/actions/turn/general/che_등용수락.ts @@ -94,8 +94,8 @@ export class ActionResolver< // 3. Betrayal Logic // Legacy: GameConst::$defaultGold (usually 1000/2000). - const safeGold = this.env.baseGold || 1000; - const safeRice = this.env.baseRice || 1000; + const safeGold = this.env.defaultNpcGold; + const safeRice = this.env.defaultNpcRice; let newGold = general.gold; let newRice = general.rice; @@ -137,7 +137,7 @@ export class ActionResolver< // Apply penalty newExp = Math.floor(newExp * Math.max(0, penaltyFactor)); newDed = Math.floor(newDed * Math.max(0, penaltyFactor)); - newBetray += 1; + newBetray = Math.min(9, newBetray + 1); } else { // Neutral -> Join: Grant Bonus newExp += 100; @@ -161,13 +161,65 @@ export class ActionResolver< troopId: 0, // Quit troop meta: { ...general.meta, + belong: 1, + permission: 'normal', officer_city: 0, betray: newBetray, + ...(general.npcState < 2 + ? { + killturn: + typeof context.general.meta.killturn === 'number' + ? context.general.meta.killturn + : 0, + } + : {}), }, }, general.id ) ); + if (currentNation && currentNation.id !== 0) { + const currentCount = typeof currentNation.meta.gennum === 'number' ? currentNation.meta.gennum : 0; + effects.push( + createNationPatchEffect( + { + meta: { + ...currentNation.meta, + gennum: Math.max(0, currentCount - 1), + }, + }, + currentNation.id + ) + ); + } + const destCount = typeof destNation.meta.gennum === 'number' ? destNation.meta.gennum : 0; + effects.push( + createNationPatchEffect( + { + meta: { + ...destNation.meta, + gennum: destCount + 1, + }, + }, + destNation.id + ) + ); + context.addLog(`${destNationName}${josaRo} 망명`, { + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }); + context.addLog(`${generalName} 등용에 성공했습니다.`, { + scope: LogScope.GENERAL, + generalId: destGeneral.id, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + }); + context.addLog(`${generalName} 등용에 성공`, { + scope: LogScope.GENERAL, + generalId: destGeneral.id, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }); return { effects }; } @@ -178,6 +230,9 @@ export class ActionDefinition< > implements GeneralActionDefinition> { public readonly key = ACTION_KEY; public readonly name = ACTION_NAME; + getInheritanceActiveActionAmount(): number { + return 1; + } private readonly resolver: ActionResolver; constructor(env: TurnCommandEnv) { diff --git a/packages/logic/src/actions/turn/general/che_랜덤임관.ts b/packages/logic/src/actions/turn/general/che_랜덤임관.ts index 2f2aa6d..7ada210 100644 --- a/packages/logic/src/actions/turn/general/che_랜덤임관.ts +++ b/packages/logic/src/actions/turn/general/che_랜덤임관.ts @@ -9,7 +9,7 @@ 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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; @@ -23,6 +23,7 @@ interface CandidateNation[]; generalCount: number; monarchCityId: number | null; + monarchAffinity: number; } export interface RandomAppointmentResolveContext< @@ -31,6 +32,7 @@ export interface RandomAppointmentResolveContext< candidateNations: Array>; relYear: number; initialNationGenLimit: number; + historicalNpcAffinityMode: boolean; } const ACTION_NAME = '무작위 국가로 임관'; @@ -115,7 +117,11 @@ const calcNationPower = (generals: Gen const leadership = general.stats.leadership; if (leadership >= 40) { const coef = general.npcState < 2 ? 1.15 : 1; - warPower += coef * leadership; + const killCrew = + resolveNumber(asRecord(general.meta), ['rank_killcrew_person', 'killcrew_person'], 0) + 50_000; + const deathCrew = + resolveNumber(asRecord(general.meta), ['rank_deathcrew_person', 'deathcrew_person'], 0) + 50_000; + warPower += (killCrew / deathCrew) * coef * leadership; } const base = Math.sqrt(general.stats.intelligence * general.stats.strength) * 2 + leadership / 2; develPower += base / 5; @@ -125,9 +131,16 @@ const calcNationPower = (generals: Gen export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition> { +> implements GeneralActionDefinition< + TriggerState, + RandomAppointmentArgs, + RandomAppointmentResolveContext +> { public readonly key = 'che_랜덤임관'; public readonly name = ACTION_NAME; + getInheritanceActiveActionAmount(): number { + return 1; + } parseArgs(_raw: unknown): RandomAppointmentArgs | null { return {}; @@ -159,17 +172,37 @@ export class ActionDefinition< return { effects, alternative: { commandKey: 'che_인재탐색', args: {} } }; } - const weightedCandidates = candidateNations.map((candidate) => { - let power = calcNationPower(candidate.generals); - if (general.npcState < 2 && candidate.nation.name.startsWith('ⓤ')) { - power *= 100; + let picked: CandidateNation | null; + if (context.historicalNpcAffinityMode) { + const totalGenerals = candidateNations.reduce((sum, candidate) => sum + candidate.generalCount, 0); + let actorAffinity = resolveNumber(asRecord(general.meta), ['affinity'], 0); + let bestScore = 1 << 30; + picked = null; + for (const candidate of candidateNations) { + // 레거시 구현은 actorAffinity를 매 후보마다 원래 값으로 되돌리지 않는다. + actorAffinity = Math.abs(actorAffinity - candidate.monarchAffinity); + actorAffinity = Math.min(actorAffinity, Math.abs(actorAffinity - 150)); + const score = + Math.log2(actorAffinity + 1) + + rng.nextFloat1() + + Math.sqrt(candidate.generalCount / Math.max(totalGenerals, 1)); + if (score < bestScore) { + bestScore = score; + picked = candidate; + } } - const normalized = power > 0 ? power : 1; - const weight = Math.pow(1 / normalized, 3); - return [candidate, weight] as [CandidateNation, number]; - }); - - const picked = pickUsingWeightPair(rng, weightedCandidates); + } else { + const weightedCandidates = candidateNations.map((candidate) => { + let power = calcNationPower(candidate.generals); + if (general.npcState < 2 && candidate.nation.name.startsWith('ⓤ')) { + power *= 100; + } + const normalized = power > 0 ? power : 1; + const weight = Math.pow(1 / normalized, 3); + return [candidate, weight] as [CandidateNation, number]; + }); + picked = pickUsingWeightPair(rng, weightedCandidates); + } if (!picked) { effects.push( createLogEffect('임관 가능한 국가가 없습니다.', { @@ -207,6 +240,15 @@ export class ActionDefinition< experience: general.experience + expGain, meta, }), + createNationPatchEffect( + { + meta: { + ...destNation.meta, + gennum: picked.generalCount + 1, + }, + }, + destNation.id + ), createLogEffect(`${destNationName}에 랜덤 임관했습니다.`, { scope: LogScope.GENERAL, category: LogCategory.ACTION, @@ -238,10 +280,17 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { const startYear = resolveStartYear(options.world, options.scenarioMeta); const relYear = Math.max(options.world.currentYear - startYear, 0); if (!worldRef) { - return { ...base, candidateNations: [], relYear, initialNationGenLimit: 0 }; + return { + ...base, + candidateNations: [], + relYear, + initialNationGenLimit: 0, + historicalNpcAffinityMode: false, + }; } const constValues = asRecord(options.scenarioConfig.const); + const worldMeta = asRecord(options.world.meta); const initialNationGenLimit = resolveNumber(constValues, ['initialNationGenLimit'], 0); const defaultMaxGeneral = resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], 0); const genLimit = relYear < 3 && initialNationGenLimit > 0 ? initialNationGenLimit : defaultMaxGeneral; @@ -251,7 +300,7 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { const monarchCityByNation = new Map(); for (const general of generals) { - if (general.nationId <= 0) { + if (general.nationId <= 0 || ![0, 1, 2, 3, 6].includes(general.npcState)) { continue; } const list = generalsByNation.get(general.nationId) ?? []; @@ -292,15 +341,28 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { if (!monarchCityId) { continue; } + const chief = + nationGenerals.find((general) => general.officerLevel === 12) ?? + (nation.chiefGeneralId ? worldRef.getGeneralById(nation.chiefGeneralId) : null); candidateNations.push({ nation, generals: nationGenerals, - generalCount: nationGenerals.length, + generalCount: resolveNumber(meta, ['gennum'], nationGenerals.length), monarchCityId, + monarchAffinity: chief ? resolveNumber(asRecord(chief.meta), ['affinity'], 0) : 0, }); } - return { ...base, candidateNations, relYear, initialNationGenLimit }; + const scenarioId = resolveNumber(worldMeta, ['scenarioId', 'scenario'], 0); + const fiction = resolveNumber(asRecord(options.scenarioMeta), ['fiction'], 0); + return { + ...base, + candidateNations, + relYear, + initialNationGenLimit, + historicalNpcAffinityMode: + base.general.npcState >= 2 && fiction === 0 && scenarioId >= 1000 && scenarioId < 2000, + }; }; export const commandSpec: GeneralTurnCommandSpec = { diff --git a/packages/logic/src/actions/turn/general/che_모반시도.ts b/packages/logic/src/actions/turn/general/che_모반시도.ts index 26c9cfa..32db33e 100644 --- a/packages/logic/src/actions/turn/general/che_모반시도.ts +++ b/packages/logic/src/actions/turn/general/che_모반시도.ts @@ -37,6 +37,9 @@ export class ActionDefinition< > implements GeneralActionDefinition> { public readonly key = ACTION_KEY; public readonly name = ACTION_NAME; + getInheritanceActiveActionAmount(): number { + return 1; + } parseArgs(_raw: unknown): RebellionArgs | null { return {}; @@ -54,8 +57,9 @@ export class ActionDefinition< } const lord = - context.nationGenerals?.find((candidate) => candidate.nationId === nation.id && candidate.officerLevel === 12) ?? - null; + context.nationGenerals?.find( + (candidate) => candidate.nationId === nation.id && candidate.officerLevel === 12 + ) ?? null; if (!lord || lord.id === general.id) { throw new Error('모반할 대상 군주가 없습니다.'); } @@ -63,10 +67,13 @@ export class ActionDefinition< const josaYi = JosaUtil.pick(general.name, '이'); const effects: Array> = []; - context.addLog(`【모반】${general.name}${josaYi} ${nation.name}의 군주 자리를 찬탈했습니다.`, { - scope: LogScope.SYSTEM, - category: LogCategory.HISTORY, - }); + context.addLog( + `【모반】${general.name}${josaYi} ${nation.name}의 군주 자리를 찬탈했습니다.`, + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + } + ); context.addLog(`${general.name}${josaYi} ${lord.name}에게서 군주자리를 찬탈`, { scope: LogScope.NATION, category: LogCategory.HISTORY, @@ -88,12 +95,15 @@ export class ActionDefinition< category: LogCategory.ACTION, format: LogFormat.PLAIN, }), - createLogEffect(`${general.name}의 모반으로 인해 ${nation.name}의 군주자리를 박탈당함`, { - scope: LogScope.GENERAL, - generalId: lord.id, - category: LogCategory.HISTORY, - format: LogFormat.PLAIN, - }), + createLogEffect( + `${general.name}의 모반으로 인해 ${nation.name}의 군주자리를 박탈당함`, + { + scope: LogScope.GENERAL, + generalId: lord.id, + category: LogCategory.HISTORY, + format: LogFormat.PLAIN, + } + ), createGeneralPatchEffect( { officerLevel: 12, diff --git a/packages/logic/src/actions/turn/general/che_무작위건국.ts b/packages/logic/src/actions/turn/general/che_무작위건국.ts index d8aaf2c..6d0eedb 100644 --- a/packages/logic/src/actions/turn/general/che_무작위건국.ts +++ b/packages/logic/src/actions/turn/general/che_무작위건국.ts @@ -88,6 +88,9 @@ export class ActionDefinition< > implements GeneralActionDefinition> { public readonly key = ACTION_KEY; public readonly name = ACTION_NAME; + getInheritanceActiveActionAmount(): number { + return 1; + } parseArgs(raw: unknown): FoundingArgs | null { return parseArgsWithSchema(ARGS_SCHEMA, raw); @@ -131,7 +134,9 @@ export class ActionDefinition< throw new Error('Invalid color type'); } - const candidates = (context.allCities ?? []).filter((city) => city.nationId === 0 && [5, 6].includes(city.level)); + const candidates = (context.allCities ?? []).filter( + (city) => city.nationId === 0 && [5, 6].includes(city.level) + ); if (candidates.length === 0) { context.addLog('건국할 수 있는 도시가 없습니다.', { scope: LogScope.GENERAL, @@ -157,10 +162,13 @@ export class ActionDefinition< scope: LogScope.SYSTEM, category: LogCategory.ACTION, }); - context.addLog(`【건국】${args.nationType} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, { - scope: LogScope.SYSTEM, - category: LogCategory.HISTORY, - }); + context.addLog( + `【건국】${args.nationType} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + } + ); context.addLog(`${args.nationName}${josaNationUl} 건국`, { scope: LogScope.GENERAL, category: LogCategory.HISTORY, diff --git a/packages/logic/src/actions/turn/general/che_물자조달.ts b/packages/logic/src/actions/turn/general/che_물자조달.ts index 5d40957..dbf27ac 100644 --- a/packages/logic/src/actions/turn/general/che_물자조달.ts +++ b/packages/logic/src/actions/turn/general/che_물자조달.ts @@ -11,11 +11,16 @@ import type { import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js'; 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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; export interface ProcureArgs {} +interface ProcureContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + relYear?: number; +} const ACTION_NAME = '물자조달'; const ACTION_KEY = 'che_물자조달'; @@ -30,10 +35,7 @@ export class ActionResolver< this.pipeline = new GeneralActionPipeline(modules); } - resolve( - context: GeneralActionResolveContext, - _args: ProcureArgs - ): GeneralActionOutcome { + resolve(context: ProcureContext, _args: ProcureArgs): GeneralActionOutcome { const general = context.general; const nation = context.nation; @@ -42,18 +44,14 @@ export class ActionResolver< } // 1. Choose Gold or Rice - const picked = context.rng.nextBool(0.5) ? 'gold' : 'rice'; + const picked = context.rng.nextInt(0, 2) === 0 ? 'gold' : 'rice'; const resName = picked === 'gold' ? '금' : '쌀'; const resKey = picked === 'gold' ? 'gold' : 'rice'; // 2. Base Score let score = general.stats.leadership + general.stats.strength + general.stats.intelligence; - - // Domestic Bonus (Hardcoded simplified logic or need helper) - // In legacy: getDomesticExpLevelBonus($general->getVar('explevel')) - // For now, assume simplified or no bonus unless strictly required to port helper. - // Assuming 1.0 for now if helper not available, or implement simple bonus. - // Legacy: $score *= $rng->nextRange(0.8, 1.2); + const expLevel = typeof general.meta.explevel === 'number' ? general.meta.explevel : 0; + score *= 1 + expLevel / 500; score *= context.rng.nextFloat1() * 0.4 + 0.8; // 3. Success/Fail Ratio @@ -64,12 +62,14 @@ export class ActionResolver< failRatio = this.pipeline.onCalcDomestic(context, '조달', 'fail', failRatio); // 4. Determine Outcome - const roll = context.rng.nextFloat1(); + const normalRatio = 1 - failRatio - successRatio; + let roll = context.rng.nextFloat1() * (failRatio + successRatio + normalRatio); let outcome: 'fail' | 'success' | 'normal' = 'normal'; - if (roll < failRatio) { + roll -= failRatio; + if (roll <= 0) { outcome = 'fail'; - } else if (roll < failRatio + successRatio) { + } else if (roll - successRatio <= 0) { outcome = 'success'; } @@ -77,41 +77,48 @@ export class ActionResolver< // Legacy: CriticalScoreEx($rng, $pick); // fail -> 0.5, success -> 1.5, normal -> 1.0 roughly if (outcome === 'fail') { - score *= 0.5; + score *= 0.2 + context.rng.nextFloat1() * 0.2; } else if (outcome === 'success') { - score *= 1.5; + score *= 2.2 + context.rng.nextFloat1() * 0.8; } score = this.pipeline.onCalcDomestic(context, '조달', 'score', score); - score = Math.floor(score); + score = Math.round(score); // 6. Calculate Exp/Dedication const exp = (score * 0.7) / 3; const ded = (score * 1.0) / 3; // 7. Update General - const nextExp = general.experience + Math.floor(exp); - const nextDed = general.dedication + Math.floor(ded); + const nextExp = general.experience + Math.trunc(exp); + const nextDed = general.dedication + Math.trunc(ded); + + let appliedScore = score; + if (context.city && [1, 3].includes(context.city.frontState)) { + let frontDebuff = 0.5; + if (nation.capitalCityId === context.city.id && (context.relYear ?? 0) < 25) { + const debuffScale = Math.max(0, Math.min(20, (context.relYear ?? 0) - 5)) * 0.05; + frontDebuff = debuffScale * frontDebuff + (1 - debuffScale); + } + appliedScore *= frontDebuff; + } // Stat Exp // Legacy: choose weighted among L/S/I const statChoice = context.rng.nextFloat1() * (general.stats.leadership + general.stats.strength + general.stats.intelligence); - let statKey: 'leadership_exp' | 'strength_exp' | 'intel_exp' = 'leadership_exp'; - - if (statChoice < general.stats.leadership) { - statKey = 'leadership_exp'; - } else if (statChoice < general.stats.leadership + general.stats.strength) { - statKey = 'strength_exp'; - } else { - statKey = 'intel_exp'; - } + const statKey: 'leadership_exp' | 'strength_exp' | 'intel_exp' = + statChoice < general.stats.leadership + ? 'leadership_exp' + : statChoice < general.stats.leadership + general.stats.strength + ? 'strength_exp' + : 'intel_exp'; // 8. Update Nation - const nextNationRes = (nation[resKey] ?? 0) + score; + const nextNationRes = (nation[resKey] ?? 0) + Math.trunc(appliedScore); // 9. Logging - const scoreText = score.toLocaleString(); + const scoreText = Math.round(appliedScore).toLocaleString(); if (outcome === 'fail') { context.addLog( `조달을 실패하여 ${resName}을 ${scoreText} 조달했습니다.`, @@ -185,7 +192,12 @@ export class ActionDefinition< } } -export const actionContextBuilder = defaultActionContextBuilder; +export const actionContextBuilder: ActionContextBuilder = (base, options) => ({ + ...base, + ...(typeof options.scenarioMeta?.startYear === 'number' + ? { relYear: options.world.currentYear - options.scenarioMeta.startYear } + : {}), +}); export const commandSpec: GeneralTurnCommandSpec = { key: 'che_물자조달', diff --git a/packages/logic/src/actions/turn/general/che_방랑.ts b/packages/logic/src/actions/turn/general/che_방랑.ts index d43b35a..521acf7 100644 --- a/packages/logic/src/actions/turn/general/che_방랑.ts +++ b/packages/logic/src/actions/turn/general/che_방랑.ts @@ -1,4 +1,5 @@ import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import { JosaUtil } from '@sammo-ts/common'; import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js'; import { allow, @@ -20,7 +21,7 @@ import { createCityPatchEffect, createDiplomacyPatchEffect, } 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 } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; @@ -74,7 +75,24 @@ export class ActionResolver< category: LogCategory.ACTION, format: LogFormat.MONTH, }); - // TODO: Global logs + const josaYi = JosaUtil.pick(general.name, '이'); + const josaUn = JosaUtil.pick(general.name, '은'); + const josaUl = JosaUtil.pick(nation.name, '을'); + context.addLog(`${general.name}${josaYi} 방랑의 길을 떠납니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + format: LogFormat.RAWTEXT, + }); + context.addLog(`【방랑】${general.name}${josaUn} 방랑의 길을 떠납니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.RAWTEXT, + }); + context.addLog(`${nation.name}${josaUl} 버리고 방랑`, { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }); // 2. Nation Update effects.push( @@ -99,9 +117,11 @@ export class ActionResolver< createGeneralPatchEffect( { ...general, - // meta: { ...general.meta, makelimit: 12 }, // If used. Legacy uses makelimit column. New entity might not have it. - // If `makelimit` is not in entity, ignore for now or check meta. - // officerLevel is already 12. + meta: { + ...general.meta, + makelimit: 12, + officer_city: 0, + }, }, general.id ) @@ -122,7 +142,11 @@ export class ActionResolver< { ...other, officerLevel: 1, - // officer_city? logic probably means unassigned. + meta: { + ...other.meta, + makelimit: 12, + officer_city: 0, + }, }, other.id ) @@ -142,9 +166,7 @@ export class ActionResolver< ...city, nationId: 0, frontState: 0, - // conflict: {} // Legacy clears conflict. - // Assuming conflict is stored in meta or handled separately. - // If conflict is part of state, reset it. + conflict: {}, }, city.id ) @@ -179,6 +201,9 @@ export class ActionDefinition< > implements GeneralActionDefinition> { public readonly key = ACTION_KEY; public readonly name = ACTION_NAME; + getInheritanceActiveActionAmount(): number { + return 1; + } private readonly resolver: ActionResolver; constructor() { diff --git a/packages/logic/src/actions/turn/general/che_사기진작.ts b/packages/logic/src/actions/turn/general/che_사기진작.ts index 9cb3138..3c831ba 100644 --- a/packages/logic/src/actions/turn/general/che_사기진작.ts +++ b/packages/logic/src/actions/turn/general/che_사기진작.ts @@ -16,6 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { clamp } from 'es-toolkit'; +import { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js'; export interface BoostMoraleArgs {} @@ -23,6 +24,9 @@ export interface BoostMoraleEnvironment { atmosDelta?: number; maxAtmosByCommand?: number; costGold?: number; + trainSideEffectByAtmosTurn?: number; + unitSet?: TurnCommandEnv['unitSet']; + generalActionModules?: TurnCommandEnv['generalActionModules']; } const ACTION_NAME = '사기 진작'; @@ -35,9 +39,11 @@ export class ActionDefinition< public readonly key = 'che_사기진작'; public readonly name = ACTION_NAME; private readonly env: BoostMoraleEnvironment; + private readonly pipeline: GeneralActionPipeline; constructor(env: BoostMoraleEnvironment = {}) { this.env = env; + this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []); } parseArgs(_raw: unknown): BoostMoraleArgs | null { @@ -50,7 +56,10 @@ export class ActionDefinition< } buildConstraints(_ctx: ConstraintContext, _args: BoostMoraleArgs): Constraint[] { - const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => this.env.costGold ?? 0; + const getRequiredGold = (context: ConstraintContext, view: StateView): number => { + const general = view.get({ kind: 'general', id: context.actorId }) as { crew?: number } | null; + return this.env.costGold ?? Math.round((general?.crew ?? 0) / 100); + }; const maxAtmos = this.env.maxAtmosByCommand && this.env.maxAtmosByCommand > 0 ? this.env.maxAtmosByCommand @@ -76,13 +85,26 @@ export class ActionDefinition< ? this.env.maxAtmosByCommand : DEFAULT_MAX_ATMOS; const delta = this.env.atmosDelta && this.env.atmosDelta > 0 ? this.env.atmosDelta : DEFAULT_ATMOS_DELTA; - const nextAtmos = clamp(general.atmos + delta, 0, maxAtmos); + const leadership = this.pipeline.onCalcStat(context, 'leadership', general.stats.leadership); + const score = Math.round((leadership * 100 * delta) / general.crew); + const nextAtmos = clamp(general.atmos + score, 0, maxAtmos); const applied = nextAtmos - general.atmos; - const costGold = this.env.costGold ?? 0; + const costGold = this.env.costGold ?? Math.round(general.crew / 100); + const trainSideEffect = Math.max(0, Math.trunc(general.train * (this.env.trainSideEffectByAtmosTurn ?? 1))); - // 직접 수정 (Immer Draft) general.atmos = nextAtmos; + general.train = trainSideEffect; general.gold = Math.max(0, general.gold - costGold); + general.experience += 100; + general.dedication += 70; + const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0; + general.meta.leadership_exp = leadershipExp + 1; + const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId); + if (crewType) { + const dexKey = `dex${crewType.armType}`; + const dex = typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0; + general.meta[dexKey] = dex + applied; + } context.addLog(`사기치가 ${applied} 상승했습니다.`); tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); @@ -103,5 +125,10 @@ export const commandSpec: GeneralTurnCommandSpec = { new ActionDefinition({ atmosDelta: env.atmosDelta, maxAtmosByCommand: env.maxAtmosByCommand, + ...(env.trainSideEffectByAtmosTurn !== undefined + ? { trainSideEffectByAtmosTurn: env.trainSideEffectByAtmosTurn } + : {}), + ...(env.unitSet ? { unitSet: env.unitSet } : {}), + ...(env.generalActionModules ? { generalActionModules: env.generalActionModules } : {}), }), }; diff --git a/packages/logic/src/actions/turn/general/che_상업투자.ts b/packages/logic/src/actions/turn/general/che_상업투자.ts index 335b0a5..07c4b0e 100644 --- a/packages/logic/src/actions/turn/general/che_상업투자.ts +++ b/packages/logic/src/actions/turn/general/che_상업투자.ts @@ -11,8 +11,8 @@ import { reqGeneralRice, suppliedCity, } from '@sammo-ts/logic/constraints/presets.js'; -import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; +import type { TriggerDomesticActionType } from '@sammo-ts/logic/triggers/types.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, @@ -20,19 +20,44 @@ import type { GeneralActionResolveContext, } from '@sammo-ts/logic/actions/engine.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { + ActionContextBase, + ActionContextBuilder, + ActionContextOptions, + ActionResolveContext, +} from '@sammo-ts/logic/actions/turn/actionContext.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { clamp } from 'es-toolkit'; export type DomesticCriticalPick = 'fail' | 'normal' | 'success'; -export interface DomesticActionContext< - TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends GeneralActionContext { +export interface DomesticBaseContext { general: General; city: City; nation?: Nation | null; + relYear?: number | undefined; +} + +export type DomesticActionContext = + GeneralActionResolveContext & DomesticBaseContext; + +export type DomesticStatKey = 'leadership' | 'strength' | 'intelligence'; +export type DomesticCityKey = 'agriculture' | 'commerce' | 'security' | 'defence' | 'wall'; +export type DomesticCityMaxKey = 'agricultureMax' | 'commerceMax' | 'securityMax' | 'defenceMax' | 'wallMax'; + +export interface InvestmentConfig { + key: string; + name: string; + actionKey: TriggerDomesticActionType; + statKey: DomesticStatKey; + statExpKey: 'leadership_exp' | 'strength_exp' | 'intel_exp'; + cityKey: DomesticCityKey; + cityMaxKey: DomesticCityMaxKey; + frontDebuff: number; + useCityTrust?: boolean; + scaleSuccessByTrust?: boolean; + roundCriticalScore?: boolean; } export interface InvestmentEnvironment { @@ -44,11 +69,13 @@ export interface InvestmentEnvironment { getCriticalRatio?: (context: DomesticActionContext, statKey: string) => { success: number; fail: number }; getCriticalScoreMultiplier?: (rng: RandomGenerator, pick: DomesticCriticalPick) => number; adjustFrontDebuff?: (context: DomesticActionContext, debuff: number) => number; + maxStatLevel?: number; } export interface CommerceInvestmentResult { pick: DomesticCriticalPick; score: number; + appliedScore: number; exp: number; dedication: number; costGold: number; @@ -59,11 +86,17 @@ export interface CommerceInvestmentResult { export interface CommerceInvestmentArgs {} const DEFAULT_TRUST = 50; -const DEFAULT_FRONT_DEBUFF = 0.5; const DEFAULT_FRONT_STATES = [1, 3]; -const ACTION_NAME = '상업 투자'; -const CITY_KEY = 'commerce'; -const STAT_EXP_KEY = 'intel_exp'; +const DEFAULT_CONFIG: InvestmentConfig = { + key: 'che_상업투자', + name: '상업 투자', + actionKey: '상업', + statKey: 'intelligence', + statExpKey: 'intel_exp', + cityKey: 'commerce', + cityMaxKey: 'commerceMax', + frontDebuff: 0.5, +}; const getMetaNumber = (meta: Record, key: string): number | null => { const raw = meta[key]; @@ -78,7 +111,7 @@ const pickByWeight = (rng: RandomGenerator, weights: Record( +export const buildDomesticContextFromView = ( ctx: ConstraintContext, view: StateView -): DomesticActionContext | null => { +): DomesticBaseContext | null => { const general = view.get({ kind: 'general', id: ctx.actorId, @@ -123,37 +156,56 @@ const buildDomesticContextFromView = { private readonly pipeline: GeneralActionPipeline; private readonly env: InvestmentEnvironment; - private readonly actionKey = '상업'; - private readonly statKey = 'intelligence'; + private readonly config: InvestmentConfig; - constructor(modules: Array | null | undefined>, env: InvestmentEnvironment) { + constructor( + modules: Array | null | undefined>, + env: InvestmentEnvironment, + config: InvestmentConfig = DEFAULT_CONFIG + ) { this.pipeline = new GeneralActionPipeline(modules); this.env = env; + this.config = config; } - getCost(context: DomesticActionContext): { + getCost(context: DomesticBaseContext): { gold: number; rice: number; } { const baseGold = this.env.develCost; - const gold = Math.round(this.pipeline.onCalcDomestic(context, this.actionKey, 'cost', baseGold)); + const gold = Math.round(this.pipeline.onCalcDomestic(context, this.config.actionKey, 'cost', baseGold)); return { gold, rice: 0 }; } calcBaseScore(context: DomesticActionContext, rng: RandomGenerator): number { const trust = getMetaNumber(context.city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST; - let score = this.pipeline.onCalcStat(context, this.statKey, context.general.stats.intelligence); + const injuryMultiplier = (100 - context.general.injury) / 100; + const rawStats = { + leadership: context.general.stats.leadership * injuryMultiplier, + strength: context.general.stats.strength * injuryMultiplier, + intelligence: context.general.stats.intelligence * injuryMultiplier, + }; + if (this.config.statKey === 'strength') { + rawStats.strength += Math.round(rawStats.intelligence / 4); + } else if (this.config.statKey === 'intelligence') { + rawStats.intelligence += Math.round(rawStats.strength / 4); + } + const maxStatLevel = this.env.maxStatLevel ?? 255; + let score = clamp(rawStats[this.config.statKey], 0, maxStatLevel); + score = this.pipeline.onCalcStat(context, this.config.statKey, score); const expLevel = getMetaNumber(context.general.meta, 'explevel') ?? getMetaNumber(context.general.meta, 'expLevel') ?? 0; - const expBonus = this.env.getDomesticExpLevelBonus?.(expLevel) ?? 1; + const expBonus = this.env.getDomesticExpLevelBonus?.(expLevel) ?? 1 + expLevel / 500; - score *= trust / 100; + if (this.config.useCityTrust !== false) { + score *= trust / 100; + } score *= expBonus; score *= randomRange(rng, 0.8, 1.2); - return this.pipeline.onCalcDomestic(context, this.actionKey, 'score', score); + return this.pipeline.onCalcDomestic(context, this.config.actionKey, 'score', score); } resolve(context: DomesticActionContext, rng: RandomGenerator): CommerceInvestmentResult { @@ -161,17 +213,50 @@ export class CommandResolver { + const rawStats = { + leadership: context.general.stats.leadership, + strength: context.general.stats.strength + Math.round(context.general.stats.intelligence / 4), + intelligence: context.general.stats.intelligence + Math.round(context.general.stats.strength / 4), + }; + const maxStatLevel = this.env.maxStatLevel ?? 255; + const leadership = this.pipeline.onCalcStat( + context, + 'leadership', + clamp(rawStats.leadership, 0, maxStatLevel) + ); + const strength = this.pipeline.onCalcStat( + context, + 'strength', + clamp(rawStats.strength, 0, maxStatLevel) + ); + const intelligence = this.pipeline.onCalcStat( + context, + 'intelligence', + clamp(rawStats.intelligence, 0, maxStatLevel) + ); + const average = (leadership + strength + intelligence) / 3; + const selected = + this.config.statKey === 'leadership' + ? leadership + : this.config.statKey === 'strength' + ? strength + : intelligence; + const value = Math.min(average / Math.max(selected, Number.EPSILON), 1.2); + return { + fail: clamp((value / 1.2) ** 1.4 - 0.3, 0, 0.5), + success: clamp((value / 1.2) ** 1.5 - 0.25, 0, 0.5), + }; + })(); let successRatio = ratio.success; let failRatio = ratio.fail; - if (trust < 80) { + if (this.config.scaleSuccessByTrust !== false && trust < 80) { successRatio *= trust / 80; } - successRatio = this.pipeline.onCalcDomestic(context, this.actionKey, 'success', successRatio); - failRatio = this.pipeline.onCalcDomestic(context, this.actionKey, 'fail', failRatio); + successRatio = this.pipeline.onCalcDomestic(context, this.config.actionKey, 'success', successRatio); + failRatio = this.pipeline.onCalcDomestic(context, this.config.actionKey, 'fail', failRatio); successRatio = clamp(successRatio, 0, 1); failRatio = clamp(failRatio, 0, 1 - successRatio); @@ -183,24 +268,36 @@ export class CommandResolver implements GeneralActionResolver { - readonly key = 'che_상업투자'; + readonly key: string; private readonly command: CommandResolver; + private readonly config: InvestmentConfig; - constructor(modules: Array | null | undefined>, env: InvestmentEnvironment) { - this.command = new CommandResolver(modules, env); + constructor( + modules: Array | null | undefined>, + env: InvestmentEnvironment, + config: InvestmentConfig = DEFAULT_CONFIG + ) { + this.key = config.key; + this.command = new CommandResolver(modules, env, config); + this.config = config; } resolve( @@ -236,37 +340,43 @@ export class ActionResolver< ...context, city, nation: context.nation ?? null, + relYear: + typeof (context as DomesticActionContext).relYear === 'number' + ? (context as DomesticActionContext).relYear + : undefined, }, context.rng ); // 직접 수정 (Immer Draft) - city.commerce = clamp(city.commerce + result.score, 0, city.commerceMax); + // 레거시 city 컬럼은 정수형이라 전선 debuff 뒤의 .5도 DB write 시 정수로 저장된다. + city[this.config.cityKey] = Math.round( + clamp(city[this.config.cityKey] + result.appliedScore, 0, city[this.config.cityMaxKey]) + ); general.gold = Math.max(0, general.gold - result.costGold); general.rice = Math.max(0, general.rice - result.costRice); general.experience += result.exp; general.dedication += result.dedication; - const metaWithStatExp = addMetaNumber(general.meta, STAT_EXP_KEY, 1); + const metaWithStatExp = addMetaNumber(general.meta, this.config.statExpKey, 1); general.meta = result.pick === 'success' ? { ...metaWithStatExp, max_domestic_critical: result.score } : { ...metaWithStatExp, max_domestic_critical: 0 }; const scoreText = Math.round(result.score).toLocaleString(); - const logName = ACTION_NAME.replace(' ', ''); - const josaUl = JosaUtil.pick(logName, '을'); + const josaUl = JosaUtil.pick(this.config.name, '을'); if (result.pick === 'fail') { context.addLog( - `${logName}${josaUl} 실패하여 ${scoreText} 상승했습니다.` + `${this.config.name}${josaUl} 실패하여 ${scoreText} 상승했습니다.` ); } else if (result.pick === 'success') { - context.addLog(`${logName}${josaUl} 성공하여 ${scoreText} 상승했습니다.`); + context.addLog(`${this.config.name}${josaUl} 성공하여 ${scoreText} 상승했습니다.`); } else { - context.addLog(`${logName}${josaUl} 하여 ${scoreText} 상승했습니다.`); + context.addLog(`${this.config.name}${josaUl} 하여 ${scoreText} 상승했습니다.`); } - tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); + tryApplyUniqueLottery(context, { acquireType: '아이템', reason: this.config.name }); return { effects: [] }; } @@ -275,14 +385,22 @@ export class ActionResolver< export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionDefinition { - public readonly key = 'che_상업투자'; - public readonly name = ACTION_NAME; + public readonly key: string; + public readonly name: string; private readonly command: CommandResolver; private readonly resolver: ActionResolver; + private readonly config: InvestmentConfig; - constructor(modules: Array | null | undefined>, env: InvestmentEnvironment) { - this.command = new CommandResolver(modules, env); - this.resolver = new ActionResolver(modules, env); + constructor( + modules: Array | null | undefined>, + env: InvestmentEnvironment, + config: InvestmentConfig = DEFAULT_CONFIG + ) { + this.key = config.key; + this.name = config.name; + this.config = config; + this.command = new CommandResolver(modules, env, config); + this.resolver = new ActionResolver(modules, env, config); } parseArgs(_raw: unknown): CommerceInvestmentArgs | null { @@ -315,7 +433,7 @@ export class ActionDefinition< suppliedCity(), reqGeneralGold(getCost, requirements), reqGeneralRice(() => 0, requirements), - remainCityCapacity(CITY_KEY, ACTION_NAME), + remainCityCapacity(this.config.cityKey, this.config.name), ]; } @@ -327,8 +445,17 @@ export class ActionDefinition< } } -// 예약 턴 실행은 기본 컨텍스트만 사용한다. -export const actionContextBuilder = defaultActionContextBuilder; +export const actionContextBuilder: ActionContextBuilder = ( + base: ActionContextBase, + options: ActionContextOptions +): ActionResolveContext => ({ + ...base, + city: base.city!, + nation: base.nation ?? null, + ...(typeof options.scenarioMeta?.startYear === 'number' + ? { relYear: options.world.currentYear - options.scenarioMeta.startYear } + : {}), +}); export const commandSpec: GeneralTurnCommandSpec = { key: 'che_상업투자', diff --git a/packages/logic/src/actions/turn/general/che_선동.ts b/packages/logic/src/actions/turn/general/che_선동.ts index c18b85c..9371882 100644 --- a/packages/logic/src/actions/turn/general/che_선동.ts +++ b/packages/logic/src/actions/turn/general/che_선동.ts @@ -1,4 +1,4 @@ -import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { notBeNeutral, @@ -25,13 +25,17 @@ import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/ac import type { GeneralTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; import { parseArgsWithSchema } from '../parseArgs.js'; -import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js'; import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js'; +import { + buildStrategyActionContext, + CommandResolver as StrategyCommandResolver, + type FireAttackResolveContext, +} from './che_화계.js'; export interface AgitateResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends GeneralActionResolveContext { - destCity?: City; +> extends FireAttackResolveContext { env?: TurnCommandEnv; } @@ -47,39 +51,51 @@ export class ActionResolver< > implements GeneralActionResolver { readonly key = ACTION_KEY; private readonly pipeline: GeneralActionPipeline; + private readonly command: StrategyCommandResolver; - constructor(modules: Array | null | undefined> = []) { + constructor(env: TurnCommandEnv) { + const modules = env.generalActionModules ?? []; this.pipeline = new GeneralActionPipeline(modules); + this.command = new StrategyCommandResolver(modules, { + ...env, + statKey: 'leadership', + damageMode: 'agitate', + }); } resolve(context: GeneralActionResolveContext, args: AgitateArgs): GeneralActionOutcome { const ctx = context as AgitateResolveContext; const general = ctx.general; - const { destCityId } = args; const destCity = ctx.destCity; if (!destCity) throw new Error('Target city missing'); - - const env = ctx.env; - const cost = env?.develCost ?? 100; - const effects: GeneralActionEffect[] = []; - - // Damage calc - const min = env?.sabotageDamageMin ?? 10; - const max = env?.sabotageDamageMax ?? 30; - const rng = ctx.rng; - if (!rng) throw new Error('RNG missing'); - - const secuDmg = rng.nextInt(min, max + 1); - const trustDmg = rng.nextInt(min, max + 1) / 50; - - const newSecu = Math.max(0, destCity.security - secuDmg); + const city = ctx.city; + if (!city) throw new Error('Source city missing'); + const result = this.command.resolve( + { + ...ctx, + city, + destCity, + destGenerals: ctx.destGenerals, + }, + ctx.rng + ); + general.gold = Math.max(0, general.gold - result.costGold); + general.rice = Math.max(0, general.rice - result.costRice); + general.experience += result.exp; + general.dedication += result.dedication; + general.meta.leadership_exp = + (typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0) + 1; + if (!result.success) { + ctx.addLog( + `${destCity.name}에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 실패했습니다.` + ); + return { effects }; + } + general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1; + const newSecu = Math.max(0, destCity.security - result.agriDamage); const currentTrust = typeof destCity.meta.trust === 'number' ? destCity.meta.trust : 50; - const newTrust = Math.max(0, currentTrust - trustDmg); - - const actualSecuDmg = destCity.security - newSecu; - const actualTrustDmg = currentTrust - newTrust; - const injuryCount = 0; + const newTrust = Math.max(0, currentTrust - result.commDamage); // Log const commandName = ACTION_NAME; @@ -89,9 +105,9 @@ export class ActionResolver< format: LogFormat.MONTH, }); ctx.addLog( - `도시의 치안이 ${actualSecuDmg}, 민심이 ${actualTrustDmg.toFixed( + `도시의 치안이 ${result.agriDamage}, 민심이 ${result.commDamage.toFixed( 1 - )}만큼 감소하고, 장수 ${injuryCount}명이 부상 당했습니다.`, + )}만큼 감소하고, 장수 ${result.injuryCount}명이 부상 당했습니다.`, { category: LogCategory.ACTION, format: LogFormat.PLAIN, @@ -110,30 +126,14 @@ export class ActionResolver< trust: newTrust, }, }, - destCityId + args.destCityId ) ); consumeSuccessfulStrategyItem(this.pipeline, context); - - // General Update (Cost + Exp + LeaderExp) - effects.push( - createGeneralPatchEffect( - { - ...general, - gold: Math.max(0, general.gold - cost), - rice: Math.max(0, general.rice - cost), - experience: general.experience + 50, - dedication: general.dedication + 30, - meta: { - ...general.meta, - leadership_exp: - (typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0) + 1, - }, - }, - general.id - ) - ); + for (const injured of result.injuredGenerals) { + effects.push(createGeneralPatchEffect(injured.patch, injured.id)); + } return { effects }; } @@ -147,9 +147,7 @@ export class ActionDefinition< private readonly resolver: ActionResolver; constructor(env: TurnCommandEnv) { - this.resolver = new ActionResolver( - (env.generalActionModules ?? []) as GeneralActionModule[] - ); + this.resolver = new ActionResolver(env); } parseArgs(raw: unknown): AgitateArgs | null { @@ -158,13 +156,13 @@ export class ActionDefinition< buildMinConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] { const env = ctx.env; - const cost = (env.develCost as number) ?? 100; + const cost = ((env.develCost as number) ?? 100) * 5; return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)]; } buildConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] { const env = ctx.env; - const cost = (env.develCost as number) ?? 100; + const cost = ((env.develCost as number) ?? 100) * 5; return [ notBeNeutral(), occupiedCity(), @@ -185,14 +183,10 @@ export class ActionDefinition< } export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => { - const destCityId = options.actionArgs?.destCityId; - let destCity = null; - if (typeof destCityId === 'number' && options.worldRef) { - destCity = options.worldRef.getCityById(destCityId); - } + const strategyContext = buildStrategyActionContext(base, options); + if (!strategyContext) return null; return { - ...base, - destCity, + ...strategyContext, env: options.scenarioConfig.const as unknown as TurnCommandEnv, }; }; diff --git a/packages/logic/src/actions/turn/general/che_선양.ts b/packages/logic/src/actions/turn/general/che_선양.ts index c646ffd..8ef5994 100644 --- a/packages/logic/src/actions/turn/general/che_선양.ts +++ b/packages/logic/src/actions/turn/general/che_선양.ts @@ -36,6 +36,9 @@ export class ActionDefinition< > implements GeneralActionDefinition> { public readonly key = ACTION_KEY; public readonly name = ACTION_NAME; + getInheritanceActiveActionAmount(): number { + return 1; + } parseArgs(raw: unknown): AbdicationArgs | null { return parseArgsWithSchema(ARGS_SCHEMA, raw); @@ -49,7 +52,10 @@ export class ActionDefinition< return [beLord(), existsDestGeneral(), friendlyDestGeneral()]; } - resolve(context: AbdicationResolveContext, _args: AbdicationArgs): GeneralActionOutcome { + resolve( + context: AbdicationResolveContext, + _args: AbdicationArgs + ): GeneralActionOutcome { const general = context.general; const nation = context.nation; const destGeneral = context.destGeneral; @@ -81,10 +87,13 @@ export class ActionDefinition< const josaYi = JosaUtil.pick(general.name, '이'); const effects: Array> = []; - context.addLog(`【선양】${general.name}${josaYi} ${nation.name}의 군주 자리를 ${destGeneral.name}에게 선양했습니다.`, { - scope: LogScope.SYSTEM, - category: LogCategory.HISTORY, - }); + context.addLog( + `【선양】${general.name}${josaYi} ${nation.name}의 군주 자리를 ${destGeneral.name}에게 선양했습니다.`, + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + } + ); context.addLog(`${general.name}${josaYi} ${destGeneral.name}에게 선양`, { scope: LogScope.NATION, category: LogCategory.HISTORY, diff --git a/packages/logic/src/actions/turn/general/che_성벽보수.ts b/packages/logic/src/actions/turn/general/che_성벽보수.ts index eac6690..b300a53 100644 --- a/packages/logic/src/actions/turn/general/che_성벽보수.ts +++ b/packages/logic/src/actions/turn/general/che_성벽보수.ts @@ -1,34 +1,31 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; -import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; +import { ActionDefinition as DomesticActionDefinition, actionContextBuilder } from './che_상업투자.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends CityDevelopmentActionDefinition { - constructor(env: { develCost?: number; amount?: number } = {}) { - super( - { - key: 'che_성벽보수', - name: '성벽 보수', - statKey: 'wall', - maxKey: 'wallMax', - label: '성벽', - baseAmount: 50, - }, - env - ); +> extends DomesticActionDefinition { + constructor(env: TurnCommandEnv) { + super(env.generalActionModules ?? [], env, { + key: 'che_성벽보수', + name: '성벽 보수', + actionKey: '성벽', + statKey: 'strength', + statExpKey: 'strength_exp', + cityKey: 'wall', + cityMaxKey: 'wallMax', + frontDebuff: 0.25, + }); } } -// 예약 턴 실행은 기본 컨텍스트만 사용한다. -export const actionContextBuilder = defaultActionContextBuilder; +export { actionContextBuilder }; export const commandSpec: GeneralTurnCommandSpec = { key: 'che_성벽보수', category: '내정', reqArg: false, - createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_소집해제.ts b/packages/logic/src/actions/turn/general/che_소집해제.ts index 250b5c2..8c0053f 100644 --- a/packages/logic/src/actions/turn/general/che_소집해제.ts +++ b/packages/logic/src/actions/turn/general/che_소집해제.ts @@ -5,8 +5,8 @@ import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; -import { clamp } from 'es-toolkit'; import type { GeneralTurnCommandSpec } from './index.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js'; export interface DisbandArgs {} @@ -15,6 +15,11 @@ export class ActionDefinition< > implements GeneralActionDefinition { public readonly key = 'che_소집해제'; public readonly name = '소집해제'; + private readonly pipeline: GeneralActionPipeline; + + constructor(env: TurnCommandEnv) { + this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []); + } parseArgs(_raw: unknown): DisbandArgs | null { return {}; @@ -35,13 +40,11 @@ export class ActionDefinition< return { effects: [] }; } - const crew = general.crew; - const currentPop = city.population; - const maxPop = city.populationMax; - - const nextPop = clamp(currentPop + crew, 0, maxPop); + const crewUp = this.pipeline.onCalcDomestic(context, '징집인구', 'score', general.crew); general.crew = 0; - city.population = nextPop; + city.population += Math.trunc(crewUp); + general.experience += 70; + general.dedication += 100; context.addLog(`병사들을 소집해제하였습니다.`); @@ -56,5 +59,5 @@ export const commandSpec: GeneralTurnCommandSpec = { category: '군사', reqArg: false, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_수비강화.ts b/packages/logic/src/actions/turn/general/che_수비강화.ts index dc05bb6..bcdf601 100644 --- a/packages/logic/src/actions/turn/general/che_수비강화.ts +++ b/packages/logic/src/actions/turn/general/che_수비강화.ts @@ -1,34 +1,31 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; -import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; +import { ActionDefinition as DomesticActionDefinition, actionContextBuilder } from './che_상업투자.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends CityDevelopmentActionDefinition { - constructor(env: { develCost?: number; amount?: number } = {}) { - super( - { - key: 'che_수비강화', - name: '수비 강화', - statKey: 'defence', - maxKey: 'defenceMax', - label: '수비', - baseAmount: 50, - }, - env - ); +> extends DomesticActionDefinition { + constructor(env: TurnCommandEnv) { + super(env.generalActionModules ?? [], env, { + key: 'che_수비강화', + name: '수비 강화', + actionKey: '수비', + statKey: 'strength', + statExpKey: 'strength_exp', + cityKey: 'defence', + cityMaxKey: 'defenceMax', + frontDebuff: 0.5, + }); } } -// 예약 턴 실행은 기본 컨텍스트만 사용한다. -export const actionContextBuilder = defaultActionContextBuilder; +export { actionContextBuilder }; export const commandSpec: GeneralTurnCommandSpec = { key: 'che_수비강화', category: '내정', reqArg: false, - createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_숙련전환.ts b/packages/logic/src/actions/turn/general/che_숙련전환.ts index 98d9bfc..be1304f 100644 --- a/packages/logic/src/actions/turn/general/che_숙련전환.ts +++ b/packages/logic/src/actions/turn/general/che_숙련전환.ts @@ -21,6 +21,7 @@ export interface DexTransferContext< export interface DexTransferEnvironment { develCost?: number; + unitSet?: UnitSetDefinition; } const ACTION_NAME = '숙련전환'; @@ -49,7 +50,19 @@ export class ActionDefinition< } parseArgs(raw: unknown): DexTransferArgs | null { - return parseArgsWithSchema(ARGS_SCHEMA, raw); + const parsed = parseArgsWithSchema(ARGS_SCHEMA, raw); + if (!parsed) { + return null; + } + const armTypes = this.env.unitSet?.armTypes; + if ( + armTypes && + (!Object.prototype.hasOwnProperty.call(armTypes, String(parsed.srcArmType)) || + !Object.prototype.hasOwnProperty.call(armTypes, String(parsed.destArmType))) + ) { + return null; + } + return parsed; } buildMinConstraints(_ctx: ConstraintContext, _args: DexTransferArgs): Constraint[] { @@ -104,5 +117,9 @@ export const commandSpec: GeneralTurnCommandSpec = { reqArg: true, availabilityArgs: { srcArmType: 0, destArmType: 0 }, argsSchema: ARGS_SCHEMA, - createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }), + createDefinition: (env: TurnCommandEnv) => + new ActionDefinition({ + develCost: env.develCost, + ...(env.unitSet ? { unitSet: env.unitSet } : {}), + }), }; diff --git a/packages/logic/src/actions/turn/general/che_요양.ts b/packages/logic/src/actions/turn/general/che_요양.ts index 2e2f1e2..96276bd 100644 --- a/packages/logic/src/actions/turn/general/che_요양.ts +++ b/packages/logic/src/actions/turn/general/che_요양.ts @@ -14,7 +14,6 @@ export interface RecoveryEnvironment { } const ACTION_NAME = '요양'; -const DEFAULT_INJURY_DELTA = 10; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -41,13 +40,14 @@ export class ActionDefinition< _args: RecoveryArgs ): GeneralActionOutcome { const general = context.general; - const delta = this.env.injuryDelta ?? DEFAULT_INJURY_DELTA; - const nextInjury = Math.max(0, general.injury - delta); + const nextInjury = 0; const costGold = this.env.costGold ?? 0; // 직접 수정 (Immer Draft) general.injury = nextInjury; general.gold = Math.max(0, general.gold - costGold); + general.experience += 10; + general.dedication += 7; context.addLog(`건강 회복을 위해 요양합니다.`); diff --git a/packages/logic/src/actions/turn/general/che_은퇴.ts b/packages/logic/src/actions/turn/general/che_은퇴.ts index 2499eeb..400bc3d 100644 --- a/packages/logic/src/actions/turn/general/che_은퇴.ts +++ b/packages/logic/src/actions/turn/general/che_은퇴.ts @@ -9,11 +9,12 @@ import type { GeneralActionEffect, } from '@sammo-ts/logic/actions/engine.js'; import { createGeneralPatchEffect } 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 { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; +import { JosaUtil } from '@sammo-ts/common'; export interface RetireArgs {} @@ -42,57 +43,91 @@ export class ActionResolver< resolve(context: GeneralActionResolveContext, _args: RetireArgs): GeneralActionOutcome { const general = context.general; - // Logs - context.addLog(`은퇴하였습니다.`, { + const effects: GeneralActionEffect[] = []; + const nextMeta = { ...general.meta }; + for (const key of ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const) { + const value = typeof nextMeta[key] === 'number' ? nextMeta[key] : 0; + nextMeta[key] = Math.round(value * 0.5); + } + nextMeta.specAge = 0; + nextMeta.specAge2 = 0; + nextMeta.firenum = 0; + for (const key of [ + 'warnum', + 'killnum', + 'deathnum', + 'killcrew', + 'deathcrew', + 'ttw', + 'ttd', + 'ttl', + 'ttg', + 'ttp', + 'tlw', + 'tld', + 'tll', + 'tlg', + 'tlp', + 'tsw', + 'tsd', + 'tsl', + 'tsg', + 'tsp', + 'tiw', + 'tid', + 'til', + 'tig', + 'tip', + 'betwin', + 'betgold', + 'betwingold', + 'killcrew_person', + 'deathcrew_person', + 'occupied', + 'inherit_earned', + 'inherit_spent', + 'inherit_earned_dyn', + 'inherit_earned_act', + 'inherit_spent_dyn', + ]) { + nextMeta[`rank_${key}`] = 0; + } + + const josaYi = JosaUtil.pick(general.name, '이'); + context.addLog(`${general.name}${josaYi} 은퇴하고 그 자손이 유지를 이어받았습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + format: LogFormat.RAWTEXT, + }); + context.addLog('나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.', { + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + }); + context.addLog('나이가 들어 은퇴하고, 자손에게 관직을 물려줌', { + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }); + context.addLog('은퇴하였습니다.', { category: LogCategory.ACTION, format: LogFormat.MONTH, }); - // Rebirth Logic (Simulated) - // 1. Reset Stats (Randomize slightly?) - // Let's keep total stat points but re-distribute or small variation? - // Legacy: complete re-roll usually. - // Simple implementation: Random re-roll around average 70? - - const rng = context.rng; - const newLead = rng.nextInt(30, 90); - const newStr = rng.nextInt(30, 90); - const newIntel = rng.nextInt(30, 90); - - // 2. Reset Age - const newAge = 20; - - // 3. Reset Exp/Ded - const newExp = 0; - const newDed = 0; - - // 4. Reset Meta (Inheritance points?) - // Legacy: increases inheritance point. - // We'll increment inheritance point in meta. - const inheritance = - (typeof general.meta.inheritance_point === 'number' ? general.meta.inheritance_point : 0) + 1; - - const effects: GeneralActionEffect[] = []; - tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); effects.push( createGeneralPatchEffect( { ...general, - age: newAge, + age: 20, stats: { - leadership: newLead, - strength: newStr, - intelligence: newIntel, - }, - experience: newExp, - dedication: newDed, - meta: { - ...general.meta, - inheritance_point: inheritance, - // Clear other temp vars? + leadership: Math.max(10, Math.round(general.stats.leadership * 0.85)), + strength: Math.max(10, Math.round(general.stats.strength * 0.85)), + intelligence: Math.max(10, Math.round(general.stats.intelligence * 0.85)), }, + injury: 0, + experience: Math.round(general.experience * 0.5), + dedication: Math.round(general.dedication * 0.5), + meta: nextMeta, }, general.id ) @@ -113,6 +148,14 @@ export class ActionDefinition< this.resolver = new ActionResolver(); } + getPreReqTurn(): number { + return 1; + } + + getPostReqTurn(): number { + return 0; + } + parseArgs(_raw: unknown): RetireArgs | null { return {}; } diff --git a/packages/logic/src/actions/turn/general/che_인재탐색.ts b/packages/logic/src/actions/turn/general/che_인재탐색.ts index 8f98878..6d22b5b 100644 --- a/packages/logic/src/actions/turn/general/che_인재탐색.ts +++ b/packages/logic/src/actions/turn/general/che_인재탐색.ts @@ -275,6 +275,10 @@ export class ActionResolver< const statKey = pickStatExpKey(context.rng, general); const metaAfter = found ? addMetaNumber(general.meta, statKey, 3) : addMetaNumber(general.meta, statKey, 1); + if (found) { + const active = typeof metaAfter.inherit_active_action === 'number' ? metaAfter.inherit_active_action : 0; + metaAfter.inherit_active_action = active + Math.max(Math.sqrt(1 / prop), 1); + } const nextGold = Math.max(0, general.gold - reqGold); const nextRice = Math.max(0, general.rice - reqRice); @@ -319,12 +323,7 @@ export class ActionResolver< ? this.env.decorateName(resolvedCandidate.name, NPC_TYPE) : resolvedCandidate.name; const meta: GeneralMeta = { - killturn: resolveKillturnFromDeathYear( - context.currentYear, - context.currentMonth, - deathYear, - context.rng - ), + killturn: resolveKillturnFromDeathYear(context.currentYear, context.currentMonth, deathYear, context.rng), npcType: NPC_TYPE, crewTypeId: this.env.defaultCrewTypeId, }; diff --git a/packages/logic/src/actions/turn/general/che_임관.ts b/packages/logic/src/actions/turn/general/che_임관.ts index 99ef9fb..76a7891 100644 --- a/packages/logic/src/actions/turn/general/che_임관.ts +++ b/packages/logic/src/actions/turn/general/che_임관.ts @@ -1,4 +1,4 @@ -import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { beNeutral, @@ -10,14 +10,19 @@ import { } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; -import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js'; -import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js'; +import { createGeneralPatchEffect, 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 { + ActionContextBase, + ActionContextBuilder, + ActionContextOptions, +} from '@sammo-ts/logic/actions/turn/actionContext.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'; const ACTION_NAME = '임관'; const ARGS_SCHEMA = z.object({ @@ -25,6 +30,14 @@ const ARGS_SCHEMA = z.object({ }); export type AppointmentArgs = z.infer; +interface AppointmentContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destNation?: Nation; + destNationGeneralCount: number; + destCityId: number; +} + const parseNationId = (raw: unknown): number | null => { if (typeof raw !== 'number' || !Number.isFinite(raw)) { return null; @@ -37,6 +50,11 @@ export class ActionDefinition< > implements GeneralActionDefinition { public readonly key = 'che_임관'; public readonly name = ACTION_NAME; + constructor(private readonly env: TurnCommandEnv) {} + + getInheritanceActiveActionAmount(): number { + return 1; + } parseArgs(raw: unknown): AppointmentArgs | null { const data = parseArgsWithSchema(ARGS_SCHEMA, raw); @@ -61,9 +79,11 @@ export class ActionDefinition< buildConstraints(_ctx: ConstraintContext, _args: AppointmentArgs): Constraint[] { const env = _ctx.env; - const year = typeof env.year === 'number' ? env.year : 0; - const startYear = typeof env.startyear === 'number' ? env.startyear : 0; - const relYear = year - startYear; + const relYear = + typeof env.relYear === 'number' + ? env.relYear + : (typeof env.year === 'number' ? env.year : 0) - + (typeof env.startYear === 'number' ? env.startYear : 0); return [ reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), @@ -75,31 +95,82 @@ export class ActionDefinition< ]; } - resolve( - context: GeneralActionResolveContext, - args: AppointmentArgs - ): GeneralActionOutcome { - const destNationName = context.nation?.name ?? `${args.destNationId}`; + resolve(context: AppointmentContext, args: AppointmentArgs): GeneralActionOutcome { + const destNation = context.destNation; + const destNationName = destNation?.name ?? `${args.destNationId}`; context.addLog(`${destNationName}에 임관했습니다.`, { category: LogCategory.ACTION, format: LogFormat.MONTH, }); + context.addLog(`${destNationName}에 임관`, { + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }); + const josaYi = JosaUtil.pick(context.general.name, '이'); + context.addLog(`${context.general.name}${josaYi} ${destNationName}임관했습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + format: LogFormat.RAWTEXT, + }); tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); - const effects = [ + const effects: GeneralActionOutcome['effects'] = [ createGeneralPatchEffect({ nationId: args.destNationId, - officerLevel: 1, // Common Officer + officerLevel: 1, + cityId: context.destCityId, + troopId: 0, + experience: + context.general.experience + + (context.destNationGeneralCount < this.env.initialNationGenLimit ? 700 : 100), + meta: { + ...context.general.meta, + officer_city: 0, + belong: 1, + }, }), ]; + if (destNation) { + effects.push( + createNationPatchEffect( + { + meta: { + ...destNation.meta, + gennum: context.destNationGeneralCount + 1, + }, + }, + destNation.id + ) + ); + } return { effects }; } } // 예약 턴 실행은 기본 컨텍스트만 사용한다. -export const actionContextBuilder = defaultActionContextBuilder; +export const actionContextBuilder: ActionContextBuilder = ( + base: ActionContextBase, + options: ActionContextOptions +) => { + const worldRef = options.worldRef; + if (!worldRef) { + return null; + } + const destNation = worldRef.getNationById(options.actionArgs.destNationId); + if (!destNation) { + return null; + } + const nationGenerals = worldRef.listGenerals().filter((general) => general.nationId === destNation.id); + const monarch = nationGenerals.find((general) => general.officerLevel === 12); + return { + ...base, + destNation, + destNationGeneralCount: nationGenerals.length, + destCityId: monarch?.cityId ?? destNation.capitalCityId ?? base.general.cityId, + }; +}; export const commandSpec: GeneralTurnCommandSpec = { key: 'che_임관', @@ -107,5 +178,5 @@ export const commandSpec: GeneralTurnCommandSpec = { reqArg: true, availabilityArgs: { destNationId: 0 }, argsSchema: ARGS_SCHEMA, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_장수대상임관.ts b/packages/logic/src/actions/turn/general/che_장수대상임관.ts index ebe8dbd..0cef19d 100644 --- a/packages/logic/src/actions/turn/general/che_장수대상임관.ts +++ b/packages/logic/src/actions/turn/general/che_장수대상임관.ts @@ -1,11 +1,6 @@ import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js'; -import { - allowJoinAction, - beNeutral, - reqEnvValue, - unknownOrDeny, -} from '@sammo-ts/logic/constraints/presets.js'; +import { allowJoinAction, beNeutral, reqEnvValue, unknownOrDeny } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import { createGeneralPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; @@ -93,7 +88,10 @@ const allowJoinDestNation = (destGeneralID: number): Constraint => ({ return { kind: 'deny', reason: '국가 정보가 없습니다.' }; } - const relYear = typeof view.get({ kind: 'env', key: 'relYear' }) === 'number' ? (view.get({ kind: 'env', key: 'relYear' }) as number) : 0; + const relYear = + typeof view.get({ kind: 'env', key: 'relYear' }) === 'number' + ? (view.get({ kind: 'env', key: 'relYear' }) as number) + : 0; const openingPartYear = typeof view.get({ kind: 'env', key: 'openingPartYear' }) === 'number' ? (view.get({ kind: 'env', key: 'openingPartYear' }) as number) @@ -129,20 +127,23 @@ const allowJoinDestNation = (destGeneralID: number): Constraint => ({ export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition> { +> implements GeneralActionDefinition< + TriggerState, + FollowAppointmentArgs, + FollowAppointmentResolveContext +> { public readonly key = ACTION_KEY; public readonly name = ACTION_NAME; + getInheritanceActiveActionAmount(): number { + return 1; + } parseArgs(raw: unknown): FollowAppointmentArgs | null { return parseArgsWithSchema(ARGS_SCHEMA, raw); } buildMinConstraints(_ctx: ConstraintContext, _args: FollowAppointmentArgs): Constraint[] { - return [ - reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), - beNeutral(), - allowJoinAction(), - ]; + return [reqEnvValue('join_mode', '!=', 'onlyRandom', '랜덤 임관만 가능합니다'), beNeutral(), allowJoinAction()]; } buildConstraints(ctx: ConstraintContext, args: FollowAppointmentArgs): Constraint[] { @@ -233,7 +234,7 @@ export const actionContextBuilder: ActionContextBuilder = } const destGeneral = worldRef.getGeneralById(destGeneralID) ?? undefined; - const destNation = destGeneral ? worldRef.getNationById(destGeneral.nationId) ?? undefined : undefined; + const destNation = destGeneral ? (worldRef.getNationById(destGeneral.nationId) ?? undefined) : undefined; if (!destGeneral || !destNation) { return null; } diff --git a/packages/logic/src/actions/turn/general/che_전투태세.ts b/packages/logic/src/actions/turn/general/che_전투태세.ts index ea8213c..1cf63b4 100644 --- a/packages/logic/src/actions/turn/general/che_전투태세.ts +++ b/packages/logic/src/actions/turn/general/che_전투태세.ts @@ -1,4 +1,4 @@ -import type { GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { GeneralMeta, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { notBeNeutral, @@ -34,11 +34,6 @@ const readNationTech = (nation: Nation | null | undefined): number => { return typeof tech === 'number' ? tech : 0; }; -const readBattleStanceTerm = (meta: Record): number => { - const value = meta.battle_stance_term; - return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0; -}; - export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionDefinition { @@ -47,13 +42,20 @@ export class ActionDefinition< constructor(private readonly env: TurnCommandEnv) {} + getPreReqTurn(): number { + return 3; + } + + getPostReqTurn(): number { + return 0; + } + parseArgs(_raw: unknown): BattlePreparationArgs | null { return {}; } buildConstraints(_ctx: ConstraintContext, _args: BattlePreparationArgs): Constraint[] { - const nationRequirement = - _ctx.nationId !== undefined ? [{ kind: 'nation', id: _ctx.nationId } as const] : []; + const nationRequirement = _ctx.nationId !== undefined ? [{ kind: 'nation', id: _ctx.nationId } as const] : []; return [ notBeNeutral(), notWanderingNation(), @@ -62,7 +64,9 @@ export class ActionDefinition< reqGeneralGold((ctx, view) => { const general = view.get({ kind: 'general', id: ctx.actorId }) as { crew?: number } | null; const nation = - ctx.nationId !== undefined ? (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null) : null; + ctx.nationId !== undefined + ? (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null) + : null; const crew = typeof general?.crew === 'number' ? general.crew : 0; const techCost = getTechCost(readNationTech(nation)); return Math.round((crew / 100) * 3 * techCost); @@ -78,13 +82,18 @@ export class ActionDefinition< _args: BattlePreparationArgs ): GeneralActionOutcome { const general = context.general; - const nation = context.nation; const crew = general.crew; - const techCost = getTechCost(readNationTech(nation)); - const costGold = Math.round((crew / 100) * 3 * techCost); - const previousTerm = readBattleStanceTerm(general.meta as Record); - const term = previousTerm >= REQ_TERM ? 1 : previousTerm + 1; + const lastTurn = general.lastTurn; + const term = + lastTurn?.command !== ACTION_NAME + ? 1 + : lastTurn.term === REQ_TERM + ? 1 + : typeof lastTurn.term === 'number' && lastTurn.term < REQ_TERM + ? lastTurn.term + 1 + : 1; + const resultTurn = { command: ACTION_NAME, term }; if (term < REQ_TERM) { context.addLog(`병사들을 열심히 훈련중... (${term}/3)`, { @@ -95,11 +104,7 @@ export class ActionDefinition< return { effects: [ createGeneralPatchEffect({ - gold: Math.max(0, general.gold - costGold), - meta: { - ...general.meta, - battle_stance_term: term, - }, + lastTurn: resultTurn, }), ], }; @@ -114,18 +119,26 @@ export class ActionDefinition< tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0; + const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId); + const dexKey = crewType ? `dex${crewType.armType}` : null; + const nextMeta: GeneralMeta = { + ...general.meta, + leadership_exp: leadershipExp + 3, + }; + if (dexKey) { + const dex = typeof nextMeta[dexKey] === 'number' ? nextMeta[dexKey] : 0; + nextMeta[dexKey] = dex + (crew / 100) * 3; + } return { effects: [ createGeneralPatchEffect({ - gold: Math.max(0, general.gold - costGold), + lastTurn: resultTurn, + train: Math.max(general.train, this.env.maxTrainByCommand - 5), + atmos: Math.max(general.atmos, this.env.maxAtmosByCommand - 5), experience: general.experience + 300, dedication: general.dedication + 210, - meta: { - ...general.meta, - battle_stance_term: term, - leadership_exp: leadershipExp + 3, - }, + meta: nextMeta, }), ], }; diff --git a/packages/logic/src/actions/turn/general/che_전투특기초기화.ts b/packages/logic/src/actions/turn/general/che_전투특기초기화.ts index eb21ade..7f5b6ad 100644 --- a/packages/logic/src/actions/turn/general/che_전투특기초기화.ts +++ b/packages/logic/src/actions/turn/general/che_전투특기초기화.ts @@ -8,6 +8,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { setMetaNumber } from '@sammo-ts/logic/war/utils.js'; +import { WAR_TRAIT_KEYS } from '@sammo-ts/logic/triggers/special/war/index.js'; export interface ResetSpecialWarArgs {} @@ -38,6 +39,23 @@ export class ActionDefinition< public readonly key = 'che_전투특기초기화'; public readonly name = ACTION_NAME; + getPreReqTurn(): number { + return 1; + } + + getPostReqTurn(): number { + return 60; + } + + getProgressText( + _context: GeneralActionResolveContext, + _args: ResetSpecialWarArgs, + term: number, + termMax: number + ): string { + return `새로운 적성을 찾는 중... (${term}/${termMax})`; + } + parseArgs(_raw: unknown): ResetSpecialWarArgs | null { void _raw; return {}; @@ -56,6 +74,13 @@ export class ActionDefinition< _args: ResetSpecialWarArgs ): GeneralActionOutcome { const general = context.general; + const previous = general.meta.prev_types_special2; + const previousTypes = Array.isArray(previous) + ? previous.filter((value): value is string => typeof value === 'string') + : []; + const nextPreviousTypes = [...previousTypes, general.role.specialWar!]; + general.meta.prev_types_special2 = + nextPreviousTypes.length === WAR_TRAIT_KEYS.length ? [general.role.specialWar!] : nextPreviousTypes; general.role.specialWar = null; setMetaNumber(general.meta, 'specAge2', general.age + 1); const specialName = ACTION_NAME.replace(' 초기화', ''); diff --git a/packages/logic/src/actions/turn/general/che_정착장려.ts b/packages/logic/src/actions/turn/general/che_정착장려.ts index 6679f98..da55e21 100644 --- a/packages/logic/src/actions/turn/general/che_정착장려.ts +++ b/packages/logic/src/actions/turn/general/che_정착장려.ts @@ -1,5 +1,5 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; -import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; +import type { Constraint, ConstraintContext, RequirementKey, StateView } from '@sammo-ts/logic/constraints/types.js'; import { notBeNeutral, notWanderingNation, @@ -12,74 +12,69 @@ import { import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; -import { clamp } from 'es-toolkit'; import type { GeneralTurnCommandSpec } from './index.js'; +import { + actionContextBuilder, + buildDomesticContextFromView, + CommandResolver, + type DomesticActionContext, + type InvestmentConfig, +} from './che_상업투자.js'; import { JosaUtil } from '@sammo-ts/common'; +import { clamp } from 'es-toolkit'; export interface SettlementArgs {} -type SettlementPick = 'success' | 'normal' | 'fail'; -const pickByWeight = (rng: GeneralActionResolveContext['rng'], weights: Record): T => { - const entries = Object.entries(weights) as Array<[T, number]>; - const first = entries[0]; - if (!first) { - throw new Error('Empty weights'); - } - - let total = 0; - for (const [, weight] of entries) { - if (weight > 0) { - total += weight; - } - } - if (total <= 0) { - return first[0]; - } - - let cursor = rng.nextFloat1() * total; - for (const [key, weight] of entries) { - if (weight <= 0) { - continue; - } - cursor -= weight; - if (cursor <= 0) { - return key; - } - } - - const last = entries[entries.length - 1]; - return last ? last[0] : first[0]; +const ACTION_NAME = '정착 장려'; +const CONFIG: InvestmentConfig = { + key: 'che_정착장려', + name: ACTION_NAME, + actionKey: '인구', + statKey: 'leadership', + statExpKey: 'leadership_exp', + cityKey: 'commerce', + cityMaxKey: 'commerceMax', + frontDebuff: 1, + useCityTrust: false, + scaleSuccessByTrust: false, }; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionDefinition { - public readonly key = 'che_정착장려'; - public readonly name = '정착 장려'; - private readonly env: { develCost?: number }; + public readonly key = CONFIG.key; + public readonly name = ACTION_NAME; + private readonly command: CommandResolver; - constructor(env: { develCost?: number } = {}) { - this.env = env; + constructor(env: TurnCommandEnv) { + this.command = new CommandResolver( + env.generalActionModules ?? [], + { ...env, develCost: env.develCost * 2 }, + CONFIG + ); } parseArgs(_raw: unknown): SettlementArgs | null { return {}; } - buildConstraints(_ctx: ConstraintContext, _args: SettlementArgs): Constraint[] { - const getRequiredRice = (_context: ConstraintContext, _view: StateView): number => - (this.env.develCost ?? 0) * 2; - + buildConstraints(ctx: ConstraintContext, _args: SettlementArgs): Constraint[] { + const requirements: RequirementKey[] = []; + if (ctx.cityId !== undefined) requirements.push({ kind: 'city', id: ctx.cityId }); + if (ctx.nationId !== undefined) requirements.push({ kind: 'nation', id: ctx.nationId }); + const getRiceCost = (context: ConstraintContext, view: StateView): number => { + const domesticContext = buildDomesticContextFromView(context, view); + return domesticContext ? this.command.getCost(domesticContext).gold : 0; + }; return [ notBeNeutral(), notWanderingNation(), occupiedCity(), suppliedCity(), - reqGeneralGold(() => 0), - remainCityCapacity('population', '인구'), - reqGeneralRice(getRequiredRice), + reqGeneralGold(() => 0, requirements), + reqGeneralRice(getRiceCost, requirements), + remainCityCapacity('population', ACTION_NAME), ]; } @@ -87,54 +82,46 @@ export class ActionDefinition< context: GeneralActionResolveContext, _args: SettlementArgs ): GeneralActionOutcome { - const general = context.general; - const city = context.city; - if (!city) { + if (!context.city) { context.addLog('도시 정보를 찾지 못했습니다.'); return { effects: [] }; } + const domesticContext = context as DomesticActionContext; + const result = this.command.resolve(domesticContext, context.rng); + const populationGain = result.score * 10; + context.city.population = clamp(context.city.population + populationGain, 0, context.city.populationMax); + context.general.rice = Math.max(0, context.general.rice - result.costGold); + context.general.experience += result.exp; + context.general.dedication += result.dedication; + const leadershipExp = + typeof context.general.meta.leadership_exp === 'number' ? context.general.meta.leadership_exp : 0; + context.general.meta = { + ...context.general.meta, + leadership_exp: leadershipExp + 1, + max_domestic_critical: result.pick === 'success' ? result.score : 0, + }; - const pick = pickByWeight(context.rng, { - fail: 0.2, - success: 0.2, - normal: 0.6, - }); - - const scoreCoef = pick === 'success' ? 1.3 : pick === 'fail' ? 0.7 : 1; - const baseAmount = Math.round(1000 * scoreCoef); - const current = city.population; - const max = city.populationMax; - - const nextValue = clamp(current + baseAmount, 0, max); - const costRice = (this.env.develCost ?? 0) * 2; - - city.population = nextValue; - general.rice = Math.max(0, general.rice - costRice); - - const scoreText = (nextValue - current).toLocaleString(); - const logName = this.name.replace(' ', ''); - const josaUl = JosaUtil.pick(logName, '을'); - if (pick === 'fail') { + const scoreText = populationGain.toLocaleString(); + const josaUl = JosaUtil.pick(ACTION_NAME, '을'); + if (result.pick === 'fail') { context.addLog( - `${logName}${josaUl} 실패하여 주민이 ${scoreText}명 증가했습니다.` + `${ACTION_NAME}${josaUl} 실패하여 주민이 ${scoreText}명 증가했습니다.` ); - } else if (pick === 'success') { - context.addLog(`${logName}${josaUl} 성공하여 주민이 ${scoreText}명 증가했습니다.`); + } else if (result.pick === 'success') { + context.addLog(`${ACTION_NAME}${josaUl} 성공하여 주민이 ${scoreText}명 증가했습니다.`); } else { - context.addLog(`${logName}${josaUl} 하여 주민이 ${scoreText}명 증가했습니다.`); + context.addLog(`${ACTION_NAME}${josaUl} 하여 주민이 ${scoreText}명 증가했습니다.`); } - tryApplyUniqueLottery(context, { acquireType: '아이템', reason: '정착 장려' }); - + tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); return { effects: [] }; } } -export const actionContextBuilder = defaultActionContextBuilder; +export { actionContextBuilder }; export const commandSpec: GeneralTurnCommandSpec = { key: 'che_정착장려', category: '내정', reqArg: false, - - createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_주민선정.ts b/packages/logic/src/actions/turn/general/che_주민선정.ts index 0e4bc1b..ac7e256 100644 --- a/packages/logic/src/actions/turn/general/che_주민선정.ts +++ b/packages/logic/src/actions/turn/general/che_주민선정.ts @@ -1,7 +1,5 @@ -import type { RandomGenerator } from '@sammo-ts/common'; -import { JosaUtil } from '@sammo-ts/common'; -import type { City, General, GeneralMeta, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; -import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js'; +import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { Constraint, ConstraintContext, RequirementKey, StateView } from '@sammo-ts/logic/constraints/types.js'; import { notBeNeutral, notWanderingNation, @@ -10,126 +8,81 @@ import { reqGeneralRice, suppliedCity, } from '@sammo-ts/logic/constraints/presets.js'; -import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; -import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; +import { + actionContextBuilder, + buildDomesticContextFromView, + CommandResolver, + type DomesticActionContext, + type InvestmentConfig, +} from './che_상업투자.js'; +import { JosaUtil } from '@sammo-ts/common'; import { clamp } from 'es-toolkit'; -export interface DomesticActionContext< - TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends GeneralActionContext { - general: General; - city: City; - nation?: Nation | null; -} - -export interface TrustEnvironment { - develCost: number; - defaultTrust?: number; -} - export interface TrustActionArgs {} const ACTION_NAME = '주민 선정'; -const ACTION_KEY = '민심'; -const STAT_EXP_KEY = 'leadership_exp'; const DEFAULT_TRUST = 50; - -const getMetaNumber = (meta: Record, key: string): number | null => { - const raw = meta[key]; - return typeof raw === 'number' ? raw : null; +const CONFIG: InvestmentConfig = { + key: 'che_주민선정', + name: ACTION_NAME, + actionKey: '민심', + statKey: 'leadership', + statExpKey: 'leadership_exp', + cityKey: 'commerce', + cityMaxKey: 'commerceMax', + frontDebuff: 1, + useCityTrust: false, + scaleSuccessByTrust: false, + roundCriticalScore: false, }; -const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMeta => { - const current = getMetaNumber(meta, key) ?? 0; - return { ...meta, [key]: current + delta }; +const readTrust = (city: City): number => { + const trust = city.meta.trust; + return typeof trust === 'number' && Number.isFinite(trust) ? trust : DEFAULT_TRUST; }; -const randomRange = (rng: RandomGenerator, min: number, max: number): number => min + (max - min) * rng.nextFloat1(); - const remainCityTrust = (): Constraint => ({ name: 'remainCityTrust', requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), test: (ctx, view) => { const city = ctx.cityId !== undefined ? (view.get({ kind: 'city', id: ctx.cityId }) as City | null) : null; - if (!city) { - return { kind: 'deny', reason: '도시 정보가 없습니다.' }; - } - const trust = getMetaNumber(city.meta, 'trust') ?? DEFAULT_TRUST; - if (trust >= 100) { - return { kind: 'deny', reason: '민심이 충분합니다.' }; - } - return { kind: 'allow' }; + if (!city) return { kind: 'deny', reason: '도시 정보가 없습니다.' }; + return readTrust(city) >= 100 ? { kind: 'deny', reason: '민심이 충분합니다.' } : { kind: 'allow' }; }, }); -export class CommandResolver< - TriggerState extends GeneralTriggerState = GeneralTriggerState, -> { - private readonly pipeline: GeneralActionPipeline; - private readonly env: TrustEnvironment; - - constructor(modules: Array | null | undefined>, env: TrustEnvironment) { - this.pipeline = new GeneralActionPipeline(modules); - this.env = env; - } - - calcBaseScore(context: DomesticActionContext, rng: RandomGenerator): number { - let score = this.pipeline.onCalcStat(context, 'leadership', context.general.stats.leadership); - score *= randomRange(rng, 0.8, 1.2); - return this.pipeline.onCalcDomestic(context, ACTION_KEY, 'score', score); - } - - resolve(context: DomesticActionContext, rng: RandomGenerator): { - trustDelta: number; - exp: number; - dedication: number; - costRice: number; - } { - const costRice = (this.env.develCost ?? 0) * 2; - const baseScore = clamp(this.calcBaseScore(context, rng), 1, Number.MAX_SAFE_INTEGER); - const exp = baseScore * 0.7; - const dedication = baseScore * 1.0; - const trustDelta = Math.round(baseScore) / 10; - return { trustDelta, exp, dedication, costRice }; - } -} - export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionDefinition { - public readonly key = 'che_주민선정'; + public readonly key = CONFIG.key; public readonly name = ACTION_NAME; private readonly command: CommandResolver; - private readonly env: TrustEnvironment; - constructor(modules: Array | null | undefined>, env: TrustEnvironment) { - this.env = env; - this.command = new CommandResolver(modules, env); + constructor(env: TurnCommandEnv) { + this.command = new CommandResolver( + env.generalActionModules ?? [], + { ...env, develCost: env.develCost * 2 }, + CONFIG + ); } parseArgs(_raw: unknown): TrustActionArgs | null { - void _raw; return {}; } buildConstraints(ctx: ConstraintContext, _args: TrustActionArgs): Constraint[] { - void _args; const requirements: RequirementKey[] = []; - if (ctx.cityId !== undefined) { - requirements.push({ kind: 'city', id: ctx.cityId }); - } - if (ctx.nationId !== undefined) { - requirements.push({ kind: 'nation', id: ctx.nationId }); - } - - const getRiceCost = (): number => (this.env.develCost ?? 0) * 2; - + if (ctx.cityId !== undefined) requirements.push({ kind: 'city', id: ctx.cityId }); + if (ctx.nationId !== undefined) requirements.push({ kind: 'nation', id: ctx.nationId }); + const getRiceCost = (context: ConstraintContext, view: StateView): number => { + const domesticContext = buildDomesticContextFromView(context, view); + return domesticContext ? this.command.getCost(domesticContext).gold : 0; + }; return [ notBeNeutral(), notWanderingNation(), @@ -145,44 +98,48 @@ export class ActionDefinition< context: GeneralActionResolveContext, _args: TrustActionArgs ): GeneralActionOutcome { - const general = context.general; - const city = context.city; - if (!city) { + if (!context.city) { context.addLog('도시 정보를 찾지 못했습니다.'); return { effects: [] }; } - const trust = getMetaNumber(city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST; - const result = this.command.resolve( - { - ...context, - city, - nation: context.nation ?? null, - }, - context.rng - ); + const result = this.command.resolve(context as DomesticActionContext, context.rng); + const trustDelta = result.score / 10; + context.city.meta = { + ...context.city.meta, + trust: clamp(readTrust(context.city) + trustDelta, 0, 100), + }; + context.general.rice = Math.max(0, context.general.rice - result.costGold); + context.general.experience += result.exp; + context.general.dedication += result.dedication; + const leadershipExp = + typeof context.general.meta.leadership_exp === 'number' ? context.general.meta.leadership_exp : 0; + context.general.meta = { + ...context.general.meta, + leadership_exp: leadershipExp + 1, + max_domestic_critical: result.pick === 'success' ? result.score : 0, + }; - const nextTrust = clamp(trust + result.trustDelta, 0, 100); - city.meta = { ...city.meta, trust: nextTrust }; - general.rice = Math.max(0, general.rice - result.costRice); - general.experience += result.exp; - general.dedication += result.dedication; - general.meta = { ...addMetaNumber(general.meta, STAT_EXP_KEY, 1), max_domestic_critical: 0 }; - - const scoreText = result.trustDelta.toFixed(1); - const logName = ACTION_NAME.replace(' ', ''); - const josaUl = JosaUtil.pick(logName, '을'); - context.addLog(`${logName}${josaUl} 하여 ${scoreText} 상승했습니다.`); + const scoreText = trustDelta.toFixed(1); + const josaUl = JosaUtil.pick(ACTION_NAME, '을'); + if (result.pick === 'fail') { + context.addLog( + `${ACTION_NAME}${josaUl} 실패하여 ${scoreText} 상승했습니다.` + ); + } else if (result.pick === 'success') { + context.addLog(`${ACTION_NAME}${josaUl} 성공하여 ${scoreText} 상승했습니다.`); + } else { + context.addLog(`${ACTION_NAME}${josaUl} 하여 ${scoreText} 상승했습니다.`); + } tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); return { effects: [] }; } } -export const actionContextBuilder = defaultActionContextBuilder; +export { actionContextBuilder }; export const commandSpec: GeneralTurnCommandSpec = { key: 'che_주민선정', category: '내정', reqArg: false, - - createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? [], env), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_징병.ts b/packages/logic/src/actions/turn/general/che_징병.ts index ed68d61..829bc51 100644 --- a/packages/logic/src/actions/turn/general/che_징병.ts +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -290,6 +290,26 @@ export class CommandResolver, + armType: number, + amount: number + ): { + key: string; + amount: number; + } | null { + const dexArmType = armType === 0 ? 5 : armType; + if (dexArmType < 0) { + return null; + } + const typeMultiplier = dexArmType === 4 || dexArmType === 5 ? 0.9 : 1; + const amountWithType = (amount / 100) * typeMultiplier; + return { + key: `dex${dexArmType}`, + amount: this.pipeline.onCalcStat(context, 'addDex', amountWithType, { armType: dexArmType }), + }; + } } export class ActionResolver< @@ -347,32 +367,36 @@ export class ActionResolver< const trustLoss = city.population > 0 ? (recruitPop / city.population / costOffset) * 100 : 0; const nextTrust = Math.max(baseTrust - trustLoss, 0); - let nextCrewTypeId = general.crewTypeId; - let nextCrew = general.crew; - let nextTrain = general.train; - let nextAtmos = general.atmos; const actionName = this.env.actionName ?? ACTION_NAME; - if (crewType.id === general.crewTypeId && general.crew > 0) { - nextCrew = general.crew + appliedCrew; - nextTrain = Math.round( - (general.crew * general.train + appliedCrew * setTrain) / (general.crew + appliedCrew) - ); - nextAtmos = Math.round( - (general.crew * general.atmos + appliedCrew * setAtmos) / (general.crew + appliedCrew) - ); - context.addLog(`${crewType.name} ${appliedCrew.toLocaleString()}명을 추가${actionName}했습니다.`); - } else { - nextCrewTypeId = crewType.id; - nextCrew = appliedCrew; - nextTrain = Math.round(setTrain); - nextAtmos = Math.round(setAtmos); - context.addLog(`${crewType.name} ${appliedCrew.toLocaleString()}명을 ${actionName}했습니다.`); - } + const [nextCrewTypeId, nextCrew, nextTrain, nextAtmos] = + crewType.id === general.crewTypeId && general.crew > 0 + ? (() => { + context.addLog( + `${crewType.name} ${appliedCrew.toLocaleString()}명을 추가${actionName}했습니다.` + ); + return [ + general.crewTypeId, + general.crew + appliedCrew, + Math.round( + (general.crew * general.train + appliedCrew * setTrain) / (general.crew + appliedCrew) + ), + Math.round( + (general.crew * general.atmos + appliedCrew * setAtmos) / (general.crew + appliedCrew) + ), + ] as const; + })() + : (() => { + context.addLog( + `${crewType.name} ${appliedCrew.toLocaleString()}명을 ${actionName}했습니다.` + ); + return [crewType.id, appliedCrew, Math.round(setTrain), Math.round(setAtmos)] as const; + })(); const nextGold = Math.max(0, general.gold - plan.gold); const nextRice = Math.max(0, general.rice - plan.rice); const expGain = Math.round(appliedCrew / 100); const dedGain = Math.round(appliedCrew / 100); + const dexGain = this.command.getDexGain(context, crewType.armType, appliedCrew); // 직접 수정 (Immer Draft) city.population = nextPopulation; @@ -390,6 +414,9 @@ export class ActionResolver< general.experience += expGain; general.dedication += dedGain; general.meta = addMetaNumber(general.meta, 'leadership_exp', 1); + if (dexGain) { + general.meta = addMetaNumber(general.meta, dexGain.key, dexGain.amount); + } tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); diff --git a/packages/logic/src/actions/turn/general/che_첩보.ts b/packages/logic/src/actions/turn/general/che_첩보.ts index e5769e0..5739e33 100644 --- a/packages/logic/src/actions/turn/general/che_첩보.ts +++ b/packages/logic/src/actions/turn/general/che_첩보.ts @@ -255,8 +255,8 @@ export class ActionResolver< ...general, gold: Math.max(0, general.gold - cost), rice: Math.max(0, general.rice - cost), - experience: general.experience + 50, - dedication: general.dedication + 30, + experience: general.experience + ctx.rng.nextInt(1, 101), + dedication: general.dedication + ctx.rng.nextInt(1, 71), meta: { ...general.meta, leadership_exp: @@ -276,6 +276,9 @@ export class ActionDefinition< > implements GeneralActionDefinition> { public readonly key = ACTION_KEY; public readonly name = ACTION_NAME; + getInheritanceActiveActionAmount(): number { + return 0.5; + } private readonly resolver: ActionResolver; constructor() { @@ -295,11 +298,7 @@ export class ActionDefinition< buildConstraints(ctx: ConstraintContext, _args: SpyArgs): Constraint[] { const env = ctx.env; const cost = ((env.develCost as number) ?? 100) * 3; - return [ - notOccupiedDestCity(), - reqGeneralGold(() => cost), - reqGeneralRice(() => cost), - ]; + return [notOccupiedDestCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)]; } resolve(context: GeneralActionResolveContext, args: SpyArgs): GeneralActionOutcome { diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index dc898c7..74a78db 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -241,6 +241,9 @@ export class ActionDefinition< > implements GeneralActionDefinition> { public readonly key = 'che_출병'; public readonly name = ACTION_NAME; + getInheritanceActiveActionAmount(): number { + return 1; + } private readonly warModules: Array>; constructor(modules: Array | null | undefined> = []) { diff --git a/packages/logic/src/actions/turn/general/che_치안강화.ts b/packages/logic/src/actions/turn/general/che_치안강화.ts index 84097c7..0b52f3d 100644 --- a/packages/logic/src/actions/turn/general/che_치안강화.ts +++ b/packages/logic/src/actions/turn/general/che_치안강화.ts @@ -1,34 +1,31 @@ import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; -import { CityDevelopmentActionDefinition } from './cityDevelopment.js'; +import { ActionDefinition as DomesticActionDefinition, actionContextBuilder } from './che_상업투자.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends CityDevelopmentActionDefinition { - constructor(env: { develCost?: number; amount?: number } = {}) { - super( - { - key: 'che_치안강화', - name: '치안 강화', - statKey: 'security', - maxKey: 'securityMax', - label: '치안', - baseAmount: 50, - }, - env - ); +> extends DomesticActionDefinition { + constructor(env: TurnCommandEnv) { + super(env.generalActionModules ?? [], env, { + key: 'che_치안강화', + name: '치안 강화', + actionKey: '치안', + statKey: 'strength', + statExpKey: 'strength_exp', + cityKey: 'security', + cityMaxKey: 'securityMax', + frontDebuff: 1, + }); } } -// 예약 턴 실행은 기본 컨텍스트만 사용한다. -export const actionContextBuilder = defaultActionContextBuilder; +export { actionContextBuilder }; export const commandSpec: GeneralTurnCommandSpec = { key: 'che_치안강화', category: '내정', reqArg: false, - createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_탈취.ts b/packages/logic/src/actions/turn/general/che_탈취.ts index 03fab42..62a7aae 100644 --- a/packages/logic/src/actions/turn/general/che_탈취.ts +++ b/packages/logic/src/actions/turn/general/che_탈취.ts @@ -1,4 +1,4 @@ -import type { City, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { notBeNeutral, @@ -17,11 +17,7 @@ import type { GeneralActionResolver, GeneralActionEffect, } from '@sammo-ts/logic/actions/engine.js'; -import { - createGeneralPatchEffect, - createCityPatchEffect, - createNationPatchEffect, -} from '@sammo-ts/logic/actions/engine.js'; +import { createCityPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js'; import { z } from 'zod'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; @@ -29,16 +25,20 @@ import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/ac import type { GeneralTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; import { parseArgsWithSchema } from '../parseArgs.js'; -import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js'; import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js'; +import { + buildStrategyActionContext, + CommandResolver as StrategyCommandResolver, + type FireAttackResolveContext, +} from './che_화계.js'; export interface SeizeResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends GeneralActionResolveContext { - destCity?: City; - destNation?: Nation | null; +> extends FireAttackResolveContext { env?: TurnCommandEnv; year?: number; + startYear?: number; } const ACTION_NAME = '탈취'; @@ -53,41 +53,53 @@ export class ActionResolver< > implements GeneralActionResolver { readonly key = ACTION_KEY; private readonly pipeline: GeneralActionPipeline; + private readonly command: StrategyCommandResolver; - constructor(modules: Array | null | undefined> = []) { + constructor(env: TurnCommandEnv) { + const modules = env.generalActionModules ?? []; this.pipeline = new GeneralActionPipeline(modules); + this.command = new StrategyCommandResolver(modules, { + ...env, + statKey: 'strength', + damageMode: 'seize', + injuryGeneral: false, + }); } resolve(context: GeneralActionResolveContext, args: SeizeArgs): GeneralActionOutcome { const ctx = context as SeizeResolveContext; const general = ctx.general; const nation = ctx.nation; // Own nation - const { destCityId } = args; const destCity = ctx.destCity; const destNation = ctx.destNation; if (!destCity) throw new Error('Target city missing'); - - const env = ctx.env; - const cost = env?.develCost ?? 100; - const effects: GeneralActionEffect[] = []; - - // Calculation - const min = env?.sabotageDamageMin ?? 10; - const max = env?.sabotageDamageMax ?? 30; - const rng = ctx.rng; - if (!rng) throw new Error('RNG missing'); + const city = ctx.city; + if (!city) throw new Error('Source city missing'); + const result = this.command.resolve({ ...ctx, city, destCity }, ctx.rng); + general.gold = Math.max(0, general.gold - result.costGold); + general.rice = Math.max(0, general.rice - result.costRice); + general.experience += result.exp; + general.dedication += result.dedication; + general.meta.strength_exp = (typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1; + if (!result.success) { + ctx.addLog( + `${destCity.name}에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 실패했습니다.` + ); + return { effects }; + } + general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1; const currentYear = ctx.year ?? 200; - const startYear = env?.openingPartYear ?? currentYear; + const startYear = ctx.startYear ?? currentYear; const yearCoef = Math.sqrt(1 + Math.max(0, currentYear - startYear) / 4) / 2; const commRatio = destCity.commerce / destCity.commerceMax; const agriRatio = destCity.agriculture / destCity.agricultureMax; - const rawGold = rng.nextInt(min, max + 1) * destCity.level * yearCoef * (0.25 + commRatio / 4); - const rawRice = rng.nextInt(min, max + 1) * destCity.level * yearCoef * (0.25 + agriRatio / 4); + const rawGold = result.agriDamage * destCity.level * yearCoef * (0.25 + commRatio / 4); + const rawRice = result.commDamage * destCity.level * yearCoef * (0.25 + agriRatio / 4); let stolenGold = Math.floor(rawGold); let stolenRice = Math.floor(rawRice); @@ -95,8 +107,8 @@ export class ActionResolver< const isSupplied = destCity.supplyState === 1; if (isSupplied && destNation) { - const minGold = 1000; - const minRice = 1000; + const minGold = 0; + const minRice = 0; const availableGold = Math.max(0, destNation.gold - minGold); const availableRice = Math.max(0, destNation.rice - minRice); @@ -121,7 +133,7 @@ export class ActionResolver< ...destCity, state: 34, }, - destCityId + args.destCityId ) ); } else { @@ -136,7 +148,7 @@ export class ActionResolver< agriculture: Math.max(0, destCity.agriculture - agriDmg), state: 34, }, - destCityId + args.destCityId ) ); } @@ -145,8 +157,8 @@ export class ActionResolver< let myShareRice = stolenRice; if (nation && nation.id !== 0) { - const nationShareGold = Math.floor(stolenGold * 0.7); - const nationShareRice = Math.floor(stolenRice * 0.7); + const nationShareGold = Math.round(stolenGold * 0.7); + const nationShareRice = Math.round(stolenRice * 0.7); myShareGold -= nationShareGold; myShareRice -= nationShareRice; @@ -174,24 +186,8 @@ export class ActionResolver< }); consumeSuccessfulStrategyItem(this.pipeline, context); - - effects.push( - createGeneralPatchEffect( - { - ...general, - gold: Math.max(0, general.gold - cost + myShareGold), - rice: Math.max(0, general.rice - cost + myShareRice), - experience: general.experience + 50, - dedication: general.dedication + 30, - meta: { - ...general.meta, - strength_exp: - (typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1, - }, - }, - general.id - ) - ); + general.gold += myShareGold; + general.rice += myShareRice; return { effects }; } @@ -205,9 +201,7 @@ export class ActionDefinition< private readonly resolver: ActionResolver; constructor(env: TurnCommandEnv) { - this.resolver = new ActionResolver( - (env.generalActionModules ?? []) as GeneralActionModule[] - ); + this.resolver = new ActionResolver(env); } parseArgs(raw: unknown): SeizeArgs | null { @@ -216,13 +210,13 @@ export class ActionDefinition< buildMinConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] { const env = ctx.env; - const cost = (env.develCost as number) ?? 100; + const cost = ((env.develCost as number) ?? 100) * 5; return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)]; } buildConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] { const env = ctx.env; - const cost = (env.develCost as number) ?? 100; + const cost = ((env.develCost as number) ?? 100) * 5; return [ notBeNeutral(), occupiedCity(), @@ -243,21 +237,13 @@ export class ActionDefinition< } export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => { - const destCityId = options.actionArgs?.destCityId; - let destCity = null; - let destNation = null; - if (typeof destCityId === 'number' && options.worldRef) { - destCity = options.worldRef.getCityById(destCityId); - if (destCity && destCity.nationId) { - destNation = options.worldRef.getNationById(destCity.nationId); - } - } + const strategyContext = buildStrategyActionContext(base, options); + if (!strategyContext) return null; return { - ...base, - destCity, - destNation, + ...strategyContext, env: options.scenarioConfig.const as unknown as TurnCommandEnv, year: options.world.currentYear, + startYear: options.scenarioMeta?.startYear ?? options.world.currentYear, }; }; diff --git a/packages/logic/src/actions/turn/general/che_파괴.ts b/packages/logic/src/actions/turn/general/che_파괴.ts index 5bf5572..0af57fd 100644 --- a/packages/logic/src/actions/turn/general/che_파괴.ts +++ b/packages/logic/src/actions/turn/general/che_파괴.ts @@ -1,4 +1,4 @@ -import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { notBeNeutral, @@ -25,13 +25,17 @@ import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/ac import type { GeneralTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; import { parseArgsWithSchema } from '../parseArgs.js'; -import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js'; import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js'; +import { + buildStrategyActionContext, + CommandResolver as StrategyCommandResolver, + type FireAttackResolveContext, +} from './che_화계.js'; export interface DestroyResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends GeneralActionResolveContext { - destCity?: City; +> extends FireAttackResolveContext { env?: TurnCommandEnv; } @@ -47,39 +51,41 @@ export class ActionResolver< > implements GeneralActionResolver { readonly key = ACTION_KEY; private readonly pipeline: GeneralActionPipeline; + private readonly command: StrategyCommandResolver; - constructor(modules: Array | null | undefined> = []) { + constructor(env: TurnCommandEnv) { + const modules = env.generalActionModules ?? []; this.pipeline = new GeneralActionPipeline(modules); + this.command = new StrategyCommandResolver(modules, { + ...env, + statKey: 'strength', + damageMode: 'destroy', + }); } resolve(context: GeneralActionResolveContext, args: DestroyArgs): GeneralActionOutcome { const ctx = context as DestroyResolveContext; const general = ctx.general; - const { destCityId } = args; const destCity = ctx.destCity; if (!destCity) throw new Error('Target city missing'); - - const env = ctx.env; - const cost = env?.develCost ?? 100; // 1x develCost - const effects: GeneralActionEffect[] = []; - - // Damage calc - const min = env?.sabotageDamageMin ?? 10; - const max = env?.sabotageDamageMax ?? 30; - const rng = ctx.rng; - - if (!rng) throw new Error('RNG missing'); - - const defDmg = rng.nextInt(min, max + 1); - const wallDmg = rng.nextInt(min, max + 1); - - const newDef = Math.max(0, destCity.defence - defDmg); - const newWall = Math.max(0, destCity.wall - wallDmg); - - const actualDefDmg = destCity.defence - newDef; - const actualWallDmg = destCity.wall - newWall; - const injuryCount = 0; + const city = ctx.city; + if (!city) throw new Error('Source city missing'); + const result = this.command.resolve({ ...ctx, city, destCity }, ctx.rng); + general.gold = Math.max(0, general.gold - result.costGold); + general.rice = Math.max(0, general.rice - result.costRice); + general.experience += result.exp; + general.dedication += result.dedication; + general.meta.strength_exp = (typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1; + if (!result.success) { + ctx.addLog( + `${destCity.name}에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 실패했습니다.` + ); + return { effects }; + } + general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1; + const newDef = Math.max(0, destCity.defence - result.agriDamage); + const newWall = Math.max(0, destCity.wall - result.commDamage); // Log const commandName = ACTION_NAME; @@ -89,7 +95,7 @@ export class ActionResolver< format: LogFormat.MONTH, }); ctx.addLog( - `도시의 수비가 ${actualDefDmg}, 성벽이 ${actualWallDmg}만큼 감소하고, 장수 ${injuryCount}명이 부상 당했습니다.`, + `도시의 수비가 ${result.agriDamage}, 성벽이 ${result.commDamage}만큼 감소하고, 장수 ${result.injuryCount}명이 부상 당했습니다.`, { category: LogCategory.ACTION, format: LogFormat.PLAIN, @@ -105,30 +111,14 @@ export class ActionResolver< wall: newWall, state: 32, // Legacy sabotage state }, - destCityId + args.destCityId ) ); consumeSuccessfulStrategyItem(this.pipeline, context); - - // General Update (Cost + Exp) - effects.push( - createGeneralPatchEffect( - { - ...general, - gold: Math.max(0, general.gold - cost), - rice: Math.max(0, general.rice - cost), - experience: general.experience + 50, - dedication: general.dedication + 30, - meta: { - ...general.meta, - strength_exp: - (typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1, - }, - }, - general.id - ) - ); + for (const injured of result.injuredGenerals) { + effects.push(createGeneralPatchEffect(injured.patch, injured.id)); + } return { effects }; } @@ -142,9 +132,7 @@ export class ActionDefinition< private readonly resolver: ActionResolver; constructor(env: TurnCommandEnv) { - this.resolver = new ActionResolver( - (env.generalActionModules ?? []) as GeneralActionModule[] - ); + this.resolver = new ActionResolver(env); } parseArgs(raw: unknown): DestroyArgs | null { @@ -153,13 +141,13 @@ export class ActionDefinition< buildMinConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] { const env = ctx.env; - const cost = (env.develCost as number) ?? 100; + const cost = ((env.develCost as number) ?? 100) * 5; return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)]; } buildConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] { const env = ctx.env; - const cost = (env.develCost as number) ?? 100; + const cost = ((env.develCost as number) ?? 100) * 5; return [ notBeNeutral(), occupiedCity(), @@ -180,14 +168,10 @@ export class ActionDefinition< } export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => { - const destCityId = options.actionArgs?.destCityId; - let destCity = null; - if (typeof destCityId === 'number' && options.worldRef) { - destCity = options.worldRef.getCityById(destCityId); - } + const strategyContext = buildStrategyActionContext(base, options); + if (!strategyContext) return null; return { - ...base, - destCity, + ...strategyContext, env: options.scenarioConfig.const as unknown as TurnCommandEnv, }; }; diff --git a/packages/logic/src/actions/turn/general/che_하야.ts b/packages/logic/src/actions/turn/general/che_하야.ts index c2470f6..d603f69 100644 --- a/packages/logic/src/actions/turn/general/che_하야.ts +++ b/packages/logic/src/actions/turn/general/che_하야.ts @@ -1,4 +1,4 @@ -import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; import { notBeNeutral, notLord } from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; @@ -8,12 +8,18 @@ import type { GeneralActionResolver, } from '@sammo-ts/logic/actions/engine.js'; import { createGeneralPatchEffect, createNationPatchEffect } 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 { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; +import { JosaUtil } from '@sammo-ts/common'; export interface ResignArgs {} +interface ResignContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + troopMembers: General[]; +} const ACTION_NAME = '하야'; const ACTION_KEY = 'che_하야'; @@ -22,8 +28,9 @@ export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionResolver { readonly key = ACTION_KEY; + constructor(private readonly env: TurnCommandEnv) {} - resolve(context: GeneralActionResolveContext, _args: ResignArgs): GeneralActionOutcome { + resolve(context: ResignContext, _args: ResignArgs): GeneralActionOutcome { const general = context.general; const nation = context.nation; @@ -31,11 +38,11 @@ export class ActionResolver< throw new Error('Resign requires a nation context.'); } - const effects = []; + const effects: GeneralActionOutcome['effects'] = []; // Return resources - const maxKeepGold = 1000; - const maxKeepRice = 1000; + const maxKeepGold = this.env.defaultNpcGold; + const maxKeepRice = this.env.defaultNpcRice; const newGold = Math.min(general.gold, maxKeepGold); const newRice = Math.min(general.rice, maxKeepRice); @@ -57,8 +64,8 @@ export class ActionResolver< } // Penalty - const betrayal = (general.meta.betrayal as number) ?? 0; - const penaltyRatio = 0.1 + betrayal * 0.05; + const betrayal = typeof general.meta.betray === 'number' ? general.meta.betray : 0; + const penaltyRatio = betrayal * 0.1; const nextExp = Math.floor(general.experience * (1 - penaltyRatio)); const nextDed = Math.floor(general.dedication * (1 - penaltyRatio)); @@ -66,6 +73,16 @@ export class ActionResolver< category: LogCategory.ACTION, format: LogFormat.MONTH, }); + context.addLog(`${nation.name}에서 하야`, { + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }); + const josaYi = JosaUtil.pick(general.name, '이'); + context.addLog(`${general.name}${josaYi} ${nation.name}에서 하야했습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + format: LogFormat.RAWTEXT, + }); effects.push( createGeneralPatchEffect( @@ -73,18 +90,40 @@ export class ActionResolver< ...general, nationId: 0, officerLevel: 0, + troopId: 0, gold: newGold, rice: newRice, experience: nextExp, dedication: nextDed, meta: { ...general.meta, - betrayal: betrayal + 1, + betray: Math.min(9, betrayal + 1), + belong: 0, + makelimit: 12, + officer_city: 0, + permission: 'normal', }, }, general.id ) ); + if (general.troopId === general.id) { + for (const member of context.troopMembers) { + effects.push(createGeneralPatchEffect({ troopId: 0 }, member.id)); + } + } + const gennum = typeof nation.meta.gennum === 'number' ? nation.meta.gennum : 0; + effects.push( + createNationPatchEffect( + { + meta: { + ...nation.meta, + gennum: Math.max(0, gennum - (general.npcState === 5 ? 0 : 1)), + }, + }, + nation.id + ) + ); return { effects }; } @@ -92,13 +131,16 @@ export class ActionResolver< export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition> { +> implements GeneralActionDefinition> { public readonly key = ACTION_KEY; public readonly name = ACTION_NAME; + getInheritanceActiveActionAmount(): number { + return 1; + } private readonly resolver: ActionResolver; - constructor() { - this.resolver = new ActionResolver(); + constructor(env: TurnCommandEnv) { + this.resolver = new ActionResolver(env); } parseArgs(_raw: unknown): ResignArgs | null { @@ -109,17 +151,20 @@ export class ActionDefinition< return [notBeNeutral(), notLord()]; } - resolve(context: GeneralActionResolveContext, args: ResignArgs): GeneralActionOutcome { + resolve(context: ResignContext, args: ResignArgs): GeneralActionOutcome { return this.resolver.resolve(context, args); } } -export const actionContextBuilder = defaultActionContextBuilder; +export const actionContextBuilder: ActionContextBuilder = (base, options) => ({ + ...base, + troopMembers: options.worldRef?.listGenerals().filter((general) => general.troopId === base.general.id) ?? [], +}); export const commandSpec: GeneralTurnCommandSpec = { key: ACTION_KEY, category: '인사', reqArg: false, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_헌납.ts b/packages/logic/src/actions/turn/general/che_헌납.ts index 5b872f3..1ae132d 100644 --- a/packages/logic/src/actions/turn/general/che_헌납.ts +++ b/packages/logic/src/actions/turn/general/che_헌납.ts @@ -50,9 +50,8 @@ export class ActionResolver< const realAmount = Math.max(0, Math.min(amount, currentRes)); - // Exp/Ded calculation - const exp = Math.floor(realAmount / 100); - const ded = Math.floor(realAmount / 50); + const exp = 70; + const ded = 100; const amountText = realAmount.toLocaleString(); context.addLog(`${resName} ${amountText}을 헌납했습니다.`, { @@ -70,6 +69,11 @@ export class ActionResolver< [resKey]: currentRes - realAmount, experience: general.experience + exp, dedication: general.dedication + ded, + meta: { + ...general.meta, + leadership_exp: + (typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0) + 1, + }, }, general.id ), diff --git a/packages/logic/src/actions/turn/general/che_화계.ts b/packages/logic/src/actions/turn/general/che_화계.ts index 5f6a096..78450a5 100644 --- a/packages/logic/src/actions/turn/general/che_화계.ts +++ b/packages/logic/src/actions/turn/general/che_화계.ts @@ -32,11 +32,16 @@ import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types. import { JosaUtil } from '@sammo-ts/common'; 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 { + ActionContextBase, + ActionContextBuilder, + ActionContextOptions, +} from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { clamp } from 'es-toolkit'; import { parseArgsWithSchema } from '../parseArgs.js'; import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js'; +import { searchDistance } from '@sammo-ts/logic/world/distance.js'; export interface FireAttackEnvironment { develCost: number; @@ -50,6 +55,8 @@ export interface FireAttackEnvironment { getDistance?: (sourceCityId: number, destCityId: number) => number | null; getDefenceCorrection?: (context: FireAttackContext, defender: General) => number; getInjuryProbability?: (context: FireAttackContext, defender: General) => number; + damageMode?: 'fire' | 'agitate' | 'destroy' | 'seize'; + injuryGeneral?: boolean; } export interface FireAttackContext< @@ -61,6 +68,7 @@ export interface FireAttackContext< destCity: City; destNation?: Nation | null; destGenerals: General[]; + distance?: number; } export interface FireAttackResolveContext< @@ -69,6 +77,7 @@ export interface FireAttackResolveContext< destCity: City; destNation?: Nation | null; destGenerals: General[]; + distance?: number; } export interface FireAttackResult { @@ -145,7 +154,7 @@ export class CommandResolver, rng: RandomGenerator): FireAttackResult { const { gold: costGold, rice: costRice } = this.getCost(); - const distance = this.env.getDistance?.(context.general.cityId, context.destCity.id) ?? 99; + const distance = context.distance ?? this.env.getDistance?.(context.general.cityId, context.destCity.id) ?? 99; const attackProb = this.calcAttackProb(context); const defenceProb = this.calcDefenceProb(context); @@ -175,10 +184,6 @@ export class CommandResolver>; }> = []; - for (const defender of context.destGenerals) { + for (const defender of this.env.injuryGeneral === false ? [] : (context.destGenerals ?? [])) { if (defender.nationId !== context.destCity.nationId) { continue; } @@ -226,19 +220,64 @@ export class CommandResolver { + const destCityId = options.actionArgs.destCityId; + if (typeof destCityId !== 'number' || !options.worldRef) { + return null; + } + const destCity = options.worldRef.getCityById(destCityId); + if (!destCity) { + return null; + } + const destNation = destCity.nationId > 0 ? options.worldRef.getNationById(destCity.nationId) : null; + const destGenerals = options.worldRef + .listGenerals() + .filter((general) => general.cityId === destCity.id && general.nationId === destCity.nationId); + const distance = options.map ? (searchDistance(options.map, base.general.cityId, 5)[destCity.id] ?? 99) : 99; + return { + ...base, + destCity, + destNation, + destGenerals, + distance, + }; +}; + +export const actionContextBuilder: ActionContextBuilder = buildStrategyActionContext; export const commandSpec: GeneralTurnCommandSpec = { key: 'che_화계', diff --git a/packages/logic/src/actions/turn/general/che_훈련.ts b/packages/logic/src/actions/turn/general/che_훈련.ts index 18eeca5..3fdabb6 100644 --- a/packages/logic/src/actions/turn/general/che_훈련.ts +++ b/packages/logic/src/actions/turn/general/che_훈련.ts @@ -13,7 +13,6 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; -import { clamp } from 'es-toolkit'; export interface TrainingArgs {} @@ -21,10 +20,10 @@ export interface TrainingEnvironment { trainDelta?: number; maxTrainByCommand?: number; costGold?: number; + unitSet?: TurnCommandEnv['unitSet']; } const ACTION_NAME = '훈련'; -const DEFAULT_TRAIN_DELTA = 5; const DEFAULT_MAX_TRAIN = 100; export class ActionDefinition< @@ -52,7 +51,13 @@ export class ActionDefinition< this.env.maxTrainByCommand && this.env.maxTrainByCommand > 0 ? this.env.maxTrainByCommand : DEFAULT_MAX_TRAIN; - return [notBeNeutral(), notWanderingNation(), occupiedCity(), reqGeneralCrew(), reqGeneralTrainMargin(maxTrain)]; + return [ + notBeNeutral(), + notWanderingNation(), + occupiedCity(), + reqGeneralCrew(), + reqGeneralTrainMargin(maxTrain), + ]; } resolve( @@ -64,16 +69,30 @@ export class ActionDefinition< this.env.maxTrainByCommand && this.env.maxTrainByCommand > 0 ? this.env.maxTrainByCommand : DEFAULT_MAX_TRAIN; - const delta = this.env.trainDelta && this.env.trainDelta > 0 ? this.env.trainDelta : DEFAULT_TRAIN_DELTA; - const nextTrain = clamp(general.train + delta, 0, maxTrain); - const applied = nextTrain - general.train; + const trainDelta = this.env.trainDelta && this.env.trainDelta > 0 ? this.env.trainDelta : 0; + const score = Math.max( + 0, + Math.min( + Math.round((general.stats.leadership * 100 * trainDelta) / Math.max(general.crew, 1)), + maxTrain - general.train + ) + ); const costGold = this.env.costGold ?? 0; - // 직접 수정 (Immer Draft) - general.train = nextTrain; + general.train += score; general.gold = Math.max(0, general.gold - costGold); + general.experience += 100; + general.dedication += 70; + const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0; + general.meta.leadership_exp = leadershipExp + 1; + const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId); + if (crewType) { + const dexKey = `dex${crewType.armType}`; + const dex = typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0; + general.meta[dexKey] = dex + score; + } - context.addLog(`훈련치가 ${applied} 상승했습니다.`); + context.addLog(`훈련치가 ${score.toLocaleString()} 상승했습니다.`); tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); return { effects: [] }; @@ -92,5 +111,6 @@ export const commandSpec: GeneralTurnCommandSpec = { new ActionDefinition({ trainDelta: env.trainDelta, maxTrainByCommand: env.maxTrainByCommand, + unitSet: env.unitSet, }), }; diff --git a/packages/logic/src/actions/turn/general/cityDevelopment.ts b/packages/logic/src/actions/turn/general/cityDevelopment.ts index 344a3ae..3c8245e 100644 --- a/packages/logic/src/actions/turn/general/cityDevelopment.ts +++ b/packages/logic/src/actions/turn/general/cityDevelopment.ts @@ -20,7 +20,7 @@ export interface CityDevelopmentEnvironment { amount?: number; } -type NumberKeys = { [K in keyof T]: T[K] extends number ? K : never }[keyof T]; +type NumberKeys = { [K in keyof T]-?: T[K] extends number ? K : never }[keyof T]; export interface CityDevelopmentConfig { key: string; diff --git a/packages/logic/src/actions/turn/general/cr_건국.ts b/packages/logic/src/actions/turn/general/cr_건국.ts index e542b64..20b60d5 100644 --- a/packages/logic/src/actions/turn/general/cr_건국.ts +++ b/packages/logic/src/actions/turn/general/cr_건국.ts @@ -76,6 +76,9 @@ export class ActionDefinition< > implements GeneralActionDefinition { public readonly key = ACTION_KEY; public readonly name = ACTION_NAME; + getInheritanceActiveActionAmount(): number { + return 1; + } parseArgs(raw: unknown): FoundingArgs | null { return parseArgsWithSchema(ARGS_SCHEMA, raw); @@ -126,10 +129,13 @@ export class ActionDefinition< category: LogCategory.ACTION, scope: LogScope.SYSTEM, }); - context.addLog(`【건국】${args.nationType} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, { - category: LogCategory.HISTORY, - scope: LogScope.SYSTEM, - }); + context.addLog( + `【건국】${args.nationType} ${args.nationName}${josaNationYi} 새로이 등장하였습니다.`, + { + category: LogCategory.HISTORY, + scope: LogScope.SYSTEM, + } + ); context.addLog(`${args.nationName}${josaNationUl} 건국`, { category: LogCategory.HISTORY, scope: LogScope.GENERAL, diff --git a/packages/logic/src/actions/turn/general/index.ts b/packages/logic/src/actions/turn/general/index.ts index 7221ca2..5a158af 100644 --- a/packages/logic/src/actions/turn/general/index.ts +++ b/packages/logic/src/actions/turn/general/index.ts @@ -55,7 +55,6 @@ export const GENERAL_TURN_COMMAND_KEYS = [ 'che_탈취', 'che_NPC능동', 'che_강행', - 'che_귀환', '휴식', ] as const; diff --git a/packages/logic/src/actions/turn/nation/che_물자원조.ts b/packages/logic/src/actions/turn/nation/che_물자원조.ts index ab003ae..3c72444 100644 --- a/packages/logic/src/actions/turn/nation/che_물자원조.ts +++ b/packages/logic/src/actions/turn/nation/che_물자원조.ts @@ -130,12 +130,18 @@ export class ActionDefinition< const broadcastMessage = `${destNation.name}${josaRo} 금${goldText} 쌀${riceText}을 지원했습니다.`; const recvAssist = - typeof destNation.meta.recv_assist === 'object' && destNation.meta.recv_assist !== null + typeof destNation.meta.recv_assist === 'object' && + destNation.meta.recv_assist !== null && + !Array.isArray(destNation.meta.recv_assist) ? { ...destNation.meta.recv_assist } : {}; const recvKey = `n${nation.id}`; const priorEntry = - typeof recvAssist[recvKey] === 'object' && recvAssist[recvKey] !== null ? recvAssist[recvKey] : {}; + typeof recvAssist[recvKey] === 'object' && + recvAssist[recvKey] !== null && + !Array.isArray(recvAssist[recvKey]) + ? recvAssist[recvKey] + : {}; const priorAmount = Number(priorEntry['1'] ?? 0); recvAssist[recvKey] = { 0: nation.id, diff --git a/packages/logic/src/domain/entities.ts b/packages/logic/src/domain/entities.ts index a147d2f..3ca360a 100644 --- a/packages/logic/src/domain/entities.ts +++ b/packages/logic/src/domain/entities.ts @@ -16,10 +16,10 @@ export interface StatBlock { export type TriggerValuePrimitive = boolean | number | string; export interface TriggerValueObject { - [key: string]: TriggerValuePrimitive | TriggerValueObject; + [key: string]: TriggerValue; } -export type TriggerValue = TriggerValuePrimitive | TriggerValueObject; +export type TriggerValue = TriggerValuePrimitive | TriggerValueObject | TriggerValue[]; export interface GeneralTriggerState { // Trigger 시스템에서 사용하는 확장 슬롯. @@ -67,6 +67,14 @@ export type GeneralMeta = Record & { killturn: number; }; +// 레거시 general.last_turn JSON. 연속 실행 턴의 선행 턴 누적 상태를 보존한다. +export interface GeneralLastTurn { + command: string; + arg?: Record; + term?: number; + seq?: number; +} + export interface General { id: GeneralId; name: string; @@ -91,6 +99,7 @@ export interface General; meta: Record; } diff --git a/packages/logic/src/triggers/types.ts b/packages/logic/src/triggers/types.ts index 6f322d1..2ba5f10 100644 --- a/packages/logic/src/triggers/types.ts +++ b/packages/logic/src/triggers/types.ts @@ -29,7 +29,14 @@ export type TriggerStrategicVarType = 'delay' | 'globalDelay'; export type TriggerNationalIncomeType = 'gold' | 'rice' | 'pop'; export type GeneralStatName = - 'leadership' | 'strength' | 'intelligence' | 'experience' | 'dedication' | 'sabotageDefence' | 'sabotageAttack'; + | 'leadership' + | 'strength' + | 'intelligence' + | 'experience' + | 'dedication' + | 'sabotageDefence' + | 'sabotageAttack' + | 'addDex'; export type WarStatName = | GeneralStatName diff --git a/packages/logic/src/war/units/base.ts b/packages/logic/src/war/units/base.ts index d104b5b..870dce4 100644 --- a/packages/logic/src/war/units/base.ts +++ b/packages/logic/src/war/units/base.ts @@ -1,10 +1,6 @@ import type { RandUtil } from '@sammo-ts/common'; -import type { - GeneralTriggerState, - Nation, - TriggerValue, -} from '@sammo-ts/logic/domain/entities.js'; +import type { GeneralTriggerState, Nation, TriggerValue } from '@sammo-ts/logic/domain/entities.js'; import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; import { LogFormat } from '@sammo-ts/logic/logging/types.js'; import type { WarEngineConfig } from '../types.js'; @@ -14,7 +10,7 @@ import { clampMin, getDexLog, getMetaNumber, round } from '../utils.js'; export const WAR_CRITICAL_RANGE: [number, number] = [1.3, 2.0]; export const resolveNationTech = (nation: Nation | null): number => - (nation ? getMetaNumber(nation.meta, 'tech', 0) : 0); + nation ? getMetaNumber(nation.meta, 'tech', 0) : 0; const resolveNationVar = (nation: Nation | null, key: string): TriggerValue | null => { if (!nation) { @@ -246,7 +242,8 @@ export abstract class WarUnit { // 4. Verify const updatedCity = world.getCity(1)!; - expect(updatedCity.agriculture).toBe(600); + // 레거시 che_농지개간: 능력치·경험등급·0.8~1.2 난수·성공 배율을 모두 반영한다. + expect(updatedCity.agriculture).toBe(664); }); it('should not increase agriculture when city is already maxed', async () => { diff --git a/packages/logic/test/scenarios/general_commands_new.test.ts b/packages/logic/test/scenarios/general_commands_new.test.ts index 60b88e2..a103fd7 100644 --- a/packages/logic/test/scenarios/general_commands_new.test.ts +++ b/packages/logic/test/scenarios/general_commands_new.test.ts @@ -195,6 +195,7 @@ describe('General Commands New Scenario', () => { // 2. Donate const donateDef = donateSpec.createDefinition(systemEnv); + const expBeforeDonate = world.getGeneral(1)!.experience; await runner.runTurn([ { generalId: 1, @@ -207,6 +208,8 @@ describe('General Commands New Scenario', () => { const g1_after_donate = world.getGeneral(1)!; const n1_after_donate = world.getNation(1)!; expect(g1_after_donate.gold).toBe(900); // 1000 - 100 (Procure cost 0) + expect(g1_after_donate.experience).toBe(expBeforeDonate + 70); + expect(g1_after_donate.meta.leadership_exp).toBe(1); expect(n1_after_donate.gold).toBeGreaterThan(10100); // 10000 + Procure + 100 // 3. Move @@ -270,7 +273,8 @@ describe('General Commands New Scenario', () => { const g1_after_retire = world.getGeneral(1)!; expect(g1_after_retire.age).toBe(20); - expect(g1_after_retire.experience).toBe(0); + // General::rebirth()는 앞선 명령으로 누적된 경험을 초기화하지 않고 절반으로 줄인다. + expect(g1_after_retire.experience).toBe(142); }); it('should execute employ and sabotage commands', async () => { @@ -414,6 +418,7 @@ describe('General Commands New Scenario', () => { // 1. Employ (G1 -> G2) const employDef = employSpec.createDefinition(systemEnv); + const goldBeforeEmploy = world.getGeneral(1)!.gold; await runner.runTurn([ { generalId: 1, @@ -423,6 +428,8 @@ describe('General Commands New Scenario', () => { context: { destGeneral: gen2, env: systemEnv }, // Manual inject for resolver context }, ]); + // 레거시 비용: round(develcost + (대상 경험 + 공헌) / 1000) * 10. + expect(world.getGeneral(1)!.gold).toBe(goldBeforeEmploy - 1_000); // Verify Logs? (Runner doesn't expose logs easily, but we checks no throw) @@ -500,7 +507,22 @@ describe('General Commands New Scenario', () => { commandKey: 'che_선동', resolver: agitateDef, args: { destCityId: 2 }, - context: { destCity: city2, env: systemEnv }, + context: { + destCity: city2, + destNation: nation2, + destGenerals: [gen2], + env: systemEnv, + rng: { + real: () => 0, + int: (min: number, _max: number) => min, + nextInt: (min: number, _max: number) => min, + next: () => 0, + nextBool: () => true, + nextRange: (min: number, _max: number) => min, + nextRangeInt: (min: number, _max: number) => min, + nextFloat1: () => 0, + }, + }, }, ]); @@ -521,7 +543,24 @@ describe('General Commands New Scenario', () => { commandKey: 'che_탈취', resolver: seizeDef, args: { destCityId: 2 }, - context: { destCity: city2, destNation: nation2, env: systemEnv }, + context: { + destCity: city2, + destNation: nation2, + destGenerals: [gen2], + env: systemEnv, + year: 200, + startYear: 200, + rng: { + real: () => 0, + int: (min: number, _max: number) => min, + nextInt: (min: number, _max: number) => min, + next: () => 0, + nextBool: () => true, + nextRange: (min: number, _max: number) => min, + nextRangeInt: (min: number, _max: number) => min, + nextFloat1: () => 0, + }, + }, }, ]); @@ -530,7 +569,8 @@ describe('General Commands New Scenario', () => { const g1_after_seize = world.getGeneral(1)!; - expect(g1_after_seize.gold).toBeGreaterThan(goldBeforeSeize - systemEnv.develCost); + // 레거시는 개발비의 5배를 먼저 소모한 뒤 탈취량의 30%를 개인 몫으로 지급한다. + expect(g1_after_seize.gold).toBe(goldBeforeSeize - systemEnv.develCost * 5 + 1); expect(getEquippedItemInstance(g1_after_seize, 'item')).toBeNull(); }); }); diff --git a/packages/logic/test/scenarios/troops.test.ts b/packages/logic/test/scenarios/troops.test.ts index 2a9ddcb..970e956 100644 --- a/packages/logic/test/scenarios/troops.test.ts +++ b/packages/logic/test/scenarios/troops.test.ts @@ -145,6 +145,8 @@ describe('Troop Management Scenario', () => { const generalAfterDraft = world.getGeneral(1)!; expect(generalAfterDraft.crew).toBe(1000); + // General::addDex(): 보병(armType 1)은 징병 인원 / 100만큼 숙련도가 오른다. + expect(generalAfterDraft.meta.dex1).toBe(10); // 3. Train const trainDef = trainSpec.createDefinition(systemEnv); @@ -158,7 +160,8 @@ describe('Troop Management Scenario', () => { ]); const generalAfterTrain = world.getGeneral(1)!; - expect(generalAfterTrain.train).toBe(75); + // 레거시 훈련식: round(통솔 * 100 * trainDelta / 병력), 상한까지 적용. + expect(generalAfterTrain.train).toBe(100); // 4. Boost Morale const atmosDef = atmosSpec.createDefinition(systemEnv); @@ -172,6 +175,7 @@ describe('Troop Management Scenario', () => { ]); const generalAfterAtmos = world.getGeneral(1)!; - expect(generalAfterAtmos.atmos).toBe(75); + // 레거시 사기진작식: round(통솔 * 100 / 병력 * atmosDelta), 명령 상한까지 적용. + expect(generalAfterAtmos.atmos).toBe(100); }); }); diff --git a/tools/compare-command-logs.ignore.json b/tools/compare-command-logs.ignore.json index 7c58be9..b6807d7 100644 --- a/tools/compare-command-logs.ignore.json +++ b/tools/compare-command-logs.ignore.json @@ -15,21 +15,64 @@ "regex": [] }, "General/che_모반시도": { - "templates": [ - "${}에게 군주의 자리를 뺏겼습니다." - ], + "templates": ["${}에게 군주의 자리를 뺏겼습니다."], "regex": [] }, "General/che_선양": { - "templates": [ - "${}에게서 군주의 자리를 물려받습니다." - ], + "templates": ["${}에게서 군주의 자리를 물려받습니다."], "regex": [] }, "General/che_장비매매": { + "templates": ["${}"], + "regex": [] + }, + "General/che_기술연구": { "templates": [ - "${}" + "${}${} 하여 ${} 상승했습니다.", + "기술 연구${} 실패하여 ${} 상승했습니다.", + "기술 연구${} 성공하여 ${} 상승했습니다.", + "기술 연구${} 하여 ${} 상승했습니다." ], "regex": [] + }, + "General/che_내정특기초기화": { + "templates": ["새로운 ${}를 가질 준비가 되었습니다.", "새로운 내정 특기를 가질 준비가 되었습니다."], + "regex": [] + }, + "General/che_선동": { + "templates": ["${}에 선동${} 실패했습니다."], + "regex": [] + }, + "General/che_은퇴": { + "templates": ["나이가 들어 은퇴하고 자손에게 자리를 물려줍니다."], + "regex": [] + }, + "General/che_정착장려": { + "templates": [ + "${}${} 실패하여 주민이 ${}명 증가했습니다.", + "${}${} 성공하여 주민이 ${}명 증가했습니다.", + "${}${} 하여 주민이 ${}명 증가했습니다.", + "정착 장려${} 실패하여 주민이 ${}명 증가했습니다.", + "정착 장려${} 성공하여 주민이 ${}명 증가했습니다.", + "정착 장려${} 하여 주민이 ${}명 증가했습니다." + ], + "regex": [] + }, + "General/che_주민선정": { + "templates": [ + "${}${} 하여 ${} 상승했습니다.", + "주민 선정${} 실패하여 ${} 상승했습니다.", + "주민 선정${} 성공하여 ${} 상승했습니다.", + "주민 선정${} 하여 ${} 상승했습니다." + ], + "regex": [] + }, + "General/che_탈취": { + "templates": ["${}에 탈취${} 실패했습니다."], + "regex": [] + }, + "General/che_파괴": { + "templates": ["${}에 파괴${} 실패했습니다."], + "regex": [] } } diff --git a/tools/compare-command-logs.mjs b/tools/compare-command-logs.mjs index 28b53c4..4d77387 100644 --- a/tools/compare-command-logs.mjs +++ b/tools/compare-command-logs.mjs @@ -10,6 +10,7 @@ const DEFAULT_MODE = 'action'; const DEFAULT_EXCLUDE_GUARDS = true; const DEFAULT_EXCLUDE_TARGET = true; const DEFAULT_IGNORE_FILE = 'tools/compare-command-logs.ignore.json'; +const PHP_INHERITED_LOG_SOURCE_WHEN_EMPTY = new Map([['General/che_내정특기초기화', 'General/che_전투특기초기화']]); const ARG_HELP = ` Usage: node tools/compare-command-logs.mjs [options] @@ -894,6 +895,7 @@ const diffTemplateCounts = (lhsCounts, rhsCounts) => { const loadPhpLogs = async () => { const files = (await collectFiles(PHP_ROOT)).filter((file) => file.endsWith('.php')); const logsByKey = new Map(); + const sourceByKey = new Map(); for (const file of files) { const baseName = path.basename(file, '.php'); @@ -909,6 +911,7 @@ const loadPhpLogs = async () => { const text = await fs.readFile(file, 'utf-8'); const assignments = findPhpAssignments(text); const logs = extractPhpLogCalls(text, assignments); + sourceByKey.set(key, { file, logs }); if (logs.length === 0) { continue; } @@ -940,6 +943,42 @@ const loadPhpLogs = async () => { logsByKey.set(key, filtered); } + for (const [key, parentKey] of PHP_INHERITED_LOG_SOURCE_WHEN_EMPTY) { + if (!filterCommandKey(key) || logsByKey.has(key)) { + continue; + } + const parent = sourceByKey.get(parentKey); + if (!parent) { + continue; + } + const inherited = parent.logs + .map((log) => ({ + file: path.relative(ROOT_DIR, parent.file), + line: log.line, + template: log.template, + raw: log.raw, + category: log.category, + scope: log.scope, + format: log.format, + hasGeneralId: log.hasGeneralId, + })) + .filter((entry) => { + if (!shouldIncludeEntryByMode(entry)) { + return false; + } + if (excludeGuards && isGuardLog(entry.template)) { + return false; + } + if (excludeTarget && isTargetLog(entry)) { + return false; + } + return true; + }); + if (inherited.length > 0) { + logsByKey.set(key, inherited); + } + } + return logsByKey; }; diff --git a/tools/compare-general-turn-contracts.mjs b/tools/compare-general-turn-contracts.mjs new file mode 100644 index 0000000..8027698 --- /dev/null +++ b/tools/compare-general-turn-contracts.mjs @@ -0,0 +1,141 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const root = process.cwd(); +const phpDir = path.join(root, 'legacy/hwe/sammo/Command/General'); +const tsDir = path.join(root, 'packages/logic/src/actions/turn/general'); +const check = process.argv.includes('--check'); + +const activeAction = new Map([ + ['che_거병', 1], + ['che_건국', 1], + ['che_등용수락', 1], + ['che_랜덤임관', 1], + ['che_모반시도', 1], + ['che_무작위건국', 1], + ['che_방랑', 1], + ['che_임관', 1], + ['che_장수대상임관', 1], + ['che_선양', 1], + ['che_출병', 1], + ['che_첩보', 0.5], + ['che_하야', 1], + ['cr_건국', 1], +]); + +const readSources = async (dir, extension) => { + const result = new Map(); + for (const name of await fs.readdir(dir)) { + if (!name.endsWith(extension)) continue; + const key = name.slice(0, -extension.length); + if (!(key.startsWith('che_') || key.startsWith('cr_') || key === '휴식')) continue; + result.set(key, await fs.readFile(path.join(dir, name), 'utf8')); + } + return result; +}; + +const php = await readSources(phpDir, '.php'); +const ts = await readSources(tsDir, '.ts'); +const handlerSource = await fs.readFile(path.join(root, 'app/game-engine/src/turn/reservedTurnHandler.ts'), 'utf8'); + +const phpParent = new Map(); +for (const [key, source] of php) { + const classMatch = source.match(/\bclass\s+\S+\s+extends\s+(?:Command\\GeneralCommand|([^\s{]+))/); + const rawParent = classMatch?.[1]; + if (rawParent) phpParent.set(key, rawParent.split('\\').at(-1)); +} + +const literalMethod = (source, method) => { + const match = source.match( + new RegExp(`function\\s+${method}\\s*\\([^)]*\\)\\s*:[^{]+\\{[\\s\\S]*?return\\s+(-?\\d+(?:\\.\\d+)?)\\s*;`) + ); + return match ? Number(match[1]) : null; +}; + +const resolvePhpMethod = (key, method, seen = new Set()) => { + if (seen.has(key)) return null; + seen.add(key); + const source = php.get(key); + if (!source) return null; + const own = literalMethod(source, method); + if (own !== null) return own; + const parent = phpParent.get(key); + return parent ? resolvePhpMethod(parent, method, seen) : 0; +}; + +const tsMethod = (source, method) => { + const match = source.match( + new RegExp(`${method}\\s*\\([^)]*\\)\\s*:\\s*number\\s*\\{[\\s\\S]*?return\\s+(-?\\d+(?:\\.\\d+)?)\\s*;`) + ); + return match ? Number(match[1]) : 0; +}; + +const missingTs = [...php.keys()].filter((key) => !ts.has(key)).sort(); +const extraTs = [...ts.keys()].filter((key) => !php.has(key)).sort(); +const timingMismatches = []; +const activeMismatches = []; + +for (const key of [...php.keys()].filter((value) => ts.has(value)).sort()) { + const source = ts.get(key); + const phpPre = resolvePhpMethod(key, 'getPreReqTurn'); + const phpPost = resolvePhpMethod(key, 'getPostReqTurn'); + const tsPre = tsMethod(source, 'getPreReqTurn'); + const tsPost = tsMethod(source, 'getPostReqTurn'); + if (phpPre !== tsPre || phpPost !== tsPost) { + timingMismatches.push({ key, php: [phpPre, phpPost], ts: [tsPre, tsPost] }); + } + + const expectedActive = activeAction.get(key) ?? 0; + const actualActive = tsMethod(source, 'getInheritanceActiveActionAmount'); + if (expectedActive !== actualActive) { + activeMismatches.push({ key, php: expectedActive, ts: actualActive }); + } +} + +const dynamicChecks = [ + { + key: 'che_인재탐색', + ok: /inherit_active_action[\s\S]*Math\.max\(Math\.sqrt\(1\s*\/\s*prop\),\s*1\)/.test( + ts.get('che_인재탐색') ?? '' + ), + contract: '성공 시 max(sqrt(1 / 발견확률), 1)', + }, + { + key: 'generalCommand RNG seed', + ok: /kind === 'general' \? 'generalCommand' : 'nationCommand'[\s\S]*currentYear[\s\S]*currentMonth[\s\S]*currentGeneral\.id,[\s\S]*key/.test( + handlerSource + ), + contract: 'hiddenSeed, generalCommand, year, month, generalId, raw command key', + }, + { + key: 'preprocess RNG seed', + ok: /buildSeedBase\(context\.world\),[\s\S]*'preprocess',[\s\S]*currentYear,[\s\S]*currentMonth,[\s\S]*currentGeneral\.id/.test( + handlerSource + ), + contract: 'hiddenSeed, preprocess, year, month, generalId', + }, + { + key: 'post-turn myset', + ok: /myset:\s*Math\.min\([\s\S]*9,[\s\S]*currentGeneral\.meta\.myset[\s\S]*\+\s*3/.test(handlerSource), + contract: '매 턴 +3, 상한 9', + }, +]; + +console.log(`General command inventory: PHP ${php.size}, TS ${ts.size}`); +console.log(`Missing TS: ${missingTs.length}; Extra TS: ${extraTs.length}`); +console.log(`Timing mismatches: ${timingMismatches.length}`); +console.log(`Active-action mismatches: ${activeMismatches.length}`); +for (const mismatch of timingMismatches) console.log('timing', JSON.stringify(mismatch)); +for (const mismatch of activeMismatches) console.log('active', JSON.stringify(mismatch)); +for (const item of dynamicChecks) console.log(`dynamic ${item.key}: ${item.ok ? 'ok' : 'missing'} (${item.contract})`); + +if ( + check && + (missingTs.length > 0 || + extraTs.length > 0 || + timingMismatches.length > 0 || + activeMismatches.length > 0 || + dynamicChecks.some((item) => !item.ok)) +) { + process.exitCode = 1; +}