From 06c7197a1af5dc5c232bd4cc97a26944945fc87c Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 11:07:37 +0000 Subject: [PATCH] feat: add legacy-compatible neutral auctions --- .../src/auction/neutralRegistrar.ts | 163 ++++++++++++++++++ app/game-engine/src/turn/calendarHandlers.ts | 8 +- app/game-engine/src/turn/databaseHooks.ts | 37 ++++ .../src/turn/inMemoryTurnProcessor.ts | 2 +- app/game-engine/src/turn/inMemoryWorld.ts | 29 +++- app/game-engine/src/turn/turnDaemon.ts | 10 ++ app/game-engine/src/turn/types.ts | 10 ++ ...tralAuctionPersistence.integration.test.ts | 151 ++++++++++++++++ .../test/neutralAuctionRegistrar.test.ts | 138 +++++++++++++++ docs/architecture/legacy-engine-auction.md | 35 ++-- packages/logic/src/auction/neutral.ts | 105 +++++++++++ packages/logic/src/index.ts | 1 + .../test/neutralAuctionLegacyParity.test.ts | 107 ++++++++++++ .../test/auctionFlow.test.ts | 148 +++++++++++++++- tools/legacy-oracles/neutral-auction.php | 86 +++++++++ 15 files changed, 999 insertions(+), 31 deletions(-) create mode 100644 app/game-engine/src/auction/neutralRegistrar.ts create mode 100644 app/game-engine/test/neutralAuctionPersistence.integration.test.ts create mode 100644 app/game-engine/test/neutralAuctionRegistrar.test.ts create mode 100644 packages/logic/src/auction/neutral.ts create mode 100644 packages/logic/test/neutralAuctionLegacyParity.test.ts create mode 100644 tools/legacy-oracles/neutral-auction.php diff --git a/app/game-engine/src/auction/neutralRegistrar.ts b/app/game-engine/src/auction/neutralRegistrar.ts new file mode 100644 index 0000000..e2afa44 --- /dev/null +++ b/app/game-engine/src/auction/neutralRegistrar.ts @@ -0,0 +1,163 @@ +import { asRecord } from '@sammo-ts/common'; +import { createGamePostgresConnector, GamePrisma, type RedisConnector } from '@sammo-ts/infra'; +import { buildNeutralResourceAuctionPlan } from '@sammo-ts/logic'; + +import type { TurnCalendarHandler } from '../turn/inMemoryWorld.js'; +import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; + +interface NeutralAuctionCountRow { + type: 'BUY_RICE' | 'SELL_RICE'; + count: bigint | number; +} + +interface TournamentState { + stage?: unknown; +} + +const readFiniteNumber = (value: unknown, fallback: number): number => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return fallback; +}; + +const average = (values: number[]): number => { + if (values.length === 0) { + return 0; + } + return values.reduce((sum, value) => sum + value, 0) / values.length; +}; + +const parseTournamentState = (raw: string | null): TournamentState | null => { + if (!raw) { + return null; + } + try { + const parsed: unknown = JSON.parse(raw); + return asRecord(parsed); + } catch { + return null; + } +}; + +const isTournamentActive = async ( + profileName: string, + redis: RedisConnector['client'] | null | undefined +): Promise => { + if (!redis) { + return false; + } + const state = parseTournamentState(await redis.get(`sammo:${profileName}:tournament:state`)); + return readFiniteNumber(state?.stage, 0) > 0; +}; + +export interface NeutralAuctionRegistrar { + handler: TurnCalendarHandler; + close(): Promise; +} + +export const createNeutralAuctionRegistrar = async (options: { + databaseUrl: string; + profileName: string; + getWorld: () => InMemoryTurnWorld | null; + getRedisClient: () => RedisConnector['client'] | null | undefined; + getWorldConfig: () => Record | null | undefined; + now?: () => Date; + loadNeutralAuctionCounts?: () => Promise; + loadTournamentActive?: () => Promise; +}): Promise => { + const connector = options.loadNeutralAuctionCounts + ? null + : createGamePostgresConnector({ url: options.databaseUrl }); + await connector?.connect(); + const loadNeutralAuctionCounts = + options.loadNeutralAuctionCounts ?? + (() => + connector!.prisma.$queryRaw( + GamePrisma.sql` + SELECT type, count(*) AS count + FROM auction + WHERE host_general_id = 0 + AND type IN ('BUY_RICE'::"AuctionType", 'SELL_RICE'::"AuctionType") + GROUP BY type + ` + )); + + const handler: TurnCalendarHandler = { + onMonthChanged: async (context) => { + const world = options.getWorld(); + if (!world) { + return; + } + const state = world.getState(); + const hiddenSeed = + typeof state.meta.hiddenSeed === 'string' || typeof state.meta.hiddenSeed === 'number' + ? state.meta.hiddenSeed + : state.id; + const eligibleGenerals = world.listGenerals().filter((general) => general.npcState < 2); + const counts = await loadNeutralAuctionCounts(); + const countByType = new Map(counts.map((row) => [row.type, Number(row.count)])); + for (const pending of world.peekDirtyState().pendingNeutralAuctions) { + countByType.set(pending.type, (countByType.get(pending.type) ?? 0) + 1); + } + const worldConfig = asRecord(options.getWorldConfig() ?? {}); + const consumeTournamentRoll = + 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, + consumeTournamentRoll, + averageGold: average(eligibleGenerals.map((general) => general.gold)), + averageRice: average(eligibleGenerals.map((general) => general.rice)), + buyRiceAuctionCount: countByType.get('BUY_RICE') ?? 0, + sellRiceAuctionCount: countByType.get('SELL_RICE') ?? 0, + }); + + const registrationKey = `${context.currentYear}-${String(context.currentMonth).padStart(2, '0')}`; + world.updateWorldMeta({ neutralAuctionRegistrationKey: registrationKey }); + const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60)); + for (const plan of plans) { + const openedAt = options.now?.() ?? new Date(); + const hostResourceName = plan.auctionType === 'BUY_RICE' ? '쌀' : '금'; + world.queueNeutralAuction({ + registrationKey, + type: plan.auctionType, + targetCode: String(plan.amount), + hostGeneralId: 0, + hostName: '상인', + detail: { + title: `${hostResourceName} ${plan.amount} 경매`, + hostName: '상인', + amount: plan.amount, + isReverse: false, + startBidAmount: plan.startBidAmount, + finishBidAmount: plan.finishBidAmount, + neutralRegistrationKey: registrationKey, + seedYear: context.previousYear, + seedMonth: context.previousMonth, + closeTurnCnt: plan.closeTurnCnt, + }, + closeAt: new Date(openedAt.getTime() + plan.closeTurnCnt * turnMinutes * 60_000), + }); + } + }, + }; + + return { + handler, + close: async () => { + await connector?.disconnect(); + }, + }; +}; diff --git a/app/game-engine/src/turn/calendarHandlers.ts b/app/game-engine/src/turn/calendarHandlers.ts index f8a532a..16df3d9 100644 --- a/app/game-engine/src/turn/calendarHandlers.ts +++ b/app/game-engine/src/turn/calendarHandlers.ts @@ -8,14 +8,14 @@ export const composeCalendarHandlers = ( return undefined; } return { - onMonthChanged: (context) => { + onMonthChanged: async (context) => { for (const handler of resolved) { - handler.onMonthChanged?.(context); + await handler.onMonthChanged?.(context); } }, - onYearChanged: (context) => { + onYearChanged: async (context) => { for (const handler of resolved) { - handler.onYearChanged?.(context); + await handler.onYearChanged?.(context); } }, }; diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 77ebba8..3024404 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -339,6 +339,7 @@ export const createDatabaseTurnHooks = async ( createdNations, createdTroops, createdDiplomacy, + pendingNeutralAuctions, } = changes; const reservedTurnChanges = options?.reservedTurns?.peekDirtyState(); @@ -349,6 +350,28 @@ export const createDatabaseTurnHooks = async ( meta: asJson(state.meta), }; const persist = async (prisma: GamePrisma.TransactionClient): Promise => { + let neutralAuctionsToCreate = pendingNeutralAuctions; + if (pendingNeutralAuctions.length > 0) { + const latestRegistrationKey = + pendingNeutralAuctions[pendingNeutralAuctions.length - 1]!.registrationKey; + await prisma.$executeRaw` + SELECT pg_advisory_xact_lock( + hashtext(${'neutral-auction-registration'}), + ${state.id} + ) + `; + const persistedRows = await prisma.$queryRaw>` + SELECT meta + FROM world_state + WHERE id = ${state.id} + FOR UPDATE + `; + const persistedMeta = asRecord(persistedRows[0]?.meta); + if (persistedMeta.neutralAuctionRegistrationKey === latestRegistrationKey) { + neutralAuctionsToCreate = []; + } + } + await prisma.worldState.update({ where: { id: state.id }, data: worldStateUpdate, @@ -425,6 +448,20 @@ export const createDatabaseTurnHooks = async ( ); } + if (neutralAuctionsToCreate.length > 0) { + await prisma.auction.createMany({ + data: neutralAuctionsToCreate.map((auction) => ({ + type: auction.type, + targetCode: auction.targetCode, + hostGeneralId: auction.hostGeneralId, + hostName: auction.hostName, + detail: asJson(auction.detail), + status: 'OPEN', + closeAt: auction.closeAt, + })), + }); + } + const createdIds = new Set(createdGenerals.map((general) => general.id)); const createdNationIds = new Set(createdNations.map((nation) => nation.id)); const createdTroopIds = new Set(createdTroops.map((troop) => troop.id)); diff --git a/app/game-engine/src/turn/inMemoryTurnProcessor.ts b/app/game-engine/src/turn/inMemoryTurnProcessor.ts index 3cababc..e63b268 100644 --- a/app/game-engine/src/turn/inMemoryTurnProcessor.ts +++ b/app/game-engine/src/turn/inMemoryTurnProcessor.ts @@ -96,7 +96,7 @@ export class InMemoryTurnProcessor implements TurnProcessor { partial = true; break; } - this.world.advanceMonth(nextTickTime); + await this.world.advanceMonth(nextTickTime); processedTurns += 1; nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes); } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index bdf02a3..50352b5 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -2,7 +2,7 @@ import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop, import { getNextTurnAt } from '@sammo-ts/logic'; import type { TurnCheckpoint } from '../lifecycle/types.js'; -import type { TurnDiplomacy, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js'; +import type { PendingNeutralAuction, TurnDiplomacy, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js'; import { applyDiplomacyPatch as applyDiplomacyPatchToEntry, buildDefaultDiplomacy, @@ -59,8 +59,8 @@ export interface TurnCalendarContext { export interface TurnCalendarHandler { // 월/연 변경에 따른 후처리를 끼워 넣기 위한 확장 포인트. - onMonthChanged?(context: TurnCalendarContext): void; - onYearChanged?(context: TurnCalendarContext): void; + onMonthChanged?(context: TurnCalendarContext): void | Promise; + onYearChanged?(context: TurnCalendarContext): void | Promise; } export interface InMemoryTurnWorldOptions { @@ -85,6 +85,7 @@ export interface TurnWorldChanges { createdNations: Nation[]; createdTroops: Troop[]; createdDiplomacy: TurnDiplomacy[]; + pendingNeutralAuctions: PendingNeutralAuction[]; } const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => { @@ -250,6 +251,7 @@ export class InMemoryTurnWorld { }> = []; private readonly logs: LogEntryDraft[] = []; private readonly messages: MessageDraft[] = []; + private readonly pendingNeutralAuctions: PendingNeutralAuction[] = []; private readonly scenarioConfig: ScenarioConfig; private checkpoint?: TurnCheckpoint; private state: TurnWorldState; @@ -308,6 +310,14 @@ export class InMemoryTurnWorld { this.logs.push(entry); } + queueNeutralAuction(auction: PendingNeutralAuction): void { + this.pendingNeutralAuctions.push({ + ...auction, + detail: { ...auction.detail }, + closeAt: new Date(auction.closeAt.getTime()), + }); + } + getScenarioConfig(): ScenarioConfig { return this.scenarioConfig; } @@ -681,7 +691,7 @@ export class InMemoryTurnWorld { return nextTurnAt; } - advanceMonth(turnTime: Date): void { + async advanceMonth(turnTime: Date): Promise { const previousYear = this.state.currentYear; const previousMonth = this.state.currentMonth; let nextYear = previousYear; @@ -711,9 +721,9 @@ export class InMemoryTurnWorld { turnTime, }; this.advanceDiplomacyMonth(); - this.calendarHandler?.onMonthChanged?.(context); + await this.calendarHandler?.onMonthChanged?.(context); if (nextYear !== previousYear) { - this.calendarHandler?.onYearChanged?.(context); + await this.calendarHandler?.onYearChanged?.(context); } } @@ -751,6 +761,11 @@ export class InMemoryTurnWorld { const deletedNationSnapshots = this.deletedNationSnapshots.slice(); const logs = this.logs.slice(); const messages = this.messages.slice(); + const pendingNeutralAuctions = this.pendingNeutralAuctions.map((auction) => ({ + ...auction, + detail: { ...auction.detail }, + closeAt: new Date(auction.closeAt.getTime()), + })); return { generals, @@ -768,6 +783,7 @@ export class InMemoryTurnWorld { createdNations, createdTroops, createdDiplomacy, + pendingNeutralAuctions, }; } @@ -791,6 +807,7 @@ export class InMemoryTurnWorld { this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length); this.logs.splice(0, changes.logs.length); this.messages.splice(0, changes.messages.length); + this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length); } consumeDirtyState(): TurnWorldChanges { diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 0b0a9c3..01c5709 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -30,6 +30,7 @@ import { shouldUseAi } from './ai/generalAi.js'; import { createUnificationHandler } from './unificationHandler.js'; import { createAuctionFinalizer } from '../auction/finalizer.js'; import { createAuctionBidder } from '../auction/bidder.js'; +import { createNeutralAuctionRegistrar } from '../auction/neutralRegistrar.js'; import { createTournamentRewardFinalizer } from '../tournament/finalizer.js'; import { createTournamentAutoStartHandler } from './tournamentAutoStart.js'; import { createYearbookHandler } from './yearbookHandler.js'; @@ -132,6 +133,13 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) getWorld: () => worldRef, map: snapshot.map ?? null, }); + const neutralAuctionRegistrar = await createNeutralAuctionRegistrar({ + databaseUrl: options.databaseUrl, + profileName: options.profileName ?? options.profile, + getWorld: () => worldRef, + getRedisClient: () => redisConnector?.client, + getWorldConfig: () => snapshot.worldConfig ?? null, + }); const tournamentAutoStartHandler = createTournamentAutoStartHandler({ profileName: options.profileName ?? options.profile, getRedisClient: () => redisConnector?.client, @@ -148,6 +156,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) nationTurnMonthlyHandler, incomeHandler, frontStateHandler, + neutralAuctionRegistrar.handler, tournamentAutoStartHandler, yearbookHandler.handler ); @@ -327,6 +336,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) const baseClose = close; close = async () => { await baseClose(); + await neutralAuctionRegistrar.close(); if (unification) { await unification.close(); } diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index e3b266d..be68bc4 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -35,6 +35,16 @@ export interface TurnDiplomacy { meta: Record; } +export interface PendingNeutralAuction { + registrationKey: string; + type: 'BUY_RICE' | 'SELL_RICE'; + targetCode: string; + hostGeneralId: 0; + hostName: '상인'; + detail: Record; + closeAt: Date; +} + export interface TurnWorldSnapshot extends Omit< WorldSnapshot, 'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy' diff --git a/app/game-engine/test/neutralAuctionPersistence.integration.test.ts b/app/game-engine/test/neutralAuctionPersistence.integration.test.ts new file mode 100644 index 0000000..6003655 --- /dev/null +++ b/app/game-engine/test/neutralAuctionPersistence.integration.test.ts @@ -0,0 +1,151 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const registrationKey = 'integration-neutral-auction-180-02'; + +integration('neutral auction database persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const deleteFixtureAuctions = async (): Promise => { + await db.$executeRaw( + GamePrisma.sql` + DELETE FROM auction + WHERE detail->>'neutralRegistrationKey' = ${registrationKey} + ` + ); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await deleteFixtureAuctions(); + }); + + afterAll(async () => { + await deleteFixtureAuctions(); + await closeDb?.(); + }); + + it('commits the auction with the month state and skips a duplicate registration key', async () => { + const row = await db.worldState.create({ + data: { + scenarioCode: 'neutral-auction-integration', + currentYear: 180, + currentMonth: 2, + tickSeconds: 600, + config: {}, + meta: { killturn: 24, neutralAuctionRegistrationKey: registrationKey }, + }, + }); + const state: TurnWorldState = { + id: row.id, + currentYear: 180, + currentMonth: 2, + tickSeconds: 600, + lastTurnTime: new Date('2026-07-25T00:10:00.000Z'), + meta: { killturn: 24, neutralAuctionRegistrationKey: registrationKey }, + }; + const snapshot: TurnWorldSnapshot = { + generals: [], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + scenarioConfig: { + stat: { + total: 300, + min: 10, + max: 100, + npcTotal: 150, + npcMax: 50, + npcMin: 10, + chiefMin: 70, + }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const pending = { + registrationKey, + type: 'BUY_RICE' as const, + targetCode: '1150', + hostGeneralId: 0 as const, + hostName: '상인' as const, + detail: { + title: '쌀 1150 경매', + hostName: '상인', + amount: 1150, + isReverse: false, + startBidAmount: 920, + finishBidAmount: 2300, + neutralRegistrationKey: registrationKey, + }, + closeAt: new Date('2026-07-25T00:50:00.000Z'), + }; + const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world); + try { + // DB marker는 아직 없도록 되돌려 첫 flush가 실제 생성을 담당하게 한다. + await db.worldState.update({ where: { id: row.id }, data: { meta: { killturn: 24 } } }); + world.queueNeutralAuction(pending); + await dbHooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect( + await db.auction.count({ + where: { + hostGeneralId: 0, + detail: { path: ['neutralRegistrationKey'], equals: registrationKey }, + }, + }) + ).toBe(1); + + world.queueNeutralAuction(pending); + await dbHooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + expect( + await db.auction.count({ + where: { + hostGeneralId: 0, + detail: { path: ['neutralRegistrationKey'], equals: registrationKey }, + }, + }) + ).toBe(1); + } finally { + await dbHooks.close(); + await db.worldState.delete({ where: { id: row.id } }); + } + }); +}); diff --git a/app/game-engine/test/neutralAuctionRegistrar.test.ts b/app/game-engine/test/neutralAuctionRegistrar.test.ts new file mode 100644 index 0000000..4eeee86 --- /dev/null +++ b/app/game-engine/test/neutralAuctionRegistrar.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; + +import { createNeutralAuctionRegistrar } from '../src/auction/neutralRegistrar.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildGeneral = (id: number, npcState: number, gold: number, rice: number): TurnGeneral => ({ + id, + name: `General_${id}`, + nationId: 1, + cityId: 0, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + turnTime: new Date('0180-01-01T00:00:00Z'), + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + officerLevel: 1, + experience: 0, + dedication: 0, + injury: 0, + gold, + rice, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState, +}); + +const buildSnapshot = (): TurnWorldSnapshot => ({ + generals: [ + buildGeneral(1, 0, 5_432, 7_654), + // ref의 WHERE npc < 2와 같이 평균에서 제외되어야 한다. + buildGeneral(2, 2, 99_999, 99_999), + ], + cities: [], + nations: [1, 2, 3].map((id) => ({ + id, + name: `Nation_${id}`, + color: '#000000', + capitalCityId: null, + chiefGeneralId: id === 1 ? 1 : 0, + gold: 0, + rice: 0, + power: 0, + level: 1, + typeCode: 'che_def', + meta: {}, + })), + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + scenarioConfig: { + stat: { + total: 300, + min: 10, + max: 100, + npcTotal: 150, + npcMax: 50, + npcMin: 10, + chiefMin: 70, + }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, +}); + +describe('neutral auction monthly registrar', () => { + it('uses the previous month seed and queues the legacy amount at the new month boundary', 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: false }), + now: () => now, + loadNeutralAuctionCounts: async () => [], + }); + const state: TurnWorldState = { + id: 1, + currentYear: 180, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('2026-07-25T00:00:00.000Z'), + meta: { hiddenSeed: 'merchant-11', killturn: 24 }, + }; + const world = new InMemoryTurnWorld(state, buildSnapshot(), { + 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.getState()).toMatchObject({ + currentYear: 180, + currentMonth: 2, + meta: { neutralAuctionRegistrationKey: '180-02' }, + }); + expect(world.peekDirtyState().pendingNeutralAuctions).toEqual([ + expect.objectContaining({ + registrationKey: '180-02', + type: 'BUY_RICE', + targetCode: '1150', + hostGeneralId: 0, + hostName: '상인', + closeAt: new Date(now.getTime() + 4 * 10 * 60_000), + detail: expect.objectContaining({ + amount: 1_150, + startBidAmount: 920, + finishBidAmount: 2_300, + seedYear: 180, + seedMonth: 1, + closeTurnCnt: 4, + }), + }), + ]); + await registrar.close(); + }); +}); diff --git a/docs/architecture/legacy-engine-auction.md b/docs/architecture/legacy-engine-auction.md index 381aab0..0bd4b98 100644 --- a/docs/architecture/legacy-engine-auction.md +++ b/docs/architecture/legacy-engine-auction.md @@ -71,27 +71,36 @@ Event actions controlling lifecycle: ## Open Questions / Follow-ups -- `registerAuction()` is called from legacy `func_gamerule.php` during the - monthly game-rule pass. Player-hosted auctions are implemented in core2026; - neutral merchant auction generation remains a separate compatibility item. +- `registerAuction()` is called from legacy `func_gamerule.php` after the + logical month changes. Its RNG is seeded before that change with the previous + year/month, then consumes one power roll per nation and the conditional + tournament roll before auction generation. - Scenario-specific overrides of unique auction timing have not been found. Core2026 currently applies the legacy constants against `tickSeconds`. ## core2026 compatibility implementation (2026-07-25) -| Contract | core2026 path | Status | -| --- | --- | --- | -| Resource auction list/open/bid | `game-frontend/AuctionView.vue` → `game-api/router/auction` → `auctionOpen`/`auctionBid` engine commands | Confirmed by integration test | -| Resource reservation/refund/settlement | opener reserves host gold/rice; bidder reserves and refunds the previous top bid; finalizer transfers or returns resources | Confirmed by integration test | -| Unique list/detail privacy | API replaces host and bidder identity with deterministic aliases and never returns unique-auction general IDs | Confirmed by API implementation and typecheck | -| Unique open | engine validates configured quantity, slot ownership, one active host/item auction, and atomically inserts the host's first bid while deducting `inheritance_point.previous` | Confirmed by integration test | -| Unique bid | row-locked auction, minimum `max(1%, 10)`, conditional point deduction, previous-top-bid refund, slot/top-bid conflict checks | Confirmed by integration test | -| Unique finalization | requested extension, per-slot and total ownership-limit extension, host neutralization, item inventory update | Confirmed by integration test | -| Timer/worker | API updates the Redis timer after open/bid; existing scheduler claims due rows and sends durable `auctionFinalize` commands | Confirmed by integration test for timer seed and durable command finalization | -| Neutral merchant registration | legacy monthly probabilistic `registerAuction()` | Not implemented | +| Contract | core2026 path | Status | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Resource auction list/open/bid | `game-frontend/AuctionView.vue` → `game-api/router/auction` → `auctionOpen`/`auctionBid` engine commands | Confirmed by integration test | +| Resource reservation/refund/settlement | opener reserves host gold/rice; bidder reserves and refunds the previous top bid; finalizer transfers or returns resources | Confirmed by integration test | +| Unique list/detail privacy | API replaces host and bidder identity with deterministic aliases and never returns unique-auction general IDs | Confirmed by API implementation and typecheck | +| Unique open | engine validates configured quantity, slot ownership, one active host/item auction, and atomically inserts the host's first bid while deducting `inheritance_point.previous` | Confirmed by integration test | +| Unique bid | row-locked auction, minimum `max(1%, 10)`, conditional point deduction, previous-top-bid refund, slot/top-bid conflict checks | Confirmed by integration test | +| Unique finalization | requested extension, per-slot and total ownership-limit extension, host neutralization, item inventory update | Confirmed by integration test | +| Timer/worker | API updates the Redis timer after open/bid; existing scheduler claims due rows and sends durable `auctionFinalize` commands | Confirmed by integration test for timer seed and durable command finalization | +| Neutral merchant registration | `neutralRegistrar.ts` runs at the month boundary, reproduces the previous-month seed and preceding RNG consumption, and queues host `0` auctions in the same DB transaction as the month state | Confirmed by PHP differential, engine boundary, PostgreSQL idempotency, and full integration tests | Unique auction mutations deliberately run in the turn-engine database transaction. Point reads use row locks and deductions use conditional updates, so simultaneous requests cannot spend the same previous-season inheritance points twice. A failed open command creates neither an auction nor an orphaned inheritance request marker. + +Neutral registration intentionally counts every historical host-`0` resource +auction, including finished rows, because the legacy probability denominator +does not filter on `finished`. Average gold/rice includes only generals with +`npcState < 2` and is clamped to 1,000..20,000. The month marker and generated +auction rows are persisted together under a PostgreSQL advisory transaction +lock, preventing the same month from producing duplicate merchant auctions +during daemon retry. diff --git a/packages/logic/src/auction/neutral.ts b/packages/logic/src/auction/neutral.ts new file mode 100644 index 0000000..6a29765 --- /dev/null +++ b/packages/logic/src/auction/neutral.ts @@ -0,0 +1,105 @@ +import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; + +import { simpleSerialize } from '../war/utils.js'; + +export type NeutralResourceAuctionType = 'BUY_RICE' | 'SELL_RICE'; + +export interface NeutralAuctionPlanInput { + hiddenSeed: string | number; + seedYear: number; + seedMonth: number; + nationCount: number; + consumeTournamentRoll: boolean; + averageGold: number; + averageRice: number; + buyRiceAuctionCount: number; + sellRiceAuctionCount: number; +} + +export interface NeutralResourceAuctionPlan { + auctionType: NeutralResourceAuctionType; + amount: number; + startBidAmount: number; + finishBidAmount: number; + closeTurnCnt: number; +} + +const clamp = (value: number, min: number, max: number): number => { + if (max < min) { + return min; + } + return Math.min(max, Math.max(min, value)); +}; + +const roundToTens = (value: number): number => Math.round(value / 10) * 10; + +const normalizeCount = (value: number): number => (Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0); + +const normalizeAverage = (value: number): number => clamp(Number.isFinite(value) ? value : 0, 1_000, 20_000); + +const canOpenResourceAuction = (plan: NeutralResourceAuctionPlan): boolean => + plan.closeTurnCnt >= 1 && + plan.closeTurnCnt <= 24 && + plan.amount >= 100 && + plan.amount <= 10_000 && + plan.startBidAmount >= plan.amount * 0.5 && + plan.startBidAmount <= plan.amount * 2 && + plan.finishBidAmount >= plan.amount * 1.1 && + plan.finishBidAmount <= plan.amount * 2 && + plan.finishBidAmount >= plan.startBidAmount * 1.1; + +export const buildNeutralResourceAuctionPlan = (input: NeutralAuctionPlanInput): NeutralResourceAuctionPlan[] => { + // ref TurnExecutionHelper는 날짜를 넘기기 전에 이전 연월로 monthly RNG를 만든다. + const rng = new RandUtil( + new LiteHashDRBG(simpleSerialize(input.hiddenSeed, 'monthly', input.seedYear, input.seedMonth)) + ); + + // ref postUpdateMonthly()의 국가 국력 보정이 registerAuction()보다 먼저 RNG를 소비한다. + for (let nationIdx = 0; nationIdx < normalizeCount(input.nationCount); nationIdx += 1) { + rng.nextRange(0.95, 1.05); + } + // 토너먼트가 없고 자동 개시가 켜진 경우 성공 여부와 무관하게 한 번 소비한다. + if (input.consumeTournamentRoll) { + rng.nextBool(0.4); + } + + const averageGold = normalizeAverage(input.averageGold); + const averageRice = normalizeAverage(input.averageRice); + const result: NeutralResourceAuctionPlan[] = []; + + const buyRiceAuctionCount = normalizeCount(input.buyRiceAuctionCount); + if (rng.nextBool(1 / (buyRiceAuctionCount + 5))) { + const multiplier = rng.nextRangeInt(1, 5); + const rawAmount = (averageRice / 20) * multiplier; + const rawStartBid = clamp((averageGold / 20) * 0.9 * multiplier, rawAmount * 0.8, rawAmount * 1.2); + const plan: NeutralResourceAuctionPlan = { + auctionType: 'BUY_RICE', + amount: roundToTens(rawAmount), + startBidAmount: roundToTens(rawStartBid), + finishBidAmount: roundToTens(rawAmount * 2), + closeTurnCnt: rng.nextRangeInt(3, 12), + }; + if (canOpenResourceAuction(plan)) { + result.push(plan); + } + } + + const sellRiceAuctionCount = normalizeCount(input.sellRiceAuctionCount); + if (rng.nextBool(1 / (sellRiceAuctionCount + 5))) { + const multiplier = rng.nextRangeInt(1, 5); + const rawAmount = (averageGold / 20) * multiplier; + const rawStartBid = clamp((averageRice / 20) * 1.1 * multiplier, rawAmount * 0.8, rawAmount * 1.2); + const plan: NeutralResourceAuctionPlan = { + auctionType: 'SELL_RICE', + amount: roundToTens(rawAmount), + startBidAmount: roundToTens(rawStartBid), + finishBidAmount: roundToTens(rawAmount * 2), + closeTurnCnt: rng.nextRangeInt(3, 12), + }; + if (canOpenResourceAuction(plan)) { + result.push(plan); + } + } + + return result; +}; diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index 9bbc2ed..1c9061b 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -2,6 +2,7 @@ export * from './domain/entities.js'; export type { RandomGenerator } from '@sammo-ts/common'; export * from './actions/index.js'; export * from './auction/alias.js'; +export * from './auction/neutral.js'; export * from './constraints/index.js'; export * from './crewType/index.js'; export * from './diplomacy/index.js'; diff --git a/packages/logic/test/neutralAuctionLegacyParity.test.ts b/packages/logic/test/neutralAuctionLegacyParity.test.ts new file mode 100644 index 0000000..81d5f24 --- /dev/null +++ b/packages/logic/test/neutralAuctionLegacyParity.test.ts @@ -0,0 +1,107 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + buildNeutralResourceAuctionPlan, + type NeutralAuctionPlanInput, + type NeutralResourceAuctionPlan, +} from '../src/auction/neutral.js'; + +const refRoot = process.env.SAMMO_REF_ROOT; +const oraclePath = fileURLToPath(new URL('../../../tools/legacy-oracles/neutral-auction.php', import.meta.url)); + +const runLegacyOracle = (input: NeutralAuctionPlanInput): NeutralResourceAuctionPlan[] => { + if (!refRoot) { + throw new Error('SAMMO_REF_ROOT is required'); + } + const result = spawnSync('php', [oraclePath, refRoot, JSON.stringify(input)], { + encoding: 'utf8', + }); + if (result.status !== 0) { + throw new Error(result.stderr || `legacy oracle exited with ${result.status}`); + } + return JSON.parse(result.stdout) as NeutralResourceAuctionPlan[]; +}; + +const fixtures: Array<{ input: NeutralAuctionPlanInput; expectedTypes: string[] }> = [ + { + expectedTypes: ['BUY_RICE'], + input: { + hiddenSeed: 'merchant-11', + seedYear: 180, + seedMonth: 1, + nationCount: 3, + consumeTournamentRoll: false, + averageGold: 5_432, + averageRice: 7_654, + buyRiceAuctionCount: 0, + sellRiceAuctionCount: 0, + }, + }, + { + expectedTypes: ['BUY_RICE', 'SELL_RICE'], + input: { + hiddenSeed: 'tournament-35', + seedYear: 191, + seedMonth: 12, + nationCount: 8, + consumeTournamentRoll: true, + averageGold: 25_000, + averageRice: 500, + buyRiceAuctionCount: 2, + sellRiceAuctionCount: 4, + }, + }, + { + expectedTypes: [], + input: { + hiddenSeed: 'merchant-32', + seedYear: 203, + seedMonth: 7, + nationCount: 3, + consumeTournamentRoll: false, + averageGold: 5_432, + averageRice: 7_654, + buyRiceAuctionCount: 0, + sellRiceAuctionCount: 0, + }, + }, +]; + +describe.skipIf(!refRoot)('neutral auction legacy PHP differential', () => { + for (const [index, fixture] of fixtures.entries()) { + it(`matches legacy RNG timing and amounts for fixture ${index + 1}`, () => { + const actual = buildNeutralResourceAuctionPlan(fixture.input); + expect(actual).toEqual(runLegacyOracle(fixture.input)); + expect(actual.map((plan) => plan.auctionType)).toEqual(fixture.expectedTypes); + }); + } + + it('matches a seed, month, count, tournament, and average-resource matrix', () => { + let generatedAuctions = 0; + const generatedTypes = new Set(); + for (let index = 0; index < 96; index += 1) { + const input: NeutralAuctionPlanInput = { + hiddenSeed: `neutral-matrix-${index}`, + seedYear: 180 + (index % 17), + seedMonth: (index % 12) + 1, + nationCount: index % 11, + consumeTournamentRoll: index % 2 === 0, + averageGold: 500 + ((index * 1_337) % 25_000), + averageRice: 500 + ((index * 2_111) % 25_000), + buyRiceAuctionCount: index % 9, + sellRiceAuctionCount: index % 13, + }; + const actual = buildNeutralResourceAuctionPlan(input); + expect(actual, `matrix fixture ${index}`).toEqual(runLegacyOracle(input)); + generatedAuctions += actual.length; + for (const plan of actual) { + generatedTypes.add(plan.auctionType); + } + } + expect(generatedAuctions).toBeGreaterThan(0); + expect(generatedTypes).toEqual(new Set(['BUY_RICE', 'SELL_RICE'])); + }); +}); diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index 884b467..4c1d457 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -24,7 +24,7 @@ import { resolveRedisConfigFromEnv, GamePrisma, } from '@sammo-ts/infra'; -import { ItemLoader, ITEM_KEYS } from '@sammo-ts/logic'; +import { buildNeutralResourceAuctionPlan, ItemLoader, ITEM_KEYS } from '@sammo-ts/logic'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); @@ -453,9 +453,9 @@ describe('auction integration flow', () => { const poorClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: poorBidder.accessToken, }); - await expect( - poorClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 500 }) - ).rejects.toThrow('금이 부족합니다.'); + await expect(poorClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 500 })).rejects.toThrow( + '금이 부족합니다.' + ); const updatedAuction = await prisma.auction.findUnique({ where: { id: auction.id }, @@ -576,9 +576,9 @@ describe('auction integration flow', () => { const ownerClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: ownerBidder.accessToken, }); - await expect( - ownerClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 }) - ).rejects.toThrow('이미 다른 유니크를 가지고 있습니다.'); + await expect(ownerClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow( + '이미 다른 유니크를 가지고 있습니다.' + ); const validClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: validBidder.accessToken, @@ -782,4 +782,138 @@ describe('auction integration flow', () => { expect(winner).not.toBeNull(); expect(Object.values(winner!)).toContain(uniquePair.keyA); }, 60_000); + + it('opens the same neutral merchant auctions on the legacy monthly boundary', async () => { + if (!gameConnector) { + throw new Error('runtime not ready'); + } + const prisma = gameConnector.prisma; + if (turnDaemon) { + await turnDaemon.lifecycle.stop('integration-test'); + await turnDaemon.close(); + await turnDaemonLoop; + } + + await prisma.auction.deleteMany({ + where: { + hostGeneralId: 0, + type: { in: ['BUY_RICE', 'SELL_RICE'] }, + }, + }); + const futureTurn = new Date(Date.now() + 60 * 60_000); + await prisma.general.updateMany({ + where: { npcState: { lt: 2 } }, + data: { + gold: 5_432, + rice: 7_654, + turnTime: futureTurn, + }, + }); + const nationCount = await prisma.nation.count(); + let hiddenSeed = ''; + let expected = [] as ReturnType; + for (let index = 0; index < 1_000; index += 1) { + hiddenSeed = `integration-neutral-${index}`; + expected = buildNeutralResourceAuctionPlan({ + hiddenSeed, + seedYear: 180, + seedMonth: 1, + nationCount, + consumeTournamentRoll: false, + averageGold: 5_432, + averageRice: 7_654, + buyRiceAuctionCount: 0, + sellRiceAuctionCount: 0, + }); + if (expected.length > 0) { + break; + } + } + expect(expected.length).toBeGreaterThan(0); + + const worldState = await prisma.worldState.findFirstOrThrow(); + const worldMeta = + worldState.meta && typeof worldState.meta === 'object' && !Array.isArray(worldState.meta) + ? worldState.meta + : {}; + await prisma.worldState.update({ + where: { id: worldState.id }, + data: { + currentYear: 180, + currentMonth: 1, + tickSeconds: 60, + meta: { + ...worldMeta, + hiddenSeed, + lastTurnTime: new Date(Date.now() - 61_000).toISOString(), + neutralAuctionRegistrationKey: null, + }, + }, + }); + + const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; + const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + turnDaemon = await createTurnDaemonRuntime({ + profile: 'che', + profileName: 'che:908', + databaseUrl: gameDatabaseUrl, + gatewayDatabaseUrl, + }); + turnDaemonLoop = turnDaemon.lifecycle.start(); + + const deadline = Date.now() + 10_000; + let rows = await prisma.auction.findMany({ + where: { + hostGeneralId: 0, + type: { in: ['BUY_RICE', 'SELL_RICE'] }, + }, + orderBy: { id: 'asc' }, + }); + while (rows.length < expected.length && Date.now() < deadline) { + await sleep(100); + rows = await prisma.auction.findMany({ + where: { + hostGeneralId: 0, + type: { in: ['BUY_RICE', 'SELL_RICE'] }, + }, + orderBy: { id: 'asc' }, + }); + } + + expect(rows).toHaveLength(expected.length); + for (const [index, plan] of expected.entries()) { + const row = rows[index]!; + const detail = row.detail as Record; + expect(row).toMatchObject({ + type: plan.auctionType, + targetCode: String(plan.amount), + hostGeneralId: 0, + hostName: '상인', + status: 'OPEN', + }); + expect(detail).toMatchObject({ + amount: plan.amount, + startBidAmount: plan.startBidAmount, + finishBidAmount: plan.finishBidAmount, + closeTurnCnt: plan.closeTurnCnt, + seedYear: 180, + seedMonth: 1, + neutralRegistrationKey: '180-02', + }); + expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeGreaterThanOrEqual( + plan.closeTurnCnt * 60_000 - 2_000 + ); + expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeLessThanOrEqual( + plan.closeTurnCnt * 60_000 + 2_000 + ); + } + const persistedState = await prisma.worldState.findUniqueOrThrow({ + where: { id: worldState.id }, + }); + expect(persistedState).toMatchObject({ + currentYear: 180, + currentMonth: 2, + meta: expect.objectContaining({ neutralAuctionRegistrationKey: '180-02' }), + }); + }, 60_000); }); diff --git a/tools/legacy-oracles/neutral-auction.php b/tools/legacy-oracles/neutral-auction.php new file mode 100644 index 0000000..1ff35ea --- /dev/null +++ b/tools/legacy-oracles/neutral-auction.php @@ -0,0 +1,86 @@ + \n"); + exit(2); +} + +$refRoot = rtrim($argv[1], '/'); +require $refRoot . '/vendor/autoload.php'; + +$input = json_decode($argv[2], true, flags: JSON_THROW_ON_ERROR); +$rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( + $input['hiddenSeed'], + 'monthly', + $input['seedYear'], + $input['seedMonth'], +))); + +for ($idx = 0; $idx < max(0, (int)$input['nationCount']); $idx++) { + $rng->nextRange(0.95, 1.05); +} +if ($input['consumeTournamentRoll']) { + $rng->nextBool(0.4); +} + +$avgGold = Util::valueFit($input['averageGold'], 1000, 20000); +$avgRice = Util::valueFit($input['averageRice'], 1000, 20000); +$result = []; +$appendIfOpenable = static function (array $plan) use (&$result): void { + if ($plan['closeTurnCnt'] < 1 || $plan['closeTurnCnt'] > 24) { + return; + } + if ($plan['amount'] < 100 || $plan['amount'] > 10000) { + return; + } + if ($plan['startBidAmount'] < $plan['amount'] * 0.5 || $plan['amount'] * 2 < $plan['startBidAmount']) { + return; + } + if ($plan['finishBidAmount'] < $plan['amount'] * 1.1 || $plan['amount'] * 2 < $plan['finishBidAmount']) { + return; + } + if ($plan['finishBidAmount'] < $plan['startBidAmount'] * 1.1) { + return; + } + $result[] = $plan; +}; + +$buyRiceCount = max(0, (int)$input['buyRiceAuctionCount']); +if ($rng->nextBool(1 / ($buyRiceCount + 5))) { + $mul = $rng->nextRangeInt(1, 5); + $amount = $avgRice / 20 * $mul; + $cost = $avgGold / 20 * 0.9 * $mul; + $topv = $amount * 2; + $cost = Util::valueFit($cost, $amount * 0.8, $amount * 1.2); + $appendIfOpenable([ + 'auctionType' => 'BUY_RICE', + 'amount' => Util::round($amount, -1), + 'startBidAmount' => Util::round($cost, -1), + 'finishBidAmount' => Util::round($topv, -1), + 'closeTurnCnt' => $rng->nextRangeInt(3, 12), + ]); +} + +$sellRiceCount = max(0, (int)$input['sellRiceAuctionCount']); +if ($rng->nextBool(1 / ($sellRiceCount + 5))) { + $mul = $rng->nextRangeInt(1, 5); + $amount = $avgGold / 20 * $mul; + $cost = $avgRice / 20 * 1.1 * $mul; + $topv = $amount * 2; + $cost = Util::valueFit($cost, $amount * 0.8, $amount * 1.2); + $appendIfOpenable([ + 'auctionType' => 'SELL_RICE', + 'amount' => Util::round($amount, -1), + 'startBidAmount' => Util::round($cost, -1), + 'finishBidAmount' => Util::round($topv, -1), + 'closeTurnCnt' => $rng->nextRangeInt(3, 12), + ]); +} + +echo json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), PHP_EOL;