merge: align monthly post tail ordering
This commit is contained in:
@@ -71,6 +71,8 @@ export const createNeutralAuctionRegistrar = async (options: {
|
|||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
loadNeutralAuctionCounts?: () => Promise<NeutralAuctionCountRow[]>;
|
loadNeutralAuctionCounts?: () => Promise<NeutralAuctionCountRow[]>;
|
||||||
loadTournamentActive?: () => Promise<boolean>;
|
loadTournamentActive?: () => Promise<boolean>;
|
||||||
|
getNationPowerRollCount?: () => number;
|
||||||
|
getTournamentRollConsumed?: () => boolean;
|
||||||
}): Promise<NeutralAuctionRegistrar> => {
|
}): Promise<NeutralAuctionRegistrar> => {
|
||||||
const connector = options.loadNeutralAuctionCounts
|
const connector = options.loadNeutralAuctionCounts
|
||||||
? null
|
? null
|
||||||
@@ -108,15 +110,16 @@ export const createNeutralAuctionRegistrar = async (options: {
|
|||||||
}
|
}
|
||||||
const worldConfig = asRecord(options.getWorldConfig() ?? {});
|
const worldConfig = asRecord(options.getWorldConfig() ?? {});
|
||||||
const consumeTournamentRoll =
|
const consumeTournamentRoll =
|
||||||
worldConfig.tournamentTrig === true &&
|
options.getTournamentRollConsumed?.() ??
|
||||||
!(await (options.loadTournamentActive
|
(worldConfig.tournamentTrig === true &&
|
||||||
? options.loadTournamentActive()
|
!(await (options.loadTournamentActive
|
||||||
: isTournamentActive(options.profileName, options.getRedisClient())));
|
? options.loadTournamentActive()
|
||||||
|
: isTournamentActive(options.profileName, options.getRedisClient()))));
|
||||||
const plans = buildNeutralResourceAuctionPlan({
|
const plans = buildNeutralResourceAuctionPlan({
|
||||||
hiddenSeed,
|
hiddenSeed,
|
||||||
seedYear: context.previousYear,
|
seedYear: context.previousYear,
|
||||||
seedMonth: context.previousMonth,
|
seedMonth: context.previousMonth,
|
||||||
nationCount: world.listNations().length,
|
nationCount: options.getNationPowerRollCount?.() ?? world.listNations().length,
|
||||||
consumeTournamentRoll,
|
consumeTournamentRoll,
|
||||||
averageGold: average(eligibleGenerals.map((general) => general.gold)),
|
averageGold: average(eligibleGenerals.map((general) => general.gold)),
|
||||||
averageRice: average(eligibleGenerals.map((general) => general.rice)),
|
averageRice: average(eligibleGenerals.map((general) => general.rice)),
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const calculateNationPower = (
|
|||||||
return { power, totalCrew };
|
return { power, totalCrew };
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): void => {
|
const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): number => {
|
||||||
const citiesByNation = new Map<number, string[]>();
|
const citiesByNation = new Map<number, string[]>();
|
||||||
for (const city of world.listCities().sort((left, right) => left.id - right.id)) {
|
for (const city of world.listCities().sort((left, right) => left.id - right.id)) {
|
||||||
const names = citiesByNation.get(city.nationId) ?? [];
|
const names = citiesByNation.get(city.nationId) ?? [];
|
||||||
@@ -110,7 +110,8 @@ const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): void => {
|
|||||||
citiesByNation.set(city.nationId, names);
|
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 calculated = calculateNationPower(world, nation.id);
|
||||||
const power = Math.round(calculated.power * rng.nextRange(0.95, 1.05));
|
const power = Math.round(calculated.power * rng.nextRange(0.95, 1.05));
|
||||||
const meta = asRecord(nation.meta);
|
const meta = asRecord(nation.meta);
|
||||||
@@ -134,10 +135,12 @@ const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): void => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return nations.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createMonthlyNationStatsHandler = (options: {
|
export const createMonthlyNationStatsHandler = (options: {
|
||||||
getWorld: () => InMemoryTurnWorld | null;
|
getWorld: () => InMemoryTurnWorld | null;
|
||||||
|
onNationPowerRollCount?: (count: number) => void;
|
||||||
}): TurnCalendarHandler => ({
|
}): TurnCalendarHandler => ({
|
||||||
onMonthChanged: (context) => {
|
onMonthChanged: (context) => {
|
||||||
const world = options.getWorld();
|
const world = options.getWorld();
|
||||||
@@ -149,7 +152,8 @@ export const createMonthlyNationStatsHandler = (options: {
|
|||||||
simpleSerialize(resolveHiddenSeed(world), 'monthly', context.previousYear, context.previousMonth)
|
simpleSerialize(resolveHiddenSeed(world), 'monthly', context.previousYear, context.previousMonth)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
updateNationPower(world, rng);
|
const nationPowerRollCount = updateNationPower(world, rng);
|
||||||
|
options.onNationPowerRollCount?.(nationPowerRollCount);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 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: {
|
export const createTournamentAutoStartHandler = (options: {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
|
getWorld: () => InMemoryTurnWorld | null;
|
||||||
getRedisClient: () => RedisConnector['client'] | null | undefined;
|
getRedisClient: () => RedisConnector['client'] | null | undefined;
|
||||||
getWorldConfig: () => Record<string, unknown> | null | undefined;
|
getWorldConfig: () => Record<string, unknown> | null | undefined;
|
||||||
getTickSeconds: () => number | null | undefined;
|
getNationPowerRollCount: () => number;
|
||||||
|
onTournamentRollConsumed?: (consumed: boolean) => void;
|
||||||
|
now?: () => Date;
|
||||||
}): TurnCalendarHandler => {
|
}): 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -456,8 +456,13 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
|
startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
|
||||||
commandEnv: monthlyCommandEnv,
|
commandEnv: monthlyCommandEnv,
|
||||||
});
|
});
|
||||||
|
let monthlyNationPowerRollCount = snapshot.nations.length;
|
||||||
|
let monthlyTournamentRollConsumed = false;
|
||||||
const monthlyNationStatsHandler = createMonthlyNationStatsHandler({
|
const monthlyNationStatsHandler = createMonthlyNationStatsHandler({
|
||||||
getWorld: () => worldRef,
|
getWorld: () => worldRef,
|
||||||
|
onNationPowerRollCount: (count) => {
|
||||||
|
monthlyNationPowerRollCount = count;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const monthlyDiplomacyHandler = createMonthlyDiplomacyHandler({
|
const monthlyDiplomacyHandler = createMonthlyDiplomacyHandler({
|
||||||
getWorld: () => worldRef,
|
getWorld: () => worldRef,
|
||||||
@@ -483,12 +488,18 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
getWorld: () => worldRef,
|
getWorld: () => worldRef,
|
||||||
getRedisClient: () => redisConnector?.client,
|
getRedisClient: () => redisConnector?.client,
|
||||||
getWorldConfig: () => snapshot.worldConfig ?? null,
|
getWorldConfig: () => snapshot.worldConfig ?? null,
|
||||||
|
getNationPowerRollCount: () => monthlyNationPowerRollCount,
|
||||||
|
getTournamentRollConsumed: () => monthlyTournamentRollConsumed,
|
||||||
});
|
});
|
||||||
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
|
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
|
||||||
profileName: options.profileName ?? options.profile,
|
profileName: options.profileName ?? options.profile,
|
||||||
|
getWorld: () => worldRef,
|
||||||
getRedisClient: () => redisConnector?.client,
|
getRedisClient: () => redisConnector?.client,
|
||||||
getWorldConfig: () => snapshot.worldConfig ?? null,
|
getWorldConfig: () => snapshot.worldConfig ?? null,
|
||||||
getTickSeconds: () => worldRef?.getState().tickSeconds ?? null,
|
getNationPowerRollCount: () => monthlyNationPowerRollCount,
|
||||||
|
onTournamentRollConsumed: (consumed) => {
|
||||||
|
monthlyTournamentRollConsumed = consumed;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const yearbookHandler = createYearbookHandler({
|
const yearbookHandler = createYearbookHandler({
|
||||||
databaseUrl: options.databaseUrl,
|
databaseUrl: options.databaseUrl,
|
||||||
@@ -497,6 +508,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
});
|
});
|
||||||
const calendarHandler = composeCalendarHandlers(
|
const calendarHandler = composeCalendarHandlers(
|
||||||
monthlyEventHandler,
|
monthlyEventHandler,
|
||||||
|
hasEventAction('ProcessIncome') ? null : incomeHandler,
|
||||||
yearbookHandler.handler,
|
yearbookHandler.handler,
|
||||||
monthlyBoundaryPreHandler,
|
monthlyBoundaryPreHandler,
|
||||||
nationTurnMonthlyHandler,
|
nationTurnMonthlyHandler,
|
||||||
@@ -506,10 +518,9 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
monthlyWanderHandler,
|
monthlyWanderHandler,
|
||||||
monthlyNationCountHandler,
|
monthlyNationCountHandler,
|
||||||
options.calendarHandler ?? unification?.handler,
|
options.calendarHandler ?? unification?.handler,
|
||||||
hasEventAction('ProcessIncome') ? null : incomeHandler,
|
tournamentAutoStartHandler,
|
||||||
frontStateHandler,
|
|
||||||
neutralAuctionRegistrar.handler,
|
neutralAuctionRegistrar.handler,
|
||||||
tournamentAutoStartHandler
|
frontStateHandler
|
||||||
);
|
);
|
||||||
const worldOptions: InMemoryTurnWorldOptions = {
|
const worldOptions: InMemoryTurnWorldOptions = {
|
||||||
schedule,
|
schedule,
|
||||||
|
|||||||
@@ -135,4 +135,60 @@ describe('neutral auction monthly registrar', () => {
|
|||||||
]);
|
]);
|
||||||
await registrar.close();
|
await registrar.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('continues after the legacy tournament roll and matches the tail-order fixture', async () => {
|
||||||
|
const worldRef: { current: InMemoryTurnWorld | null } = { current: null };
|
||||||
|
const now = new Date('2026-07-25T12:00:00.000Z');
|
||||||
|
const registrar = await createNeutralAuctionRegistrar({
|
||||||
|
databaseUrl: 'unused://test',
|
||||||
|
profileName: 'test',
|
||||||
|
getWorld: () => worldRef.current,
|
||||||
|
getRedisClient: () => null,
|
||||||
|
getWorldConfig: () => ({ tournamentTrig: true }),
|
||||||
|
getNationPowerRollCount: () => 2,
|
||||||
|
getTournamentRollConsumed: () => true,
|
||||||
|
now: () => now,
|
||||||
|
loadNeutralAuctionCounts: async () => [],
|
||||||
|
});
|
||||||
|
const snapshot = buildSnapshot();
|
||||||
|
snapshot.nations = snapshot.nations.slice(0, 2);
|
||||||
|
snapshot.generals = [buildGeneral(1, 0, 5_000, 7_000), buildGeneral(2, 0, 6_000, 8_000)];
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: 1,
|
||||||
|
currentYear: 193,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('2026-07-25T00:00:00.000Z'),
|
||||||
|
meta: { hiddenSeed: 'monthly-post-tail-2' },
|
||||||
|
};
|
||||||
|
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
calendarHandler: registrar.handler,
|
||||||
|
});
|
||||||
|
worldRef.current = world;
|
||||||
|
|
||||||
|
await world.advanceMonth(new Date('2026-07-25T00:10:00.000Z'));
|
||||||
|
|
||||||
|
expect(world.peekDirtyState().pendingNeutralAuctions).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'BUY_RICE',
|
||||||
|
targetCode: '380',
|
||||||
|
detail: expect.objectContaining({
|
||||||
|
amount: 380,
|
||||||
|
startBidAmount: 300,
|
||||||
|
finishBidAmount: 750,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'SELL_RICE',
|
||||||
|
targetCode: '830',
|
||||||
|
detail: expect.objectContaining({
|
||||||
|
amount: 830,
|
||||||
|
startBidAmount: 990,
|
||||||
|
finishBidAmount: 1_650,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await registrar.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
import type { Nation } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import { createTournamentAutoStartHandler } from '../src/turn/tournamentAutoStart.js';
|
||||||
|
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
|
||||||
|
const buildNation = (id: number): Nation => ({
|
||||||
|
id,
|
||||||
|
name: `국가${id}`,
|
||||||
|
color: '#777777',
|
||||||
|
capitalCityId: null,
|
||||||
|
chiefGeneralId: null,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'che_중립',
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('monthly tournament auto start', () => {
|
||||||
|
it('uses the legacy monthly RNG after nation power rolls and persists the remaining pattern', async () => {
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: 1,
|
||||||
|
currentYear: 193,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('0193-01-01T00:00:00.000Z'),
|
||||||
|
meta: {
|
||||||
|
hiddenSeed: 'monthly-post-tail-2',
|
||||||
|
tournamentPattern: [0, 1, 2, 3],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
scenarioConfig: {
|
||||||
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||||
|
iconPath: '',
|
||||||
|
map: {},
|
||||||
|
const: {},
|
||||||
|
environment: { mapName: 'test', unitSet: 'default' },
|
||||||
|
},
|
||||||
|
map: { id: 'test', name: 'test', cities: [] },
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
generals: [],
|
||||||
|
cities: [],
|
||||||
|
nations: [buildNation(1), buildNation(2)],
|
||||||
|
troops: [],
|
||||||
|
};
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const redis = {
|
||||||
|
get: async (key: string) => values.get(key) ?? null,
|
||||||
|
set: async (key: string, value: string) => {
|
||||||
|
values.set(key, value);
|
||||||
|
return 'OK';
|
||||||
|
},
|
||||||
|
} as unknown as RedisConnector['client'];
|
||||||
|
let world: InMemoryTurnWorld | null = null;
|
||||||
|
const consumed: boolean[] = [];
|
||||||
|
world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
calendarHandler: createTournamentAutoStartHandler({
|
||||||
|
profileName: 'test',
|
||||||
|
getWorld: () => world,
|
||||||
|
getRedisClient: () => redis,
|
||||||
|
getWorldConfig: () => ({ tournamentTrig: true }),
|
||||||
|
getNationPowerRollCount: () => 2,
|
||||||
|
onTournamentRollConsumed: (value) => consumed.push(value),
|
||||||
|
now: () => new Date('2026-07-25T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
|
||||||
|
|
||||||
|
expect(consumed).toEqual([false, true]);
|
||||||
|
expect(JSON.parse(values.get('sammo:test:tournament:state') ?? '{}')).toMatchObject({
|
||||||
|
stage: 1,
|
||||||
|
phase: 0,
|
||||||
|
type: 3,
|
||||||
|
auto: true,
|
||||||
|
openYear: 193,
|
||||||
|
openMonth: 2,
|
||||||
|
termSeconds: 600,
|
||||||
|
nextAt: '2026-07-25T00:10:00.000Z',
|
||||||
|
});
|
||||||
|
expect(world.getState().meta.tournamentPattern).toEqual([0, 1, 2]);
|
||||||
|
expect(world.peekDirtyState().logs).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
text: "<B><b>【대회】</b></><C>설전</> 대회가 개최됩니다! 천하의 <span class='ev_highlight'>책사</span>들을 모집하고 있습니다!",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not consume a tournament roll while a tournament is active', async () => {
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: 1,
|
||||||
|
currentYear: 193,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('0193-01-01T00:00:00.000Z'),
|
||||||
|
meta: { hiddenSeed: 'active-tournament' },
|
||||||
|
};
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
scenarioConfig: {
|
||||||
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||||
|
iconPath: '',
|
||||||
|
map: {},
|
||||||
|
const: {},
|
||||||
|
environment: { mapName: 'test', unitSet: 'default' },
|
||||||
|
},
|
||||||
|
map: { id: 'test', name: 'test', cities: [] },
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
generals: [],
|
||||||
|
cities: [],
|
||||||
|
nations: [buildNation(1)],
|
||||||
|
troops: [],
|
||||||
|
};
|
||||||
|
const redis = {
|
||||||
|
get: async () => JSON.stringify({ stage: 1 }),
|
||||||
|
set: async () => 'OK',
|
||||||
|
} as unknown as RedisConnector['client'];
|
||||||
|
let world: InMemoryTurnWorld | null = null;
|
||||||
|
const consumed: boolean[] = [];
|
||||||
|
world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||||
|
calendarHandler: createTournamentAutoStartHandler({
|
||||||
|
profileName: 'test',
|
||||||
|
getWorld: () => world,
|
||||||
|
getRedisClient: () => redis,
|
||||||
|
getWorldConfig: () => ({ tournamentTrig: true }),
|
||||||
|
getNationPowerRollCount: () => 1,
|
||||||
|
onTournamentRollConsumed: (value) => consumed.push(value),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z'));
|
||||||
|
|
||||||
|
expect(consumed).toEqual([false]);
|
||||||
|
expect(world.peekDirtyState().logs).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user