268 lines
9.5 KiB
TypeScript
268 lines
9.5 KiB
TypeScript
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
|
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
|
|
|
import type { InMemoryTurnWorld, TurnCalendarContext, TurnCalendarHandler } from './inMemoryWorld.js';
|
|
import type { TurnEvent } from './types.js';
|
|
|
|
export interface MonthlyEventEnvironment {
|
|
year: number;
|
|
month: number;
|
|
startyear: number;
|
|
currentEventID: number;
|
|
turnTime: Date;
|
|
}
|
|
|
|
export type MonthlyEventActionHandler = (
|
|
args: readonly unknown[],
|
|
environment: MonthlyEventEnvironment,
|
|
event: TurnEvent
|
|
) => void | Promise<void>;
|
|
|
|
export type MonthlyEventActionRegistry = ReadonlyMap<string, MonthlyEventActionHandler>;
|
|
|
|
export const MONTHLY_EVENT_ACTION_CATALOG = [
|
|
'ProcessIncome',
|
|
'NoticeToHistoryLog',
|
|
'NewYear',
|
|
'ResetOfficerLock',
|
|
'RandomizeCityTradeRate',
|
|
'RaiseDisaster',
|
|
'UpdateCitySupply',
|
|
'UpdateNationLevel',
|
|
'ProcessSemiAnnual',
|
|
'ProcessWarIncome',
|
|
'CreateAdminNPC',
|
|
'CreateManyNPC',
|
|
'RegNPC',
|
|
'RegNeutralNPC',
|
|
'RaiseNPCNation',
|
|
'RaiseInvader',
|
|
'AutoDeleteInvader',
|
|
'InvaderEnding',
|
|
'ChangeCity',
|
|
'ProvideNPCTroopLeader',
|
|
'OpenNationBetting',
|
|
'FinishNationBetting',
|
|
'BlockScoutAction',
|
|
'UnblockScoutAction',
|
|
'AssignGeneralSpeciality',
|
|
'AddGlobalBetray',
|
|
'LostUniqueItem',
|
|
'MergeInheritPointRank',
|
|
'DeleteEvent',
|
|
] as const;
|
|
|
|
export type MonthlyEventActionName = (typeof MONTHLY_EVENT_ACTION_CATALOG)[number];
|
|
|
|
const COMPARATORS = new Set(['==', '!=', '<', '>', '<=', '>=']);
|
|
const CITY_TRADE_PROBABILITY_BY_LEVEL: Readonly<Record<number, number>> = {
|
|
1: 0,
|
|
2: 0,
|
|
3: 0,
|
|
4: 0.2,
|
|
5: 0.4,
|
|
6: 0.6,
|
|
7: 0.8,
|
|
8: 1,
|
|
};
|
|
|
|
const resolveHiddenSeed = (world: InMemoryTurnWorld): string | number => {
|
|
const state = world.getState();
|
|
const rawSeed = state.meta.hiddenSeed ?? state.meta.seed ?? state.id;
|
|
return typeof rawSeed === 'string' || typeof rawSeed === 'number' ? rawSeed : String(rawSeed);
|
|
};
|
|
|
|
export const createRandomizeCityTradeRateHandler = (options: {
|
|
getWorld: () => InMemoryTurnWorld | null;
|
|
}): MonthlyEventActionHandler => {
|
|
return (_args, environment) => {
|
|
const world = options.getWorld();
|
|
if (!world) {
|
|
return;
|
|
}
|
|
const rng = new RandUtil(
|
|
new LiteHashDRBG(
|
|
simpleSerialize(resolveHiddenSeed(world), 'randomizeCityTradeRate', environment.year, environment.month)
|
|
)
|
|
);
|
|
|
|
// Ref's `SELECT city, level FROM city` walks the primary city key.
|
|
// Make RNG consumption independent from PostgreSQL snapshot/insertion order.
|
|
for (const city of world.listCities().sort((left, right) => left.id - right.id)) {
|
|
const probability = CITY_TRADE_PROBABILITY_BY_LEVEL[city.level];
|
|
if (probability === undefined) {
|
|
throw new Error(`Unsupported city level for RandomizeCityTradeRate: ${city.level} (cityId=${city.id})`);
|
|
}
|
|
const trade = probability > 0 && rng.nextBool(probability) ? rng.nextRangeInt(95, 105) : null;
|
|
const { trade: _previousTrade, ...metaWithoutTrade } = city.meta;
|
|
world.updateCity(city.id, {
|
|
meta: trade === null ? metaWithoutTrade : { ...metaWithoutTrade, trade },
|
|
});
|
|
}
|
|
};
|
|
};
|
|
|
|
const compare = (left: readonly (number | null)[], operator: string, right: readonly (number | null)[]): boolean => {
|
|
const normalize = (values: readonly (number | null)[]): string =>
|
|
values.map((value) => (value === null ? '' : String(value).padStart(12, '0'))).join(':');
|
|
const lhs = normalize(left);
|
|
const rhs = normalize(right);
|
|
switch (operator) {
|
|
case '==':
|
|
return lhs === rhs;
|
|
case '!=':
|
|
return lhs !== rhs;
|
|
case '<':
|
|
return lhs < rhs;
|
|
case '>':
|
|
return lhs > rhs;
|
|
case '<=':
|
|
return lhs <= rhs;
|
|
case '>=':
|
|
return lhs >= rhs;
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const readNullableInteger = (value: unknown, label: string): number | null => {
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
throw new Error(`${label} must be an integer or null.`);
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const evaluateCondition = (
|
|
raw: unknown,
|
|
environment: MonthlyEventEnvironment,
|
|
remainingNationCount: number
|
|
): boolean => {
|
|
if (typeof raw === 'boolean') {
|
|
return raw;
|
|
}
|
|
if (!Array.isArray(raw) || typeof raw[0] !== 'string') {
|
|
throw new Error('Event condition must be a boolean or condition tuple.');
|
|
}
|
|
|
|
const name = raw[0];
|
|
const normalized = name.toLowerCase();
|
|
if (normalized === 'not') {
|
|
if (raw.length !== 2) {
|
|
throw new Error('not condition requires exactly one operand.');
|
|
}
|
|
return !evaluateCondition(raw[1], environment, remainingNationCount);
|
|
}
|
|
if (normalized === 'and') {
|
|
return raw.slice(1).every((condition) => evaluateCondition(condition, environment, remainingNationCount));
|
|
}
|
|
if (normalized === 'or') {
|
|
return raw.slice(1).some((condition) => evaluateCondition(condition, environment, remainingNationCount));
|
|
}
|
|
if (normalized === 'xor') {
|
|
return raw
|
|
.slice(1)
|
|
.reduce(
|
|
(value, condition) => value !== evaluateCondition(condition, environment, remainingNationCount),
|
|
false
|
|
);
|
|
}
|
|
|
|
if (name === 'Date' || name === 'DateRelative') {
|
|
const operator = raw[1];
|
|
if (typeof operator !== 'string' || !COMPARATORS.has(operator)) {
|
|
throw new Error(`${name} condition has an invalid comparator.`);
|
|
}
|
|
const year = readNullableInteger(raw[2], `${name}.year`);
|
|
const month = readNullableInteger(raw[3], `${name}.month`);
|
|
if (year === null && month === null) {
|
|
throw new Error(`${name} condition requires year or month.`);
|
|
}
|
|
const currentYear = name === 'DateRelative' ? environment.year - environment.startyear : environment.year;
|
|
return compare([year === null ? null : currentYear, month === null ? null : environment.month], operator, [
|
|
year,
|
|
month,
|
|
]);
|
|
}
|
|
|
|
if (name === 'RemainNation') {
|
|
const operator = raw[1];
|
|
const count = raw[2];
|
|
if (typeof operator !== 'string' || !COMPARATORS.has(operator) || typeof count !== 'number') {
|
|
throw new Error('RemainNation condition is invalid.');
|
|
}
|
|
return compare([remainingNationCount], operator, [Math.floor(count)]);
|
|
}
|
|
|
|
throw new Error(`Unsupported event condition: ${name}`);
|
|
};
|
|
|
|
const parseActions = (raw: unknown): Array<{ name: string; args: readonly unknown[] }> => {
|
|
if (!Array.isArray(raw)) {
|
|
throw new Error('Event action list must be an array.');
|
|
}
|
|
return raw.map((action) => {
|
|
if (!Array.isArray(action) || typeof action[0] !== 'string') {
|
|
throw new Error('Event action must be a tuple beginning with its name.');
|
|
}
|
|
return { name: action[0], args: action.slice(1) };
|
|
});
|
|
};
|
|
|
|
export interface ScenarioEventCalendarHandler extends TurnCalendarHandler {
|
|
dispatchTarget(targetCode: string, context: TurnCalendarContext): Promise<void>;
|
|
}
|
|
|
|
export const createMonthlyEventHandler = (options: {
|
|
getWorld: () => InMemoryTurnWorld | null;
|
|
startYear: number;
|
|
actions?: MonthlyEventActionRegistry;
|
|
}): ScenarioEventCalendarHandler => {
|
|
const dispatchTarget = async (targetCode: string, context: TurnCalendarContext): Promise<void> => {
|
|
const world = options.getWorld();
|
|
if (!world) {
|
|
return;
|
|
}
|
|
const year = targetCode === 'pre_month' ? context.previousYear : context.currentYear;
|
|
const month = targetCode === 'pre_month' ? context.previousMonth : context.currentMonth;
|
|
const remainingNationCount = world.listNations().filter((nation) => nation.id > 0).length;
|
|
// Ref does not write game_env.turntime until every event and
|
|
// postUpdateMonthly step has completed. Event actions therefore see
|
|
// the previous monthly boundary even after turnDate() has advanced
|
|
// year/month. Generated general turn times depend on this distinction.
|
|
const legacyTurnTime = new Date(context.turnTime.getTime() - world.getState().tickSeconds * 1_000);
|
|
|
|
for (const event of world.listEvents(targetCode)) {
|
|
const environment: MonthlyEventEnvironment = {
|
|
year,
|
|
month,
|
|
startyear: options.startYear,
|
|
currentEventID: event.id,
|
|
turnTime: legacyTurnTime,
|
|
};
|
|
if (!evaluateCondition(event.condition, environment, remainingNationCount)) {
|
|
continue;
|
|
}
|
|
for (const action of parseActions(event.action)) {
|
|
if (action.name === 'DeleteEvent') {
|
|
world.removeEvent(event.id);
|
|
continue;
|
|
}
|
|
const handler = options.actions?.get(action.name);
|
|
if (!handler) {
|
|
throw new Error(`Unsupported monthly event action: ${action.name} (eventId=${event.id})`);
|
|
}
|
|
await handler(action.args, environment, event);
|
|
}
|
|
}
|
|
};
|
|
|
|
return {
|
|
beforeMonthChanged: (context) => dispatchTarget('pre_month', context),
|
|
onMonthChanged: (context) => dispatchTarget('month', context),
|
|
dispatchTarget,
|
|
};
|
|
};
|