feat: add legacy-compatible neutral auctions
This commit is contained in:
@@ -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<boolean> => {
|
||||
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<void>;
|
||||
}
|
||||
|
||||
export const createNeutralAuctionRegistrar = async (options: {
|
||||
databaseUrl: string;
|
||||
profileName: string;
|
||||
getWorld: () => InMemoryTurnWorld | null;
|
||||
getRedisClient: () => RedisConnector['client'] | null | undefined;
|
||||
getWorldConfig: () => Record<string, unknown> | null | undefined;
|
||||
now?: () => Date;
|
||||
loadNeutralAuctionCounts?: () => Promise<NeutralAuctionCountRow[]>;
|
||||
loadTournamentActive?: () => Promise<boolean>;
|
||||
}): Promise<NeutralAuctionRegistrar> => {
|
||||
const connector = options.loadNeutralAuctionCounts
|
||||
? null
|
||||
: createGamePostgresConnector({ url: options.databaseUrl });
|
||||
await connector?.connect();
|
||||
const loadNeutralAuctionCounts =
|
||||
options.loadNeutralAuctionCounts ??
|
||||
(() =>
|
||||
connector!.prisma.$queryRaw<NeutralAuctionCountRow[]>(
|
||||
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();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<void> => {
|
||||
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<Array<{ meta: unknown }>>`
|
||||
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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
onYearChanged?(context: TurnCalendarContext): void | Promise<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -35,6 +35,16 @@ export interface TurnDiplomacy {
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PendingNeutralAuction {
|
||||
registrationKey: string;
|
||||
type: 'BUY_RICE' | 'SELL_RICE';
|
||||
targetCode: string;
|
||||
hostGeneralId: 0;
|
||||
hostName: '상인';
|
||||
detail: Record<string, unknown>;
|
||||
closeAt: Date;
|
||||
}
|
||||
|
||||
export interface TurnWorldSnapshot extends Omit<
|
||||
WorldSnapshot,
|
||||
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
|
||||
|
||||
@@ -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<void>) | undefined;
|
||||
|
||||
const deleteFixtureAuctions = async (): Promise<void> => {
|
||||
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 } });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user