diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts new file mode 100644 index 0000000..01b87b7 --- /dev/null +++ b/packages/logic/src/actions/engine.ts @@ -0,0 +1,184 @@ +import type { RandomGenerator } from '@sammo-ts/common'; +import type { + City, + General, + GeneralRole, + GeneralTriggerState, + Nation, + StatBlock, +} from '../domain/entities.js'; +import type { GeneralActionContext } from '../triggers/general.js'; +import { getNextTurnAt, type TurnSchedule } from '../turn/calendar.js'; + +export interface GeneralActionResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> extends GeneralActionContext { + rng: RandomGenerator; + city?: City; + nation?: Nation | null; +} + +export interface TurnScheduleContext { + now: Date; + schedule: TurnSchedule; +} + +export interface GeneralPatchEffect { + type: 'general:patch'; + patch: Partial; +} + +export interface CityPatchEffect { + type: 'city:patch'; + patch: Partial; +} + +export interface NationPatchEffect { + type: 'nation:patch'; + patch: Partial; +} + +export interface LogEffect { + type: 'log'; + message: string; +} + +export interface NextTurnOverrideEffect { + type: 'schedule:override'; + nextTurnAt: Date; +} + +export type GeneralActionEffect = + | GeneralPatchEffect + | CityPatchEffect + | NationPatchEffect + | LogEffect + | NextTurnOverrideEffect; + +export interface GeneralActionOutcome { + effects: GeneralActionEffect[]; +} + +export interface GeneralActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + key: string; + resolve(context: GeneralActionResolveContext): GeneralActionOutcome; +} + +export interface GeneralActionResolution { + general: General; + city?: City; + nation?: Nation | null; + nextTurnAt: Date; + logs: string[]; + effects: GeneralActionEffect[]; +} + +const mergeStats = (base: StatBlock, patch: Partial): StatBlock => ({ + leadership: patch.leadership ?? base.leadership, + strength: patch.strength ?? base.strength, + intelligence: patch.intelligence ?? base.intelligence, +}); + +const mergeRole = ( + base: GeneralRole, + patch: Partial +): GeneralRole => ({ + ...base, + ...patch, + items: { + ...base.items, + ...(patch.items ?? {}), + }, +}); + +const mergeTriggerState = ( + base: GeneralTriggerState, + patch: Partial +): GeneralTriggerState => ({ + ...base, + ...patch, + flags: { ...base.flags, ...(patch.flags ?? {}) }, + counters: { ...base.counters, ...(patch.counters ?? {}) }, + modifiers: { ...base.modifiers, ...(patch.modifiers ?? {}) }, + meta: { ...base.meta, ...(patch.meta ?? {}) }, +}); + +const applyGeneralPatch = (base: General, patch: Partial): General => ({ + ...base, + ...patch, + stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats, + role: patch.role ? mergeRole(base.role, patch.role) : base.role, + triggerState: patch.triggerState + ? mergeTriggerState(base.triggerState, patch.triggerState) + : base.triggerState, + meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, +}); + +const applyCityPatch = (base: City, patch: Partial): City => ({ + ...base, + ...patch, + meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, +}); + +const applyNationPatch = (base: Nation, patch: Partial): Nation => ({ + ...base, + ...patch, + meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, +}); + +// 행동 결과를 Effect로 모아 상태/턴 계산을 수행한다. +export const resolveGeneralAction = < + TriggerState extends GeneralTriggerState = GeneralTriggerState +>( + resolver: GeneralActionResolver, + context: GeneralActionResolveContext, + scheduleContext: TurnScheduleContext +): GeneralActionResolution => { + const outcome = resolver.resolve(context); + const logs: string[] = []; + let nextGeneral = context.general; + let nextCity = context.city; + let nextNation = context.nation ?? null; + let nextTurnAtOverride: Date | null = null; + + for (const effect of outcome.effects) { + switch (effect.type) { + case 'general:patch': + nextGeneral = applyGeneralPatch(nextGeneral, effect.patch); + break; + case 'city:patch': + if (nextCity) { + nextCity = applyCityPatch(nextCity, effect.patch); + } + break; + case 'nation:patch': + if (nextNation) { + nextNation = applyNationPatch(nextNation, effect.patch); + } + break; + case 'log': + logs.push(effect.message); + break; + case 'schedule:override': + nextTurnAtOverride = effect.nextTurnAt; + break; + default: + break; + } + } + + const nextTurnAt = + nextTurnAtOverride ?? + getNextTurnAt(scheduleContext.now, scheduleContext.schedule); + + return { + general: nextGeneral, + city: nextCity, + nation: nextNation, + nextTurnAt, + logs, + effects: outcome.effects, + }; +}; diff --git a/packages/logic/src/actions/index.ts b/packages/logic/src/actions/index.ts index 13cea07..4ee3183 100644 --- a/packages/logic/src/actions/index.ts +++ b/packages/logic/src/actions/index.ts @@ -1 +1,2 @@ export * from './domestic/index.js'; +export * from './engine.js'; diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index 79b1b2f..70f9b62 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -5,4 +5,5 @@ export * from './ports/world.js'; export * from './ports/worldSnapshot.js'; export * from './scenario/index.js'; export * from './triggers/index.js'; +export * from './turn/index.js'; export * from './world/index.js'; diff --git a/packages/logic/src/turn/calendar.ts b/packages/logic/src/turn/calendar.ts new file mode 100644 index 0000000..c639e1c --- /dev/null +++ b/packages/logic/src/turn/calendar.ts @@ -0,0 +1,85 @@ +export interface TurnScheduleEntry { + startMinute: number; + tickMinutes: number; +} + +export interface TurnSchedule { + entries: TurnScheduleEntry[]; +} + +const MINUTES_PER_DAY = 24 * 60; + +const toMinuteOfDay = (date: Date): number => + date.getHours() * 60 + date.getMinutes(); + +const toLocalDateAtMinute = (date: Date, minuteOfDay: number, dayOffset = 0): Date => { + const hour = Math.floor(minuteOfDay / 60); + const minute = minuteOfDay % 60; + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + dayOffset, + hour, + minute, + 0, + 0 + ); +}; + +const normalizeEntries = (entries: TurnScheduleEntry[]): TurnScheduleEntry[] => { + const normalized = entries + .map((entry) => ({ + startMinute: Math.max(0, Math.min(MINUTES_PER_DAY - 1, entry.startMinute)), + tickMinutes: Math.max(1, entry.tickMinutes), + })) + .sort((a, b) => a.startMinute - b.startMinute); + + if (normalized.length === 0) { + throw new Error('Turn schedule needs at least one entry.'); + } + + return normalized; +}; + +const findCurrentEntryIndex = (minuteOfDay: number, entries: TurnScheduleEntry[]): number => { + for (let i = entries.length - 1; i >= 0; i -= 1) { + if (entries[i].startMinute <= minuteOfDay) { + return i; + } + } + return -1; +}; + +export const getTickMinutesAt = (date: Date, schedule: TurnSchedule): number => { + const entries = normalizeEntries(schedule.entries); + const minuteOfDay = toMinuteOfDay(date); + const index = findCurrentEntryIndex(minuteOfDay, entries); + const entry = index >= 0 ? entries[index] : entries[entries.length - 1]; + return entry.tickMinutes; +}; + +export const getNextTurnAt = (date: Date, schedule: TurnSchedule): Date => { + const entries = normalizeEntries(schedule.entries); + const minuteOfDay = toMinuteOfDay(date); + const index = findCurrentEntryIndex(minuteOfDay, entries); + const currentIndex = index >= 0 ? index : entries.length - 1; + const startDayOffset = index >= 0 ? 0 : -1; + const nextIndex = (currentIndex + 1) % entries.length; + const nextDayOffset = startDayOffset + (nextIndex > currentIndex ? 0 : 1); + + const currentEntry = entries[currentIndex]; + const segmentStart = toLocalDateAtMinute(date, currentEntry.startMinute, startDayOffset); + const segmentEnd = toLocalDateAtMinute(date, entries[nextIndex].startMinute, nextDayOffset); + + const elapsedMinutes = (date.getTime() - segmentStart.getTime()) / 60000; + const nextStep = Math.floor(elapsedMinutes / currentEntry.tickMinutes) + 1; + const nextCandidate = new Date( + segmentStart.getTime() + nextStep * currentEntry.tickMinutes * 60000 + ); + + if (nextCandidate.getTime() < segmentEnd.getTime()) { + return nextCandidate; + } + + return segmentEnd; +}; diff --git a/packages/logic/src/turn/index.ts b/packages/logic/src/turn/index.ts new file mode 100644 index 0000000..7a9b080 --- /dev/null +++ b/packages/logic/src/turn/index.ts @@ -0,0 +1 @@ +export * from './calendar.js';