feat: 엔진 및 턴 스케줄 관련 인터페이스 및 로직 추가

This commit is contained in:
2025-12-28 17:21:58 +00:00
parent 8a2fc4fec5
commit bfe28773e9
5 changed files with 272 additions and 0 deletions
+184
View File
@@ -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<TriggerState> {
rng: RandomGenerator;
city?: City;
nation?: Nation | null;
}
export interface TurnScheduleContext {
now: Date;
schedule: TurnSchedule;
}
export interface GeneralPatchEffect {
type: 'general:patch';
patch: Partial<General>;
}
export interface CityPatchEffect {
type: 'city:patch';
patch: Partial<City>;
}
export interface NationPatchEffect {
type: 'nation:patch';
patch: Partial<Nation>;
}
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<TriggerState>): GeneralActionOutcome;
}
export interface GeneralActionResolution {
general: General;
city?: City;
nation?: Nation | null;
nextTurnAt: Date;
logs: string[];
effects: GeneralActionEffect[];
}
const mergeStats = (base: StatBlock, patch: Partial<StatBlock>): StatBlock => ({
leadership: patch.leadership ?? base.leadership,
strength: patch.strength ?? base.strength,
intelligence: patch.intelligence ?? base.intelligence,
});
const mergeRole = (
base: GeneralRole,
patch: Partial<GeneralRole>
): GeneralRole => ({
...base,
...patch,
items: {
...base.items,
...(patch.items ?? {}),
},
});
const mergeTriggerState = (
base: GeneralTriggerState,
patch: Partial<GeneralTriggerState>
): 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>): 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>): City => ({
...base,
...patch,
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
});
const applyNationPatch = (base: Nation, patch: Partial<Nation>): Nation => ({
...base,
...patch,
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
});
// 행동 결과를 Effect로 모아 상태/턴 계산을 수행한다.
export const resolveGeneralAction = <
TriggerState extends GeneralTriggerState = GeneralTriggerState
>(
resolver: GeneralActionResolver<TriggerState>,
context: GeneralActionResolveContext<TriggerState>,
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,
};
};
+1
View File
@@ -1 +1,2 @@
export * from './domestic/index.js';
export * from './engine.js';
+1
View File
@@ -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';
+85
View File
@@ -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;
};
+1
View File
@@ -0,0 +1 @@
export * from './calendar.js';