refactor: tighten backend and logic boundaries
This commit is contained in:
@@ -1 +0,0 @@
|
||||
export {};
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -236,30 +236,6 @@ export const remainCityCapacity = (key: keyof City, label: string): Constraint =
|
||||
},
|
||||
});
|
||||
|
||||
export const remainCityCapacityByMax = (key: keyof City, maxKey: keyof City, label: string): Constraint => ({
|
||||
name: 'remainCityCapacityByMax',
|
||||
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
||||
test: (ctx, view) => {
|
||||
const city = readCity(view, ctx.cityId);
|
||||
if (!city) {
|
||||
if (ctx.cityId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||
}
|
||||
const req: RequirementKey = { kind: 'city', id: ctx.cityId };
|
||||
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
|
||||
}
|
||||
const current = city[key];
|
||||
const max = city[maxKey];
|
||||
if (typeof current !== 'number' || typeof max !== 'number') {
|
||||
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||
}
|
||||
if (current < max) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: `${label}이 충분합니다.` };
|
||||
},
|
||||
});
|
||||
|
||||
export const reqCityCapacity = (key: keyof City, label: string, required: number | string): Constraint => ({
|
||||
name: 'reqCityCapacity',
|
||||
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
||||
@@ -506,7 +482,7 @@ export const hasRouteWithEnemy = (): Constraint => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const beNeutralCity = (): Constraint => ({
|
||||
export const neutralCity = (): Constraint => ({
|
||||
name: 'beNeutralCity',
|
||||
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
||||
test: (ctx, view) => {
|
||||
@@ -534,8 +510,6 @@ export const beNeutralCity = (): Constraint => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const neutralCity = (): Constraint => beNeutralCity();
|
||||
|
||||
export const constructableCity = (): Constraint => ({
|
||||
name: 'constructableCity',
|
||||
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
||||
@@ -558,25 +532,6 @@ export const constructableCity = (): Constraint => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const reqCityLevel = (levels: number[]): Constraint => ({
|
||||
name: 'reqCityLevel',
|
||||
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
|
||||
test: (ctx, view) => {
|
||||
const city = readCity(view, ctx.cityId);
|
||||
if (!city) {
|
||||
if (ctx.cityId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
|
||||
}
|
||||
const req: RequirementKey = { kind: 'city', id: ctx.cityId };
|
||||
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
|
||||
}
|
||||
if (levels.includes(city.level)) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: '규모가 맞지 않습니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const nearCity = (maxDistance: number): Constraint => ({
|
||||
name: 'nearCity',
|
||||
requires: (ctx) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Constraint, ConstraintContext, ConstraintResult, RequirementKey, StateView } from './types.js';
|
||||
import type { Constraint, ConstraintContext, ConstraintResult, StateView } from './types.js';
|
||||
|
||||
export const evaluateConstraints = (
|
||||
constraints: Constraint[],
|
||||
@@ -24,14 +24,6 @@ export const evaluateConstraints = (
|
||||
return { kind: 'allow' };
|
||||
};
|
||||
|
||||
export const collectRequirements = (constraints: Constraint[], ctx: ConstraintContext): RequirementKey[] => {
|
||||
const keys: RequirementKey[] = [];
|
||||
for (const constraint of constraints) {
|
||||
keys.push(...constraint.requires(ctx));
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
export interface ActionWithConstraints {
|
||||
buildConstraints(ctx: ConstraintContext, args: unknown): Constraint[];
|
||||
}
|
||||
|
||||
@@ -89,25 +89,6 @@ export const beChief = (): Constraint => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const beMonarch = (): Constraint => ({
|
||||
name: 'beMonarch',
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
test: (ctx, view) => {
|
||||
const req: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||
if (!view.has(req)) {
|
||||
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||
}
|
||||
const general = view.get(req) as General | null;
|
||||
if (!general) {
|
||||
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||
}
|
||||
if (general.officerLevel === 12) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: '군주가 아닙니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const beLord = (): Constraint => ({
|
||||
name: 'beLord',
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
@@ -508,47 +489,6 @@ export const existsDestGeneral = (): Constraint => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const destGeneralInDestNation = (): Constraint => ({
|
||||
name: 'destGeneralInDestNation',
|
||||
requires: (ctx) => {
|
||||
const reqs: RequirementKey[] = [];
|
||||
const destGeneralId = resolveDestGeneralId(ctx);
|
||||
if (destGeneralId !== undefined) {
|
||||
reqs.push({ kind: 'destGeneral', id: destGeneralId });
|
||||
}
|
||||
const destNationId = resolveDestNationId(ctx);
|
||||
if (destNationId !== undefined) {
|
||||
reqs.push({ kind: 'destNation', id: destNationId });
|
||||
}
|
||||
return reqs;
|
||||
},
|
||||
test: (ctx, view) => {
|
||||
const destGeneral = readDestGeneral(ctx, view);
|
||||
if (!destGeneral) {
|
||||
const destGeneralId = resolveDestGeneralId(ctx);
|
||||
if (destGeneralId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '장수 정보가 없습니다.');
|
||||
}
|
||||
const req: RequirementKey = {
|
||||
kind: 'destGeneral',
|
||||
id: destGeneralId,
|
||||
};
|
||||
return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||
}
|
||||
const destNationId = resolveDestNationId(ctx);
|
||||
if (destNationId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '국가 정보가 없습니다.');
|
||||
}
|
||||
if (destGeneral.nationId !== destNationId) {
|
||||
return {
|
||||
kind: 'deny',
|
||||
reason: '제의 장수가 국가 소속이 아닙니다.',
|
||||
};
|
||||
}
|
||||
return allow();
|
||||
},
|
||||
});
|
||||
|
||||
export const friendlyDestGeneral = (): Constraint => ({
|
||||
name: 'friendlyDestGeneral',
|
||||
requires: (ctx) => {
|
||||
@@ -603,29 +543,6 @@ export const mustBeNPC = (): Constraint => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const notSameDestNation = (): Constraint => ({
|
||||
name: 'notSameDestNation',
|
||||
requires: (ctx) => {
|
||||
const reqs: RequirementKey[] = [];
|
||||
const destNationId = resolveDestNationId(ctx);
|
||||
if (destNationId !== undefined) {
|
||||
reqs.push({ kind: 'destNation', id: destNationId });
|
||||
}
|
||||
return reqs;
|
||||
},
|
||||
test: (ctx, _view) => {
|
||||
const destNationId = resolveDestNationId(ctx);
|
||||
if (destNationId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '목표 국가가 없습니다.');
|
||||
}
|
||||
|
||||
if (ctx.nationId === destNationId) {
|
||||
return { kind: 'deny', reason: '이미 소속된 국가입니다.' };
|
||||
}
|
||||
return allow();
|
||||
},
|
||||
});
|
||||
|
||||
export const notLord = (): Constraint => ({
|
||||
name: 'notLord',
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
@@ -640,18 +557,3 @@ export const notLord = (): Constraint => ({
|
||||
return { kind: 'deny', reason: '군주는 불가능합니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const notChief = (): Constraint => ({
|
||||
name: 'notChief',
|
||||
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
|
||||
test: (ctx, view) => {
|
||||
const req: RequirementKey = { kind: 'general', id: ctx.actorId };
|
||||
const general = view.get(req) as General | null;
|
||||
if (!general) return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.');
|
||||
|
||||
if (general.officerLevel <= 4) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: '수뇌입니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,9 +7,6 @@ export const denyWithReason = (reason: string): Constraint => ({
|
||||
test: () => ({ kind: 'deny', reason }),
|
||||
});
|
||||
|
||||
// TODO: 점진 이전을 위해 유지. 신규 코드에서는 denyWithReason을 사용한다.
|
||||
export const alwaysFail = denyWithReason;
|
||||
|
||||
export const notOpeningPart = (relYear: number, openingPartYear: number): Constraint => ({
|
||||
name: 'notOpeningPart',
|
||||
requires: () => [],
|
||||
|
||||
@@ -48,25 +48,6 @@ export const notWanderingNation = (): Constraint => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const beWanderingNation = (): Constraint => ({
|
||||
name: 'beWanderingNation',
|
||||
requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []),
|
||||
test: (ctx, view) => {
|
||||
const nation = readNation(view, ctx.nationId);
|
||||
if (!nation) {
|
||||
if (ctx.nationId === undefined) {
|
||||
return unknownOrDeny(ctx, [], '국가 정보가 없습니다.');
|
||||
}
|
||||
const req: RequirementKey = { kind: 'nation', id: ctx.nationId };
|
||||
return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.');
|
||||
}
|
||||
if (nation.level === 0) {
|
||||
return allow();
|
||||
}
|
||||
return { kind: 'deny', reason: '방랑군이 아닙니다.' };
|
||||
},
|
||||
});
|
||||
|
||||
export const wanderingNation = (): Constraint => ({
|
||||
name: 'wanderingNation',
|
||||
requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { actionModule as castleFirst } from './actions/che_성벽선제.js';
|
||||
import type { CrewTypeActionModule, CrewTypeActionRegistry } from './types.js';
|
||||
|
||||
export const CREW_TYPE_ACTION_KEYS = ['che_성벽선제'] as const;
|
||||
|
||||
export const createCrewTypeActionRegistry = (
|
||||
modules: readonly CrewTypeActionModule[] = [castleFirst]
|
||||
): CrewTypeActionRegistry => new Map(modules.map((module) => [module.key, module]));
|
||||
|
||||
@@ -4,7 +4,6 @@ import { DIPLOMACY_STATE } from './constants.js';
|
||||
|
||||
export { DIPLOMACY_STATE } from './constants.js';
|
||||
|
||||
export const DEFAULT_DECLARE_WAR_TERM = 24;
|
||||
export const DEFAULT_WAR_TERM = 6;
|
||||
|
||||
const MAX_WAR_TERM = 13;
|
||||
|
||||
@@ -87,7 +87,7 @@ export const createIncomeActionContext = (nation: Nation): GeneralActionContext
|
||||
return { general, nation };
|
||||
};
|
||||
|
||||
export const calcCityGoldIncomeBase = (
|
||||
const calcCityGoldIncomeBase = (
|
||||
context: NationIncomeContext,
|
||||
city: CityIncomeSource,
|
||||
officerCnt: number,
|
||||
@@ -112,7 +112,7 @@ export const calcCityGoldIncomeBase = (
|
||||
return Math.round(adjusted);
|
||||
};
|
||||
|
||||
export const calcCityRiceIncomeBase = (
|
||||
const calcCityRiceIncomeBase = (
|
||||
context: NationIncomeContext,
|
||||
city: CityIncomeSource,
|
||||
officerCnt: number,
|
||||
@@ -137,7 +137,7 @@ export const calcCityRiceIncomeBase = (
|
||||
return Math.round(adjusted);
|
||||
};
|
||||
|
||||
export const calcCityWallIncomeBase = (
|
||||
const calcCityWallIncomeBase = (
|
||||
context: NationIncomeContext,
|
||||
city: CityIncomeSource,
|
||||
officerCnt: number,
|
||||
@@ -161,7 +161,7 @@ export const calcCityWallIncomeBase = (
|
||||
return Math.round(adjusted);
|
||||
};
|
||||
|
||||
export const calcCityWarGoldIncome = (context: NationIncomeContext, city: CityIncomeSource): number => {
|
||||
const calcCityWarGoldIncome = (context: NationIncomeContext, city: CityIncomeSource): number => {
|
||||
if (city.supplyState === 0) {
|
||||
return 0;
|
||||
}
|
||||
@@ -257,7 +257,7 @@ export const getWarGoldIncome = (context: NationIncomeContext, cities: CityIncom
|
||||
return total;
|
||||
};
|
||||
|
||||
export const resolveDedLevel = (dedication: number): number => {
|
||||
const resolveDedLevel = (dedication: number): number => {
|
||||
const level = Math.ceil(Math.sqrt(Math.max(0, dedication)) / 10);
|
||||
return Math.max(0, Math.min(MAX_DED_LEVEL, level));
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ export * from './economy/index.js';
|
||||
export * from './logging/index.js';
|
||||
export * from './messages/index.js';
|
||||
export * from './items/index.js';
|
||||
export { ITEM_KEYS, createItemActionModules, createItemModuleRegistry, loadItemModules } from './items/index.js';
|
||||
export * from './rewards/uniqueLottery.js';
|
||||
export * from './inheritance/inheritBuff.js';
|
||||
export * from './resources/index.js';
|
||||
|
||||
@@ -754,26 +754,13 @@ export const createItemActionModules = <TriggerState extends GeneralTriggerState
|
||||
});
|
||||
|
||||
export type { ItemModule, ItemModuleExport, ItemSlot } from './types.js';
|
||||
export {
|
||||
canAcquireItem,
|
||||
isInventoryEnabled,
|
||||
listEquippedItemKeys,
|
||||
consumeItemRemain,
|
||||
getItemRemain,
|
||||
setItemRemain,
|
||||
} from './utils.js';
|
||||
export {
|
||||
cloneItemInventory,
|
||||
consumeEquippedItemCharge,
|
||||
createItemInventoryFromSlots,
|
||||
ensureItemInventory,
|
||||
equipNewItem,
|
||||
getEquippedItemInstance,
|
||||
parseItemInventory,
|
||||
projectItemSlots,
|
||||
readItemInventory,
|
||||
readItemInventoryFromMeta,
|
||||
removeEquippedItem,
|
||||
serializeItemInventory,
|
||||
withSerializedItemInventory,
|
||||
} from './inventory.js';
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
import type { ScenarioConfig } from '@sammo-ts/logic/scenario/types.js';
|
||||
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { ItemModule } from './types.js';
|
||||
import {
|
||||
consumeEquippedItemCharge,
|
||||
ensureItemInventory,
|
||||
getEquippedItemInstance,
|
||||
readItemInventory,
|
||||
} from './inventory.js';
|
||||
|
||||
const toBoolean = (value: unknown): boolean => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value > 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === 'yes' || normalized === '1';
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isInventoryEnabled = (config: ScenarioConfig): boolean => {
|
||||
const constConfig = config.const ?? {};
|
||||
return toBoolean(
|
||||
constConfig['allowInventory'] ?? constConfig['inventoryEnabled'] ?? constConfig['enableInventory']
|
||||
);
|
||||
};
|
||||
import { consumeEquippedItemCharge, readItemInventory } from './inventory.js';
|
||||
|
||||
export const listEquippedItemKeys = <TriggerState extends GeneralTriggerState>(
|
||||
general: General<TriggerState>
|
||||
@@ -49,32 +21,6 @@ export const listEquippedItemKeys = <TriggerState extends GeneralTriggerState>(
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getItemRemain = <TriggerState extends GeneralTriggerState>(
|
||||
general: General<TriggerState>,
|
||||
itemKey: string
|
||||
): number | null => {
|
||||
const instance = getEquippedItemInstance(general, 'item');
|
||||
const value = instance?.itemKey === itemKey ? instance.state.charges : undefined;
|
||||
return typeof value === 'number' && value > 0 ? value : null;
|
||||
};
|
||||
|
||||
export const setItemRemain = <TriggerState extends GeneralTriggerState>(
|
||||
general: General<TriggerState>,
|
||||
itemKey: string,
|
||||
remain: number | null
|
||||
): void => {
|
||||
ensureItemInventory(general);
|
||||
const instance = getEquippedItemInstance(general, 'item');
|
||||
if (!instance || instance.itemKey !== itemKey) {
|
||||
return;
|
||||
}
|
||||
if (remain === null || remain <= 0) {
|
||||
delete instance.state.charges;
|
||||
return;
|
||||
}
|
||||
instance.state.charges = remain;
|
||||
};
|
||||
|
||||
export const consumeItemRemain = <TriggerState extends GeneralTriggerState>(
|
||||
general: General<TriggerState>,
|
||||
itemKey: string,
|
||||
@@ -82,27 +28,3 @@ export const consumeItemRemain = <TriggerState extends GeneralTriggerState>(
|
||||
): boolean => {
|
||||
return consumeEquippedItemCharge(general, 'item', itemKey, fallbackRemain);
|
||||
};
|
||||
|
||||
export const canAcquireItem = <TriggerState extends GeneralTriggerState>(options: {
|
||||
general: General<TriggerState>;
|
||||
item: ItemModule;
|
||||
config: ScenarioConfig;
|
||||
registry: Map<string, ItemModule>;
|
||||
}): boolean => {
|
||||
const { general, item, config, registry } = options;
|
||||
if (!item.unique) {
|
||||
return true;
|
||||
}
|
||||
if (isInventoryEnabled(config)) {
|
||||
return true;
|
||||
}
|
||||
const slotItemKey = general.role.items[item.slot];
|
||||
if (!slotItemKey) {
|
||||
return true;
|
||||
}
|
||||
const slotItem = registry.get(slotItemKey);
|
||||
if (!slotItem) {
|
||||
return true;
|
||||
}
|
||||
return !slotItem.unique;
|
||||
};
|
||||
|
||||
@@ -45,9 +45,9 @@ export interface MessageStore {
|
||||
insertMessage(draft: MessageRecordDraft): Promise<number>;
|
||||
}
|
||||
|
||||
export const isValidMailbox = (mailbox: number): boolean => mailbox > 0 && mailbox <= MESSAGE_MAILBOX_PUBLIC;
|
||||
const isValidMailbox = (mailbox: number): boolean => mailbox > 0 && mailbox <= MESSAGE_MAILBOX_PUBLIC;
|
||||
|
||||
export const resolveReceiverMailbox = (draft: MessageDraft): number => {
|
||||
const resolveReceiverMailbox = (draft: MessageDraft): number => {
|
||||
switch (draft.msgType) {
|
||||
case 'public':
|
||||
return MESSAGE_MAILBOX_PUBLIC;
|
||||
@@ -59,7 +59,7 @@ export const resolveReceiverMailbox = (draft: MessageDraft): number => {
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveSenderMailbox = (draft: MessageDraft): number | null => {
|
||||
const resolveSenderMailbox = (draft: MessageDraft): number | null => {
|
||||
switch (draft.msgType) {
|
||||
case 'public':
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const MapCityStatsSchema = z.object({
|
||||
const MapCityStatsSchema = z.object({
|
||||
population: z.number(),
|
||||
agriculture: z.number(),
|
||||
commerce: z.number(),
|
||||
@@ -9,7 +9,7 @@ export const MapCityStatsSchema = z.object({
|
||||
wall: z.number(),
|
||||
});
|
||||
|
||||
export const MapCityDefinitionSchema = z.object({
|
||||
const MapCityDefinitionSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
level: z.number(),
|
||||
@@ -24,7 +24,7 @@ export const MapCityDefinitionSchema = z.object({
|
||||
meta: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const MapDefaultsSchema = z
|
||||
const MapDefaultsSchema = z
|
||||
.object({
|
||||
trust: z.number(),
|
||||
trade: z.number(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { SCENARIO_EFFECT_KEYS } from '../scenario/scenarioEffect.js';
|
||||
|
||||
export const ScenarioStatBlockSchema = z
|
||||
const ScenarioStatBlockSchema = z
|
||||
.object({
|
||||
total: z.number(),
|
||||
min: z.number(),
|
||||
@@ -18,7 +18,7 @@ export const ScenarioDefaultsInputSchema = z.object({
|
||||
iconPath: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ScenarioExtendsInputSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]);
|
||||
const ScenarioExtendsInputSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]);
|
||||
|
||||
const ScenarioConstInputSchema = z
|
||||
.object({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
const numericRecordSchema = z.record(z.string(), z.number());
|
||||
const numericArraySchema = z.array(z.number());
|
||||
|
||||
export const CrewTypeRequirementSchema = z.union([
|
||||
const CrewTypeRequirementSchema = z.union([
|
||||
z.object({ type: z.literal('ReqTech'), tech: z.number() }),
|
||||
z.object({ type: z.literal('ReqRegions'), regions: z.array(z.string()) }),
|
||||
z.object({ type: z.literal('ReqCities'), cities: z.array(z.string()) }),
|
||||
@@ -22,7 +22,7 @@ export const CrewTypeRequirementSchema = z.union([
|
||||
z.object({ type: z.string() }).passthrough(),
|
||||
]);
|
||||
|
||||
export const CrewTypeDefinitionInputSchema = z.object({
|
||||
const CrewTypeDefinitionInputSchema = z.object({
|
||||
id: z.number(),
|
||||
armType: z.number(),
|
||||
name: z.string(),
|
||||
|
||||
@@ -7,8 +7,7 @@ import { equipNewItem } from '../items/inventory.js';
|
||||
|
||||
export type UniqueItemPool = Record<string, Record<string, number>>;
|
||||
|
||||
export const UNIQUE_ACQUIRE_TYPES = ['아이템', '설문조사', '랜덤 임관', '건국'] as const;
|
||||
export type UniqueAcquireType = (typeof UNIQUE_ACQUIRE_TYPES)[number];
|
||||
export type UniqueAcquireType = '아이템' | '설문조사' | '랜덤 임관' | '건국';
|
||||
|
||||
export type UniqueLotteryRequest = {
|
||||
acquireType: UniqueAcquireType;
|
||||
@@ -322,7 +321,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
|
||||
return rng.choiceUsingWeightPair(availableUnique);
|
||||
};
|
||||
|
||||
export const applyUniqueItemGain = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||
const applyUniqueItemGain = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||
context: GeneralActionResolveContext<TriggerState>,
|
||||
itemModule: ItemModule,
|
||||
acquireType: UniqueAcquireType,
|
||||
|
||||
@@ -12,7 +12,7 @@ const DEFENCE_TRAIN_PENALTY_WAIVER_EFFECTS = new Set<ScenarioEffectKey>([
|
||||
'event_MoreEffect',
|
||||
]);
|
||||
|
||||
export const isScenarioEffectKey = (value: string): value is ScenarioEffectKey =>
|
||||
const isScenarioEffectKey = (value: string): value is ScenarioEffectKey =>
|
||||
SCENARIO_EFFECT_KEYS.includes(value as ScenarioEffectKey);
|
||||
|
||||
export const normalizeScenarioEffect = (value: unknown): ScenarioEffectKey | null => {
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface GeneralSkillActivation {
|
||||
activate(...keys: string[]): void;
|
||||
}
|
||||
|
||||
export const createGeneralSkillActivation = <TriggerState extends GeneralTriggerState>(
|
||||
const createGeneralSkillActivation = <TriggerState extends GeneralTriggerState>(
|
||||
general: General<TriggerState>
|
||||
): GeneralSkillActivation => ({
|
||||
has: (key: string) => Boolean(general.triggerState.flags[key]),
|
||||
|
||||
@@ -37,7 +37,7 @@ const findCurrentEntryIndex = (minuteOfDay: number, entries: TurnScheduleEntries
|
||||
const getEntryAt = (entries: TurnScheduleEntries, index: number): TurnScheduleEntry =>
|
||||
entries[Math.max(0, Math.min(entries.length - 1, index))] ?? entries[0];
|
||||
|
||||
export const getTickMinutesAt = (date: Date, schedule: TurnSchedule): number => {
|
||||
const getTickMinutesAt = (date: Date, schedule: TurnSchedule): number => {
|
||||
const entries = normalizeEntries(schedule.entries);
|
||||
const minuteOfDay = toMinuteOfDay(date);
|
||||
const index = findCurrentEntryIndex(minuteOfDay, entries);
|
||||
|
||||
@@ -8,19 +8,6 @@ import { che_필살발동, che_필살시도 } from './triggers/che_필살.js';
|
||||
import { che_회피발동, che_회피시도 } from './triggers/che_회피.js';
|
||||
import { che_계략발동, che_계략실패, che_계략시도 } from './triggers/che_계략.js';
|
||||
|
||||
export const CREW_TYPE_WAR_TRIGGER_KEYS = [
|
||||
'che_성벽부상무효',
|
||||
'che_기병병종전투',
|
||||
'che_방어력증가5p',
|
||||
'che_선제사격시도',
|
||||
'che_선제사격발동',
|
||||
'che_저지시도',
|
||||
'che_저지발동',
|
||||
'che_필살',
|
||||
'che_회피',
|
||||
'che_계략',
|
||||
] as const;
|
||||
|
||||
export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({
|
||||
che_성벽부상무효: (unit) => new che_성벽부상무효(unit),
|
||||
che_기병병종전투: (unit) => new che_기병병종전투(unit),
|
||||
@@ -31,6 +18,5 @@ export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({
|
||||
che_저지발동: (unit) => new che_저지(unit),
|
||||
che_필살: (unit) => new WarTriggerCaller(new che_필살시도(unit), new che_필살발동(unit)),
|
||||
che_회피: (unit) => new WarTriggerCaller(new che_회피시도(unit), new che_회피발동(unit)),
|
||||
che_계략: (unit) =>
|
||||
new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)),
|
||||
che_계략: (unit) => new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)),
|
||||
});
|
||||
|
||||
@@ -2,9 +2,8 @@ import { JosaUtil } from '@sammo-ts/common';
|
||||
|
||||
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||
import type { WarTriggerModule } from './types.js';
|
||||
|
||||
const MAGIC_TO_GENERAL = {
|
||||
위보: [1.2, 1.1],
|
||||
@@ -83,13 +82,7 @@ export class che_계략시도 extends BaseWarUnitTrigger {
|
||||
const table = oppose instanceof WarUnitCity ? MAGIC_TO_CITY : MAGIC_TO_GENERAL;
|
||||
const magic = self.rng.choice(Object.keys(table));
|
||||
const [rawSuccessDamage, failDamage] = table[magic as keyof typeof table];
|
||||
const successDamage = applyMagicDamageModifiers(
|
||||
self,
|
||||
oppose,
|
||||
'warMagicSuccessDamage',
|
||||
rawSuccessDamage,
|
||||
magic
|
||||
);
|
||||
const successDamage = applyMagicDamageModifiers(self, oppose, 'warMagicSuccessDamage', rawSuccessDamage, magic);
|
||||
|
||||
self.activateSkill('계략시도', magic);
|
||||
if (self.rng.nextBool(successProbability)) {
|
||||
@@ -145,7 +138,8 @@ export class che_계략실패 extends BaseWarUnitTrigger {
|
||||
selfEnv: Record<string, unknown>,
|
||||
_opposeEnv: Record<string, unknown>
|
||||
): boolean {
|
||||
if (!(self instanceof WarUnitGeneral) || !self.hasActivatedSkill('계략실패') || selfEnv['계략실패']) return true;
|
||||
if (!(self instanceof WarUnitGeneral) || !self.hasActivatedSkill('계략실패') || selfEnv['계략실패'])
|
||||
return true;
|
||||
const magicState = readMagic(selfEnv);
|
||||
if (!magicState) return true;
|
||||
selfEnv['계략실패'] = true;
|
||||
@@ -159,11 +153,3 @@ export class che_계략실패 extends BaseWarUnitTrigger {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const triggerModule: WarTriggerModule = {
|
||||
key: 'che_계략',
|
||||
name: '계략',
|
||||
info: '[전투] 귀병의 계략 시도/성공/실패',
|
||||
createTriggerList: (unit) =>
|
||||
new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)),
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
|
||||
import { WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||
import type { WarTriggerModule } from './types.js';
|
||||
|
||||
export class che_회피시도 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
@@ -41,10 +40,3 @@ export class che_회피발동 extends BaseWarUnitTrigger {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const triggerModule: WarTriggerModule = {
|
||||
key: 'che_회피',
|
||||
name: '회피',
|
||||
info: '[전투] 페이즈마다 확률로 회피 발동',
|
||||
createTriggerList: (unit) => new WarTriggerCaller(new che_회피시도(unit), new che_회피발동(unit)),
|
||||
};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { WarTriggerModule, WarTriggerModuleExport } from './types.js';
|
||||
import type { WarTriggerRegistry } from '@sammo-ts/logic/war/triggers.js';
|
||||
|
||||
export const WAR_TRIGGER_KEYS = ['che_필살', 'che_의술'] as const;
|
||||
|
||||
export type WarTriggerKey = (typeof WAR_TRIGGER_KEYS)[number];
|
||||
export type WarTriggerKey = 'che_필살' | 'che_의술';
|
||||
|
||||
export type WarTriggerImporter = () => Promise<WarTriggerModuleExport>;
|
||||
|
||||
@@ -12,9 +9,6 @@ const defaultImporters: Record<WarTriggerKey, WarTriggerImporter> = {
|
||||
che_의술: async () => import('./che_의술.js'),
|
||||
};
|
||||
|
||||
export const isWarTriggerKey = (value: string): value is WarTriggerKey =>
|
||||
WAR_TRIGGER_KEYS.includes(value as WarTriggerKey);
|
||||
|
||||
export class WarTriggerLoader {
|
||||
private readonly cache = new Map<WarTriggerKey, Promise<WarTriggerModule>>();
|
||||
|
||||
@@ -60,12 +54,4 @@ export const loadWarTriggerModules = async (
|
||||
return modules;
|
||||
};
|
||||
|
||||
export const createWarTriggerRegistry = (modules: WarTriggerModule[]): WarTriggerRegistry => {
|
||||
const registry: WarTriggerRegistry = {};
|
||||
for (const module of modules) {
|
||||
registry[module.key] = (unit) => module.createTriggerList(unit);
|
||||
}
|
||||
return registry;
|
||||
};
|
||||
|
||||
export type { WarTriggerModule, WarTriggerModuleExport } from './types.js';
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { WarUnit, WAR_CRITICAL_RANGE, resolveNationTech } from './units/base.js';
|
||||
export { WarUnit } from './units/base.js';
|
||||
export { WarUnitGeneral } from './units/general.js';
|
||||
export { WarUnitCity } from './units/city.js';
|
||||
|
||||
@@ -9,8 +9,6 @@ export const clamp = (value: number, min: number, max: number): number => Math.m
|
||||
|
||||
export const clampMin = (value: number, min: number): number => (value < min ? min : value);
|
||||
|
||||
export const clampMax = (value: number, max: number): number => (value > max ? max : value);
|
||||
|
||||
// REF-COMPAT:BEGIN ref-php-half-rounding
|
||||
// PHP's round() compensates for small binary floating-point drift around a
|
||||
// half boundary and rounds halves away from zero. War state is persisted to
|
||||
@@ -36,11 +34,6 @@ export const getMetaNumber = (meta: Record<string, TriggerValue>, key: string, f
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
};
|
||||
|
||||
export const getMetaString = (meta: Record<string, TriggerValue>, key: string): string | null => {
|
||||
const value = meta[key];
|
||||
return typeof value === 'string' ? value : null;
|
||||
};
|
||||
|
||||
export const setMetaNumber = (meta: Record<string, TriggerValue>, key: string, value: number): void => {
|
||||
meta[key] = round(value);
|
||||
};
|
||||
@@ -120,23 +113,6 @@ export const sortConflictEntries = (
|
||||
.map(([key, value]) => [key, value] as const);
|
||||
};
|
||||
|
||||
export const stringifyConflict = (conflict: Record<number, number> | null): string => {
|
||||
if (!conflict) {
|
||||
return '{}';
|
||||
}
|
||||
const sorted = Object.entries(conflict)
|
||||
.map(([key, value]) => [Number(key), value] as const)
|
||||
.filter(([key, value]) => Number.isFinite(key) && typeof value === 'number')
|
||||
.sort(([, lhs], [, rhs]) => rhs - lhs);
|
||||
|
||||
const ordered: Record<string, number> = {};
|
||||
for (const [key, value] of sorted) {
|
||||
ordered[String(key)] = value;
|
||||
}
|
||||
|
||||
return JSON.stringify(ordered);
|
||||
};
|
||||
|
||||
export const sortConflict = (
|
||||
conflict: Record<number, number>,
|
||||
preferredOrder: number[] = []
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './types.js';
|
||||
export * from './bootstrap.js';
|
||||
export * from './loader.js';
|
||||
export * from './unitSet.js';
|
||||
export * from './distance.js';
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { City, General, Nation, Troop } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { ScenarioConfig, ScenarioDiplomacy } from '@sammo-ts/logic/scenario/types.js';
|
||||
import type { ScenarioConfigSource, WorldStateSnapshotSource } from '@sammo-ts/logic/ports/worldSnapshot.js';
|
||||
import type { MapDefinition, ScenarioMeta, UnitSetDefinition, WorldSnapshot } from './types.js';
|
||||
|
||||
export interface WorldSnapshotLoadInput<
|
||||
GeneralType extends General = General,
|
||||
CityType extends City = City,
|
||||
NationType extends Nation = Nation,
|
||||
TroopType extends Troop = Troop,
|
||||
> {
|
||||
worldSource: WorldStateSnapshotSource<GeneralType, CityType, NationType, TroopType>;
|
||||
scenarioConfig?: ScenarioConfig;
|
||||
scenarioMeta?: ScenarioMeta;
|
||||
scenarioSource?: ScenarioConfigSource;
|
||||
map: MapDefinition;
|
||||
unitSet?: UnitSetDefinition;
|
||||
diplomacy?: ScenarioDiplomacy[];
|
||||
events?: unknown[];
|
||||
initialEvents?: unknown[];
|
||||
}
|
||||
|
||||
// DB 기반 월드 로더: 세계 상태와 시나리오 설정을 합쳐 스냅샷을 만든다.
|
||||
export const loadWorldSnapshot = async <
|
||||
GeneralType extends General,
|
||||
CityType extends City,
|
||||
NationType extends Nation,
|
||||
TroopType extends Troop,
|
||||
>(
|
||||
input: WorldSnapshotLoadInput<GeneralType, CityType, NationType, TroopType>
|
||||
): Promise<WorldSnapshot> => {
|
||||
const { worldSource, scenarioSource } = input;
|
||||
|
||||
const scenarioConfig =
|
||||
input.scenarioConfig ?? (scenarioSource ? await scenarioSource.loadScenarioConfig() : undefined);
|
||||
if (!scenarioConfig) {
|
||||
throw new Error('Scenario config is required to load world snapshot.');
|
||||
}
|
||||
|
||||
const scenarioMeta =
|
||||
input.scenarioMeta ?? (scenarioSource?.loadScenarioMeta ? await scenarioSource.loadScenarioMeta() : undefined);
|
||||
|
||||
const [generals, cities, nations, troops] = await Promise.all([
|
||||
worldSource.listGenerals(),
|
||||
worldSource.listCities(),
|
||||
worldSource.listNations(),
|
||||
worldSource.listTroops(),
|
||||
]);
|
||||
|
||||
return {
|
||||
scenarioConfig,
|
||||
...(scenarioMeta ? { scenarioMeta } : {}),
|
||||
map: input.map,
|
||||
...(input.unitSet ? { unitSet: input.unitSet } : {}),
|
||||
nations,
|
||||
cities,
|
||||
generals,
|
||||
troops,
|
||||
diplomacy: input.diplomacy ?? [],
|
||||
events: input.events ?? [],
|
||||
initialEvents: input.initialEvents ?? [],
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user