fix(engine): align monthly post tail ordering

This commit is contained in:
2026-07-25 22:33:36 +00:00
parent 86ceaab8a6
commit de006181b7
6 changed files with 392 additions and 17 deletions
@@ -71,6 +71,8 @@ export const createNeutralAuctionRegistrar = async (options: {
now?: () => Date;
loadNeutralAuctionCounts?: () => Promise<NeutralAuctionCountRow[]>;
loadTournamentActive?: () => Promise<boolean>;
getNationPowerRollCount?: () => number;
getTournamentRollConsumed?: () => boolean;
}): Promise<NeutralAuctionRegistrar> => {
const connector = options.loadNeutralAuctionCounts
? null
@@ -108,15 +110,16 @@ export const createNeutralAuctionRegistrar = async (options: {
}
const worldConfig = asRecord(options.getWorldConfig() ?? {});
const consumeTournamentRoll =
worldConfig.tournamentTrig === true &&
!(await (options.loadTournamentActive
? options.loadTournamentActive()
: isTournamentActive(options.profileName, options.getRedisClient())));
options.getTournamentRollConsumed?.() ??
(worldConfig.tournamentTrig === true &&
!(await (options.loadTournamentActive
? options.loadTournamentActive()
: isTournamentActive(options.profileName, options.getRedisClient()))));
const plans = buildNeutralResourceAuctionPlan({
hiddenSeed,
seedYear: context.previousYear,
seedMonth: context.previousMonth,
nationCount: world.listNations().length,
nationCount: options.getNationPowerRollCount?.() ?? world.listNations().length,
consumeTournamentRoll,
averageGold: average(eligibleGenerals.map((general) => general.gold)),
averageRice: average(eligibleGenerals.map((general) => general.rice)),
@@ -102,7 +102,7 @@ const calculateNationPower = (
return { power, totalCrew };
};
const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): void => {
const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): number => {
const citiesByNation = new Map<number, string[]>();
for (const city of world.listCities().sort((left, right) => left.id - right.id)) {
const names = citiesByNation.get(city.nationId) ?? [];
@@ -110,7 +110,8 @@ const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): void => {
citiesByNation.set(city.nationId, names);
}
for (const nation of world.listNations().sort((left, right) => left.id - right.id)) {
const nations = world.listNations().sort((left, right) => left.id - right.id);
for (const nation of nations) {
const calculated = calculateNationPower(world, nation.id);
const power = Math.round(calculated.power * rng.nextRange(0.95, 1.05));
const meta = asRecord(nation.meta);
@@ -134,10 +135,12 @@ const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): void => {
},
});
}
return nations.length;
};
export const createMonthlyNationStatsHandler = (options: {
getWorld: () => InMemoryTurnWorld | null;
onNationPowerRollCount?: (count: number) => void;
}): TurnCalendarHandler => ({
onMonthChanged: (context) => {
const world = options.getWorld();
@@ -149,7 +152,8 @@ export const createMonthlyNationStatsHandler = (options: {
simpleSerialize(resolveHiddenSeed(world), 'monthly', context.previousYear, context.previousMonth)
)
);
updateNationPower(world, rng);
const nationPowerRollCount = updateNationPower(world, rng);
options.onNationPowerRollCount?.(nationPowerRollCount);
},
});
+160 -5
View File
@@ -1,13 +1,168 @@
import { createTournamentAutoStartHandler as createCommonTournamentAutoStartHandler } from '@sammo-ts/common';
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { RedisConnector } from '@sammo-ts/infra';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import type { TurnCalendarHandler } from './inMemoryWorld.js';
import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js';
interface TournamentState {
stage: number;
phase: number;
type: number;
auto: boolean;
openYear: number;
openMonth: number;
termSeconds: number;
nextAt: string;
bettingId?: number;
bettingCloseAt?: string;
winnerId?: number;
bettingSettled?: boolean;
rewardSettled?: boolean;
participantsLockedAt?: string;
lastError?: string;
lastErrorAt?: string;
}
const TOURNAMENT_TEXT = [
['전력전', '영웅'],
['통솔전', '명사'],
['일기토', '용사'],
['설전', '책사'],
] as const;
const safeJsonParse = <T>(raw: string | null): T | null => {
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
};
const resolveTermSeconds = (tickSeconds: number): number => {
const turnMinutes = Math.max(1, Math.round(tickSeconds / 60));
return Math.min(120, Math.max(5, turnMinutes)) * 60;
};
const readPattern = (world: InMemoryTurnWorld, config: Record<string, unknown>): number[] => {
const raw = world.getState().meta.tournamentPattern ?? config.tournamentPattern;
if (!Array.isArray(raw)) {
return [];
}
return raw.filter(
(value): value is number => typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 3
);
};
const shuffledDefaultPattern = (): number[] => {
const pattern = [0, 0, 1, 2, 3];
for (let index = pattern.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1));
[pattern[index], pattern[swapIndex]] = [pattern[swapIndex]!, pattern[index]!];
}
return pattern;
};
export const createTournamentAutoStartHandler = (options: {
profileName: string;
getWorld: () => InMemoryTurnWorld | null;
getRedisClient: () => RedisConnector['client'] | null | undefined;
getWorldConfig: () => Record<string, unknown> | null | undefined;
getTickSeconds: () => number | null | undefined;
getNationPowerRollCount: () => number;
onTournamentRollConsumed?: (consumed: boolean) => void;
now?: () => Date;
}): TurnCalendarHandler => {
return createCommonTournamentAutoStartHandler(options);
};
const keys = {
state: `sammo:${options.profileName}:tournament:state`,
participants: `sammo:${options.profileName}:tournament:participants`,
matches: `sammo:${options.profileName}:tournament:matches`,
betting: `sammo:${options.profileName}:tournament:betting`,
};
return {
onMonthChanged: async (context) => {
options.onTournamentRollConsumed?.(false);
const world = options.getWorld();
const redis = options.getRedisClient();
const config = asRecord(options.getWorldConfig() ?? {});
if (!world || !redis || config.tournamentTrig !== true) {
return;
}
const previousState = safeJsonParse<TournamentState>(await redis.get(keys.state));
if (previousState && previousState.stage > 0) {
return;
}
const state = world.getState();
const hiddenSeed =
typeof state.meta.hiddenSeed === 'string' || typeof state.meta.hiddenSeed === 'number'
? state.meta.hiddenSeed
: state.id;
const rng = new RandUtil(
new LiteHashDRBG(simpleSerialize(hiddenSeed, 'monthly', context.previousYear, context.previousMonth))
);
for (let index = 0; index < options.getNationPowerRollCount(); index += 1) {
rng.nextRange(0.95, 1.05);
}
options.onTournamentRollConsumed?.(true);
if (!rng.nextBool(0.4)) {
return;
}
const pattern = readPattern(world, config);
const resolvedPattern = pattern.length > 0 ? pattern : shuffledDefaultPattern();
const type = resolvedPattern.pop() ?? 0;
world.updateWorldMeta({ tournamentPattern: resolvedPattern });
const now = options.now?.() ?? new Date();
const termSeconds =
previousState && Number.isFinite(previousState.termSeconds) && previousState.termSeconds > 0
? previousState.termSeconds
: resolveTermSeconds(state.tickSeconds);
const nextState: TournamentState = {
stage: 1,
phase: 0,
type,
auto: true,
openYear: context.currentYear,
openMonth: context.currentMonth,
termSeconds,
nextAt: new Date(now.getTime() + termSeconds * 1_000).toISOString(),
bettingId: undefined,
bettingCloseAt: undefined,
winnerId: undefined,
bettingSettled: false,
rewardSettled: false,
participantsLockedAt: undefined,
lastError: undefined,
lastErrorAt: undefined,
};
await redis.set(keys.participants, '[]');
await redis.set(keys.matches, '[]');
await redis.set(keys.betting, '[]');
await redis.set(keys.state, JSON.stringify(nextState));
const [typeText, generalTypeText] = TOURNAMENT_TEXT[type] ?? TOURNAMENT_TEXT[0];
const emperor = world
.listGenerals()
.filter((general) => general.officerLevel === 12)
.filter((general) => world.getNationById(general.nationId)?.level === 7)
.sort((left, right) => left.id - right.id)[0];
const previousWinner =
typeof state.meta.prev_winner === 'string'
? state.meta.prev_winner
: typeof state.meta.previousTournamentWinnerName === 'string'
? state.meta.previousTournamentWinnerName
: '';
const opener = emperor?.name ?? previousWinner;
const openerText = opener ? `황제 <Y>${opener}</>의 명으로 ` : '';
world.pushLog({
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
text: `<B><b>【대회】</b></>${openerText}<C>${typeText}</> 대회가 개최됩니다! 천하의 <span class='ev_highlight'>${generalTypeText}</span>들을 모집하고 있습니다!`,
format: LogFormat.EVENT_YEAR_MONTH,
});
},
};
};
+15 -4
View File
@@ -456,8 +456,13 @@ const createTurnDaemonRuntimeWithLease = async (
startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
commandEnv: monthlyCommandEnv,
});
let monthlyNationPowerRollCount = snapshot.nations.length;
let monthlyTournamentRollConsumed = false;
const monthlyNationStatsHandler = createMonthlyNationStatsHandler({
getWorld: () => worldRef,
onNationPowerRollCount: (count) => {
monthlyNationPowerRollCount = count;
},
});
const monthlyDiplomacyHandler = createMonthlyDiplomacyHandler({
getWorld: () => worldRef,
@@ -483,12 +488,18 @@ const createTurnDaemonRuntimeWithLease = async (
getWorld: () => worldRef,
getRedisClient: () => redisConnector?.client,
getWorldConfig: () => snapshot.worldConfig ?? null,
getNationPowerRollCount: () => monthlyNationPowerRollCount,
getTournamentRollConsumed: () => monthlyTournamentRollConsumed,
});
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
profileName: options.profileName ?? options.profile,
getWorld: () => worldRef,
getRedisClient: () => redisConnector?.client,
getWorldConfig: () => snapshot.worldConfig ?? null,
getTickSeconds: () => worldRef?.getState().tickSeconds ?? null,
getNationPowerRollCount: () => monthlyNationPowerRollCount,
onTournamentRollConsumed: (consumed) => {
monthlyTournamentRollConsumed = consumed;
},
});
const yearbookHandler = createYearbookHandler({
databaseUrl: options.databaseUrl,
@@ -497,6 +508,7 @@ const createTurnDaemonRuntimeWithLease = async (
});
const calendarHandler = composeCalendarHandlers(
monthlyEventHandler,
hasEventAction('ProcessIncome') ? null : incomeHandler,
yearbookHandler.handler,
monthlyBoundaryPreHandler,
nationTurnMonthlyHandler,
@@ -506,10 +518,9 @@ const createTurnDaemonRuntimeWithLease = async (
monthlyWanderHandler,
monthlyNationCountHandler,
options.calendarHandler ?? unification?.handler,
hasEventAction('ProcessIncome') ? null : incomeHandler,
frontStateHandler,
tournamentAutoStartHandler,
neutralAuctionRegistrar.handler,
tournamentAutoStartHandler
frontStateHandler
);
const worldOptions: InMemoryTurnWorldOptions = {
schedule,