내무부, 금/쌀수입, 감찰부 구현

This commit is contained in:
2026-01-18 16:40:12 +00:00
parent bb618a9ade
commit 5b51aad6d0
15 changed files with 2705 additions and 143 deletions
@@ -0,0 +1,22 @@
import type { TurnCalendarHandler } from './inMemoryWorld.js';
export const composeCalendarHandlers = (
...handlers: Array<TurnCalendarHandler | null | undefined>
): TurnCalendarHandler | null => {
const resolved = handlers.filter(Boolean) as TurnCalendarHandler[];
if (resolved.length === 0) {
return null;
}
return {
onMonthChanged: (context) => {
for (const handler of resolved) {
handler.onMonthChanged?.(context);
}
},
onYearChanged: (context) => {
for (const handler of resolved) {
handler.onYearChanged?.(context);
}
},
};
};
+281
View File
@@ -0,0 +1,281 @@
import { asNumber, asRecord } from '@sammo-ts/common';
import {
ActionLogger,
LogFormat,
createIncomeActionContext,
getBill,
getGoldIncome,
getOutcome,
getRiceIncome,
getWallIncome,
type CityIncomeSource,
type Nation,
type NationIncomeContext,
type NationTraitModule,
type TriggerNationalIncomeType,
} from '@sammo-ts/logic';
import type { ScenarioConfig } from '@sammo-ts/logic';
import type { InMemoryTurnWorld, TurnCalendarHandler, TurnCalendarContext } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return fallback;
};
const resolveNationRate = (nation: Nation): number => asNumber(nation.meta.rate, 20);
const resolveNationBill = (nation: Nation): number => asNumber(nation.meta.bill, 100);
const resolveOfficerCity = (meta: Record<string, unknown>): number => {
const camel = asNumber(meta.officerCity, 0);
if (camel > 0) {
return camel;
}
return asNumber(meta.officer_city, 0);
};
const resolveCityTrust = (meta: Record<string, unknown>): number => {
const trust = asNumber(meta.trust, 50);
return trust;
};
const toIncomeCity = (city: ReturnType<InMemoryTurnWorld['listCities']>[number]): CityIncomeSource => ({
id: city.id,
population: city.population,
populationMax: city.populationMax,
agriculture: city.agriculture,
agricultureMax: city.agricultureMax,
commerce: city.commerce,
commerceMax: city.commerceMax,
security: city.security,
securityMax: city.securityMax,
trust: resolveCityTrust(asRecord(city.meta)),
supplyState: city.supplyState,
defence: city.defence,
defenceMax: city.defenceMax,
wall: city.wall,
wallMax: city.wallMax,
meta: asRecord(city.meta),
});
const buildNationIncomeContext = (nation: Nation, trait: NationTraitModule | null): NationIncomeContext => {
const actionContext = createIncomeActionContext(nation);
const modifyIncome = trait?.onCalcNationalIncome
? (type: TriggerNationalIncomeType, amount: number) => trait.onCalcNationalIncome!(actionContext, type, amount)
: undefined;
return {
rate: resolveNationRate(nation),
modifyIncome,
};
};
const buildOfficerCountMap = (generals: TurnGeneral[]): Map<number, number> => {
const officerCntByCity = new Map<number, number>();
for (const general of generals) {
if (general.officerLevel < 2 || general.officerLevel > 4) {
continue;
}
const officerCity = resolveOfficerCity(asRecord(general.meta));
if (!officerCity || general.cityId !== officerCity) {
continue;
}
officerCntByCity.set(officerCity, (officerCntByCity.get(officerCity) ?? 0) + 1);
}
return officerCntByCity;
};
const pushLogs = (world: InMemoryTurnWorld, logs: ReturnType<ActionLogger['flush']>): void => {
for (const log of logs) {
world.pushLog(log);
}
};
const roundResource = (value: number): number => Math.round(value);
const applyIncomeOutcome = (
current: number,
income: number,
outcome: number,
baseResource: number,
originOutcome: number
): { next: number; ratio: number; realOutcome: number } => {
let next = current + income;
let realOutcome = 0;
if (next < baseResource) {
realOutcome = 0;
next = baseResource;
} else if (next - baseResource < outcome) {
realOutcome = next - baseResource;
next = baseResource;
} else {
realOutcome = outcome;
next -= realOutcome;
}
const ratio = originOutcome > 0 ? realOutcome / originOutcome : 0;
return { next: Math.max(next, baseResource), ratio, realOutcome };
};
const processIncomeForNation = (
world: InMemoryTurnWorld,
nation: Nation,
generals: TurnGeneral[],
cities: ReturnType<InMemoryTurnWorld['listCities']>,
officerCounts: Map<number, number>,
traitMap: Map<string, NationTraitModule>,
type: 'gold' | 'rice',
baseResource: number
): void => {
const nationCities = cities.filter((city) => city.nationId === nation.id).map(toIncomeCity);
const nationGenerals = generals.filter((general) => general.nationId === nation.id && general.npcState !== 5);
const trait = traitMap.get(nation.typeCode) ?? null;
const incomeContext = buildNationIncomeContext(nation, trait);
let income = 0;
if (type === 'gold') {
income = getGoldIncome(
incomeContext,
nationCities,
officerCounts,
nation.capitalCityId ?? 0,
nation.level
);
} else {
income =
getRiceIncome(
incomeContext,
nationCities,
officerCounts,
nation.capitalCityId ?? 0,
nation.level
) +
getWallIncome(
incomeContext,
nationCities,
officerCounts,
nation.capitalCityId ?? 0,
nation.level
);
}
const incomeValue = roundResource(income);
const originOutcome = getOutcome(100, nationGenerals);
const bill = resolveNationBill(nation);
const outcome = Math.round((bill / 100) * originOutcome);
const current = type === 'gold' ? nation.gold : nation.rice;
const { next, ratio } = applyIncomeOutcome(current, incomeValue, outcome, baseResource, originOutcome);
const nextMeta = {
...nation.meta,
[`prev_income_${type}`]: incomeValue,
};
if (type === 'gold') {
world.updateNation(nation.id, { gold: next, meta: nextMeta });
} else {
world.updateNation(nation.id, { rice: next, meta: nextMeta });
}
const incomeText = incomeValue.toLocaleString();
const incomeLog = type === 'gold' ? `이번 수입은 금 <C>${incomeText}</>입니다.` : `이번 수입은 쌀 <C>${incomeText}</>입니다.`;
for (const general of nationGenerals) {
const pay = Math.round(getBill(general.dedication) * ratio);
if (type === 'gold') {
world.updateGeneral(general.id, { gold: general.gold + pay });
} else {
world.updateGeneral(general.id, { rice: general.rice + pay });
}
const logger = new ActionLogger({ generalId: general.id, nationId: nation.id });
if (general.officerLevel > 4) {
logger.pushGeneralActionLog(incomeLog, LogFormat.PLAIN);
}
const payText = pay.toLocaleString();
const payLog =
type === 'gold'
? `봉급으로 금 <C>${payText}</>을 받았습니다.`
: `봉급으로 쌀 <C>${payText}</>을 받았습니다.`;
logger.pushGeneralActionLog(payLog, LogFormat.PLAIN);
pushLogs(world, logger.flush());
}
};
export const createIncomeHandler = (options: {
getWorld: () => InMemoryTurnWorld | null;
scenarioConfig: ScenarioConfig;
nationTraits: Map<string, NationTraitModule>;
}): TurnCalendarHandler => {
const constValues = asRecord(options.scenarioConfig.const);
const baseGold = resolveNumber(constValues, ['baseGold', 'basegold'], 0);
const baseRice = resolveNumber(constValues, ['baseRice', 'baserice'], 0);
const handler: TurnCalendarHandler = {
onMonthChanged: (context: TurnCalendarContext) => {
const world = options.getWorld();
if (!world) {
return;
}
const month = context.currentMonth;
if (month !== 1 && month !== 7) {
return;
}
const nations = world.listNations();
const generals = world.listGenerals();
const cities = world.listCities();
const byNation: Map<number, TurnGeneral[]> = new Map();
for (const general of generals) {
const bucket = byNation.get(general.nationId) ?? [];
bucket.push(general);
byNation.set(general.nationId, bucket);
}
for (const nation of nations) {
const nationGenerals = byNation.get(nation.id) ?? [];
const officerCounts = buildOfficerCountMap(nationGenerals);
if (month === 1) {
processIncomeForNation(
world,
nation,
nationGenerals,
cities,
officerCounts,
options.nationTraits,
'gold',
baseGold
);
} else if (month === 7) {
processIncomeForNation(
world,
nation,
nationGenerals,
cities,
officerCounts,
options.nationTraits,
'rice',
baseRice
);
}
}
const logger = new ActionLogger();
if (month === 1) {
logger.pushGlobalHistoryLog('<W><b>【지급】</b></>봄이 되어 봉록에 따라 자금이 지급됩니다.');
} else if (month === 7) {
logger.pushGlobalHistoryLog('<W><b>【지급】</b></>가을이 되어 봉록에 따라 군량이 지급됩니다.');
}
pushLogs(world, logger.flush());
},
};
return handler;
};
+12 -1
View File
@@ -1,6 +1,7 @@
import type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic';
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';
import { SystemClock } from '../lifecycle/clock.js';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
@@ -16,6 +17,8 @@ import { InMemoryTurnProcessor } from './inMemoryTurnProcessor.js';
import { InMemoryTurnStateStore } from './inMemoryStateStore.js';
import { createGatewayAdminActionConsumer } from './gatewayAdminActions.js';
import { createGatewayProfileGate } from './gatewayProfileGate.js';
import { composeCalendarHandlers } from './calendarHandlers.js';
import { createIncomeHandler } from './incomeHandler.js';
import { createReservedTurnHandler } from './reservedTurnHandler.js';
import { createReservedTurnStore } from './reservedTurnStore.js';
import { createTurnDaemonCommandHandler } from './worldCommandHandler.js';
@@ -100,6 +103,8 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
})
: await loadTurnCommandProfile());
let worldRef: InMemoryTurnWorld | null = null;
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
const unification = options.calendarHandler
? null
: createUnificationHandler({
@@ -107,6 +112,12 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
profileName: options.profileName ?? options.profile,
getWorld: () => worldRef,
});
const incomeHandler = createIncomeHandler({
getWorld: () => worldRef,
scenarioConfig: snapshot.scenarioConfig,
nationTraits: nationTraitMap,
});
const calendarHandler = composeCalendarHandlers(options.calendarHandler ?? unification?.handler, incomeHandler);
const worldOptions: InMemoryTurnWorldOptions = {
schedule,
generalTurnHandler:
@@ -120,7 +131,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
getWorld: () => worldRef,
commandProfile,
})),
calendarHandler: options.calendarHandler ?? unification?.handler,
calendarHandler: calendarHandler ?? undefined,
};
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
worldRef = world;