From de006181b73383c25be35b6fe4b2310d5f7c3aa8 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 22:33:36 +0000 Subject: [PATCH] fix(engine): align monthly post tail ordering --- .../src/auction/neutralRegistrar.ts | 13 +- .../src/turn/monthlyNationStatsHandler.ts | 10 +- .../src/turn/tournamentAutoStart.ts | 165 +++++++++++++++++- app/game-engine/src/turn/turnDaemon.ts | 19 +- .../test/neutralAuctionRegistrar.test.ts | 56 ++++++ .../test/tournamentAutoStart.test.ts | 146 ++++++++++++++++ 6 files changed, 392 insertions(+), 17 deletions(-) create mode 100644 app/game-engine/test/tournamentAutoStart.test.ts diff --git a/app/game-engine/src/auction/neutralRegistrar.ts b/app/game-engine/src/auction/neutralRegistrar.ts index e2afa44..df6b3e7 100644 --- a/app/game-engine/src/auction/neutralRegistrar.ts +++ b/app/game-engine/src/auction/neutralRegistrar.ts @@ -71,6 +71,8 @@ export const createNeutralAuctionRegistrar = async (options: { now?: () => Date; loadNeutralAuctionCounts?: () => Promise; loadTournamentActive?: () => Promise; + getNationPowerRollCount?: () => number; + getTournamentRollConsumed?: () => boolean; }): Promise => { 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)), diff --git a/app/game-engine/src/turn/monthlyNationStatsHandler.ts b/app/game-engine/src/turn/monthlyNationStatsHandler.ts index a63a0e2..453ffc6 100644 --- a/app/game-engine/src/turn/monthlyNationStatsHandler.ts +++ b/app/game-engine/src/turn/monthlyNationStatsHandler.ts @@ -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(); 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); }, }); diff --git a/app/game-engine/src/turn/tournamentAutoStart.ts b/app/game-engine/src/turn/tournamentAutoStart.ts index 57993d4..750d295 100644 --- a/app/game-engine/src/turn/tournamentAutoStart.ts +++ b/app/game-engine/src/turn/tournamentAutoStart.ts @@ -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 = (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): 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 | null | undefined; - getTickSeconds: () => number | null | undefined; + getNationPowerRollCount: () => number; + onTournamentRollConsumed?: (consumed: boolean) => void; + now?: () => Date; }): TurnCalendarHandler => { - return createCommonTournamentAutoStartHandler(options); -}; \ No newline at end of file + 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(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 ? `황제 ${opener}의 명으로 ` : ''; + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: `【대회】${openerText}${typeText} 대회가 개최됩니다! 천하의 ${generalTypeText}들을 모집하고 있습니다!`, + format: LogFormat.EVENT_YEAR_MONTH, + }); + }, + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index e1527aa..aa3a3b2 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -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, diff --git a/app/game-engine/test/neutralAuctionRegistrar.test.ts b/app/game-engine/test/neutralAuctionRegistrar.test.ts index 4eeee86..9bffc3e 100644 --- a/app/game-engine/test/neutralAuctionRegistrar.test.ts +++ b/app/game-engine/test/neutralAuctionRegistrar.test.ts @@ -135,4 +135,60 @@ describe('neutral auction monthly registrar', () => { ]); 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(); + }); }); diff --git a/app/game-engine/test/tournamentAutoStart.test.ts b/app/game-engine/test/tournamentAutoStart.test.ts new file mode 100644 index 0000000..7a7ced0 --- /dev/null +++ b/app/game-engine/test/tournamentAutoStart.test.ts @@ -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(); + 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: "【대회】설전 대회가 개최됩니다! 천하의 책사들을 모집하고 있습니다!", + }), + ]); + }); + + 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([]); + }); +});