Merge branch 'main' into compare/scenario2601-200-parity-20260815

This commit is contained in:
2026-08-15 09:42:02 +00:00
99 changed files with 1052 additions and 1897 deletions
@@ -1 +0,0 @@
export {};
+150 -141
View File
@@ -1,5 +1,5 @@
import type { RandomGenerator } from '@sammo-ts/common';
import { enablePatches, produceWithPatches, castDraft } from 'immer';
import { enablePatches, produceWithPatches, castDraft, type Draft, type Patch } from 'immer';
import type {
City,
General,
@@ -82,11 +82,6 @@ export interface LogEffect {
entry: LogEntryDraft;
}
export interface NextTurnOverrideEffect {
type: 'schedule:override';
nextTurnAt: Date;
}
export interface MessageAddEffect {
type: 'message:add';
draft: MessageDraft;
@@ -100,8 +95,7 @@ export type GeneralActionEffect<TriggerState extends GeneralTriggerState = Gener
| NationAddEffect
| DiplomacyPatchEffect
| LogEffect
| MessageAddEffect
| NextTurnOverrideEffect;
| MessageAddEffect;
export interface GeneralActionOutcome<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
effects: GeneralActionEffect<TriggerState>[];
@@ -229,10 +223,129 @@ export const createMessageEffect = (draft: MessageDraft): MessageAddEffect => ({
draft,
});
export const createNextTurnOverrideEffect = (nextTurnAt: Date): NextTurnOverrideEffect => ({
type: 'schedule:override',
nextTurnAt,
});
const createActionLogSink = <TriggerState extends GeneralTriggerState>(
context: GeneralActionResolveInputContext<TriggerState>,
logs: LogEntryDraft[]
): GeneralActionResolveContext<TriggerState>['addLog'] => {
return (message, options = {}) => {
const entry: LogEntryDraft = {
scope: options.scope ?? LogScope.GENERAL,
category: options.category ?? LogCategory.ACTION,
text: message,
format: options.format ?? LogFormat.MONTH,
...options,
};
switch (entry.scope) {
case LogScope.GENERAL:
logs.push({
...entry,
generalId: entry.generalId ?? context.general.id,
});
break;
case LogScope.NATION:
if (entry.nationId !== undefined) {
logs.push(entry);
break;
}
if (context.nation?.id !== undefined) {
logs.push({
...entry,
nationId: context.nation.id,
});
}
break;
case LogScope.USER:
if (entry.userId) {
logs.push(entry);
}
break;
case LogScope.SYSTEM:
default:
logs.push(entry);
break;
}
};
};
interface ActionResolutionAccumulator {
createdGenerals: General[];
createdNations: Nation[];
patches: NonNullable<GeneralActionResolution['patches']>;
pendingEffects: GeneralActionEffect[];
}
const applyGeneralActionEffects = <TriggerState extends GeneralTriggerState>(options: {
effects: GeneralActionEffect<TriggerState>[];
draft: Draft<WorldState<TriggerState>>;
context: GeneralActionResolveInputContext<TriggerState>;
addLog: GeneralActionResolveContext<TriggerState>['addLog'];
accumulator: ActionResolutionAccumulator;
}): void => {
const { effects, draft, context, addLog, accumulator } = options;
for (const effect of effects) {
switch (effect.type) {
case 'log':
addLog(effect.entry.text, effect.entry);
break;
case 'general:add':
accumulator.createdGenerals.push(effect.general as General);
break;
case 'nation:add':
accumulator.createdNations.push(effect.nation as Nation);
break;
case 'diplomacy:patch':
case 'message:add':
accumulator.pendingEffects.push(effect);
break;
case 'general:patch':
if (effect.targetId !== undefined && effect.targetId !== context.general.id) {
accumulator.patches.generals.push({
id: effect.targetId,
patch: effect.patch as Partial<General>,
});
} else {
Object.assign(draft.general, effect.patch);
}
break;
case 'city:patch':
if (effect.targetId !== undefined && effect.targetId !== context.city?.id) {
accumulator.patches.cities.push({ id: effect.targetId, patch: effect.patch });
} else if (draft.city) {
Object.assign(draft.city, effect.patch);
}
break;
case 'nation:patch':
if (effect.targetId !== undefined && effect.targetId !== context.nation?.id) {
accumulator.patches.nations.push({ id: effect.targetId, patch: effect.patch });
} else if (draft.nation) {
Object.assign(draft.nation, effect.patch);
}
break;
}
}
};
const resolveDirtyState = <TriggerState extends GeneralTriggerState>(
context: GeneralActionResolveInputContext<TriggerState>,
worldPatches: readonly Patch[]
): NonNullable<GeneralActionResolution['dirty']> => {
const dirty: NonNullable<GeneralActionResolution['dirty']> = {
general: false,
city: false,
nation: false,
generalId: context.general.id,
};
if (context.city) dirty.cityId = context.city.id;
if (context.nation) dirty.nationId = context.nation.id;
for (const patch of worldPatches) {
if (patch.path[0] === 'general') dirty.general = true;
if (patch.path[0] === 'city') dirty.city = true;
if (patch.path[0] === 'nation') dirty.nation = true;
}
return dirty;
};
// 행동 결과를 Effect로 모아 상태/턴 계산을 수행한다.
export const resolveGeneralAction = <TriggerState extends GeneralTriggerState = GeneralTriggerState, Args = unknown>(
@@ -242,16 +355,12 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
args: Args
): GeneralActionResolution => {
const logs: LogEntryDraft[] = [];
let nextTurnAtOverride: Date | null = null;
const createdGenerals: General[] = [];
const createdNations: Nation[] = [];
const patches: NonNullable<GeneralActionResolution['patches']> = {
generals: [],
cities: [],
nations: [],
const accumulator: ActionResolutionAccumulator = {
createdGenerals: [],
createdNations: [],
patches: { generals: [], cities: [], nations: [] },
pendingEffects: [],
};
const pendingEffects: GeneralActionEffect[] = [];
let outcome: GeneralActionOutcome<TriggerState> | undefined;
const [nextWorld, worldPatches] = produceWithPatches(
{
@@ -260,45 +369,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
nation: context.nation,
} as WorldState<TriggerState>,
(draft) => {
const addLog = (message: string, options: Partial<Omit<LogEntryDraft, 'text'>> = {}) => {
const entry: LogEntryDraft = {
scope: options.scope ?? LogScope.GENERAL,
category: options.category ?? LogCategory.ACTION,
text: message,
format: options.format ?? LogFormat.MONTH,
...options,
};
switch (entry.scope) {
case LogScope.GENERAL:
logs.push({
...entry,
generalId: entry.generalId ?? context.general.id,
});
break;
case LogScope.NATION:
if (entry.nationId !== undefined) {
logs.push(entry);
break;
}
if (context.nation?.id !== undefined) {
logs.push({
...entry,
nationId: context.nation.id,
});
}
break;
case LogScope.USER:
if (entry.userId) {
logs.push(entry);
}
break;
case LogScope.SYSTEM:
default:
logs.push(entry);
break;
}
};
const addLog = createActionLogSink(context, logs);
outcome = resolver.resolve(
{
@@ -312,85 +383,19 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
args
);
for (const effect of outcome.effects) {
switch (effect.type) {
case 'log':
addLog(effect.entry.text, effect.entry);
break;
case 'schedule:override':
nextTurnAtOverride = effect.nextTurnAt;
break;
case 'general:add':
createdGenerals.push(effect.general as General);
break;
case 'nation:add':
createdNations.push(effect.nation as Nation);
break;
case 'diplomacy:patch':
case 'message:add':
pendingEffects.push(effect);
break;
case 'general:patch':
case 'city:patch':
case 'nation:patch':
// 타겟이 다른 경우 patches에 추가
if (
effect.type === 'general:patch' &&
effect.targetId !== undefined &&
effect.targetId !== context.general.id
) {
patches.generals.push({
id: effect.targetId,
patch: effect.patch as Partial<General>,
});
} else if (effect.type === 'general:patch') {
Object.assign(draft.general, effect.patch);
} else if (
effect.type === 'city:patch' &&
effect.targetId !== undefined &&
effect.targetId !== context.city?.id
) {
patches.cities.push({
id: effect.targetId,
patch: effect.patch,
});
} else if (effect.type === 'city:patch' && draft.city) {
Object.assign(draft.city, effect.patch);
} else if (
effect.type === 'nation:patch' &&
effect.targetId !== undefined &&
effect.targetId !== context.nation?.id
) {
patches.nations.push({
id: effect.targetId,
patch: effect.patch,
});
} else if (effect.type === 'nation:patch' && draft.nation) {
Object.assign(draft.nation, effect.patch);
}
break;
}
}
applyGeneralActionEffects({
effects: outcome.effects,
draft,
context,
addLog,
accumulator,
});
}
);
const nextTurnAt = nextTurnAtOverride ?? getNextTurnAt(scheduleContext.now, scheduleContext.schedule);
const nextTurnAt = getNextTurnAt(scheduleContext.now, scheduleContext.schedule);
const dirty: NonNullable<GeneralActionResolution['dirty']> = {
general: false,
city: false,
nation: false,
generalId: context.general.id,
};
if (context.city) dirty.cityId = context.city.id;
if (context.nation) dirty.nationId = context.nation.id;
// worldPatches를 분석하여 dirty 설정
for (const patch of worldPatches) {
if (patch.path[0] === 'general') dirty.general = true;
if (patch.path[0] === 'city') dirty.city = true;
if (patch.path[0] === 'nation') dirty.nation = true;
}
const dirty = resolveDirtyState(context, worldPatches);
const resolution: GeneralActionResolution = {
general: nextWorld.general as General,
@@ -398,7 +403,7 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
completed: outcome?.completed !== false,
nextTurnAt,
logs,
effects: pendingEffects,
effects: accumulator.pendingEffects,
...(outcome?.alternative ? { alternative: outcome.alternative } : {}),
...(outcome?.deletedTroopIds?.length ? { deletedTroopIds: outcome.deletedTroopIds } : {}),
...(outcome?.reservedGeneralTurnPlans?.length
@@ -411,13 +416,17 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
if (dirty.general || dirty.city || dirty.nation) {
resolution.dirty = dirty;
}
if (patches.generals.length > 0 || patches.cities.length > 0 || patches.nations.length > 0) {
resolution.patches = patches;
if (
accumulator.patches.generals.length > 0 ||
accumulator.patches.cities.length > 0 ||
accumulator.patches.nations.length > 0
) {
resolution.patches = accumulator.patches;
}
if (createdGenerals.length > 0 || createdNations.length > 0) {
if (accumulator.createdGenerals.length > 0 || accumulator.createdNations.length > 0) {
resolution.created = {
generals: createdGenerals,
...(createdNations.length > 0 ? { nations: createdNations } : {}),
generals: accumulator.createdGenerals,
...(accumulator.createdNations.length > 0 ? { nations: accumulator.createdNations } : {}),
};
}
-3
View File
@@ -6,6 +6,3 @@ export * from './turn/commandModule.js';
export * from './turn/commandProfile.js';
export * from './turn/general/index.js';
export * from './turn/nation/index.js';
export * from './instant/general/index.js';
export * from './instant/nation/index.js';
export * from './admin/index.js';
@@ -1 +0,0 @@
export {};
@@ -1,15 +0,0 @@
export {
ActionDefinition as NonAggressionAcceptActionDefinition,
type NonAggressionAcceptArgs,
type NonAggressionAcceptContext,
} from './che_불가침수락.js';
export {
ActionDefinition as NonAggressionCancelAcceptActionDefinition,
type NonAggressionCancelAcceptArgs,
type NonAggressionCancelAcceptContext,
} from './che_불가침파기수락.js';
export {
ActionDefinition as StopWarAcceptActionDefinition,
type StopWarAcceptArgs,
type StopWarAcceptContext,
} from './che_종전수락.js';
@@ -9,7 +9,7 @@ import {
reqGeneralValue,
reqEnvValue,
readMetaNumberFromUnknown,
alwaysFail,
denyWithReason,
} from '@sammo-ts/logic/constraints/presets.js';
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type {
@@ -318,7 +318,7 @@ export class ActionDefinition<
}
buildPermissionConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] {
return [alwaysFail('예약 불가능 커맨드')];
return [denyWithReason('예약 불가능 커맨드')];
}
buildConstraints(_ctx: ConstraintContext, _args: AcceptScoutArgs): Constraint[] {
@@ -1,7 +1,7 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
alwaysFail,
denyWithReason,
reqCityCapacity,
reqCityTrader,
reqGeneralGold,
@@ -131,13 +131,13 @@ export class ActionDefinition<
}
const currentItemCode = general.role.items[args.itemType];
if (currentItemCode === args.itemCode) {
return alwaysFail('이미 가지고 있습니다.').test(ctx, view);
return denyWithReason('이미 가지고 있습니다.').test(ctx, view);
}
if (currentItemCode) {
const currentItem = readItem(this.env.itemCatalog, currentItemCode);
if (currentItem && !currentItem.buyable) {
return alwaysFail('이미 진귀한 것을 가지고 있습니다.').test(ctx, view);
return denyWithReason('이미 진귀한 것을 가지고 있습니다.').test(ctx, view);
}
}
return { kind: 'allow' };
@@ -1,102 +0,0 @@
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js';
import {
notBeNeutral,
notWanderingNation,
occupiedCity,
remainCityCapacity,
reqGeneralGold,
reqGeneralRice,
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 { clamp } from 'es-toolkit';
export interface CityDevelopmentArgs {}
export interface CityDevelopmentEnvironment {
develCost?: number;
amount?: number;
}
type NumberKeys<T> = { [K in keyof T]-?: T[K] extends number ? K : never }[keyof T];
export interface CityDevelopmentConfig {
key: string;
name: string;
statKey: NumberKeys<City>;
maxKey: NumberKeys<City>;
label: string;
baseAmount: number;
}
const readNumber = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
export class CityDevelopmentActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, CityDevelopmentArgs> {
public readonly key: string;
public readonly name: string;
private readonly config: CityDevelopmentConfig;
private readonly env: CityDevelopmentEnvironment;
constructor(config: CityDevelopmentConfig, env: CityDevelopmentEnvironment) {
this.key = config.key;
this.name = config.name;
this.config = config;
this.env = env;
}
parseArgs(_raw: unknown): CityDevelopmentArgs | null {
void _raw;
return {};
}
buildConstraints(_ctx: ConstraintContext, _args: CityDevelopmentArgs): Constraint[] {
const getRequiredGold = (_context: ConstraintContext, _view: StateView): number => this.env.develCost ?? 0;
return [
notBeNeutral(),
notWanderingNation(),
occupiedCity(),
suppliedCity(),
remainCityCapacity(this.config.statKey, this.config.label),
reqGeneralGold(getRequiredGold),
reqGeneralRice(() => 0),
];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
_args: CityDevelopmentArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const city = context.city;
if (!city) {
context.addLog('도시 정보를 찾지 못했습니다.');
return { effects: [] };
}
const baseAmount = this.env.amount ?? this.config.baseAmount;
const current = readNumber(city[this.config.statKey]);
const max = readNumber(city[this.config.maxKey]);
if (current === null || max === null) {
context.addLog('도시 정보를 찾지 못했습니다.');
return { effects: [] };
}
const nextValue = clamp(current + baseAmount, 0, max);
const costGold = this.env.develCost ?? 0;
// 직접 수정 (Immer Draft)
city[this.config.statKey] = nextValue;
general.gold = Math.max(0, general.gold - costGold);
const logMessage = `${this.config.label}${nextValue - current} 증가했습니다.`;
context.addLog(logMessage);
return { effects: [] };
}
}
@@ -169,4 +169,4 @@ export const loadGeneralTurnCommandSpecs = async (
return specs;
};
export { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
export { readLegacyCityTrust } from './legacyCityTrust.js';