feat: 주민 선정 재구현

This commit is contained in:
2026-01-24 12:00:43 +00:00
parent 45bab33fc2
commit 756382d13b
2 changed files with 162 additions and 16 deletions
@@ -528,7 +528,7 @@ describe('NPC 대형 시뮬레이션', () => {
const state = world.getState();
const startYear = snapshot.scenarioMeta?.startYear ?? 0;
const constraintEnv = {
const constraintEnv: Record<string, unknown> = {
currentYear: state.currentYear,
currentMonth: state.currentMonth,
year: state.currentYear,
@@ -1,28 +1,174 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import { CityDevelopmentActionDefinition } from './cityDevelopment.js';
import type { RandomGenerator } from '@sammo-ts/common';
import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext, RequirementKey } from '@sammo-ts/logic/constraints/types.js';
import {
notBeNeutral,
notWanderingNation,
occupiedCity,
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 type { GeneralTurnCommandSpec } from './index.js';
import { clamp } from 'es-toolkit';
export class ActionDefinition<
export interface DomesticActionContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends CityDevelopmentActionDefinition<TriggerState> {
constructor(env: { develCost?: number; amount?: number } = {}) {
super(
> extends GeneralActionContext<TriggerState> {
general: General<TriggerState>;
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<string, unknown>, key: string): number | null => {
const raw = meta[key];
return typeof raw === 'number' ? raw : null;
};
const addMetaNumber = (meta: Record<string, unknown>, key: string, delta: number): Record<string, unknown> => {
const current = getMetaNumber(meta, key) ?? 0;
return { ...meta, [key]: current + delta };
};
const randomRange = (rng: RandomGenerator, min: number, max: number): number => min + (max - min) * rng.nextFloat();
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' };
},
});
export class CommandResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
private readonly env: TrustEnvironment;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, env: TrustEnvironment) {
this.pipeline = new GeneralActionPipeline(modules);
this.env = env;
}
calcBaseScore(context: DomesticActionContext<TriggerState>, 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<TriggerState>, 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<TriggerState, TrustActionArgs> {
public readonly key = 'che_주민선정';
public readonly name = ACTION_NAME;
private readonly command: CommandResolver<TriggerState>;
private readonly env: TrustEnvironment;
constructor(modules: Array<GeneralActionModule<TriggerState> | null | undefined>, env: TrustEnvironment) {
this.env = env;
this.command = new CommandResolver(modules, env);
}
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;
return [
notBeNeutral(),
notWanderingNation(),
occupiedCity(),
suppliedCity(),
reqGeneralRice(getRiceCost, requirements),
remainCityTrust(),
];
}
resolve(
context: GeneralActionResolveContext<TriggerState>,
_args: TrustActionArgs
): GeneralActionOutcome<TriggerState> {
const general = context.general;
const city = context.city;
if (!city) {
context.addLog('도시 정보를 찾지 못했습니다.');
return { effects: [] };
}
const trust = getMetaNumber(city.meta, 'trust') ?? this.env.defaultTrust ?? DEFAULT_TRUST;
const result = this.command.resolve(
{
key: 'che_주민선정',
name: '주민 선정',
statKey: 'population',
maxKey: 'populationMax',
label: '인구',
baseAmount: 1000,
...context,
city,
nation: context.nation ?? null,
},
env
context.rng
);
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 };
context.addLog(`민심이 ${result.trustDelta.toFixed(1)} 상승했습니다.`);
return { effects: [] };
}
}
// 예약 턴 실행은 기본 컨텍스트만 사용한다.
export const actionContextBuilder = defaultActionContextBuilder;
export const commandSpec: GeneralTurnCommandSpec = {
@@ -30,5 +176,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
category: '내정',
reqArg: false,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ develCost: env.develCost }),
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? [], env),
};