feat: add legacy-compatible neutral auctions

This commit is contained in:
2026-07-25 11:07:37 +00:00
parent 93ae4df519
commit 06c7197a1a
15 changed files with 999 additions and 31 deletions
@@ -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();
});
});