토너먼트 NPC 개방 베팅을 복구하고 장수 DB 저장을 일괄 처리

This commit is contained in:
2026-09-12 08:04:48 +00:00
parent 3ad230beb3
commit 4dac08b916
9 changed files with 581 additions and 62 deletions
+9 -8
View File
@@ -28,6 +28,14 @@ export class CorruptTournamentProjectionError extends Error {
}
}
const zTournamentBet = z
.object({
generalId: z.number().int(),
targetId: z.number().int(),
amount: z.number(),
})
.passthrough();
const zTournamentState = z
.object({
stage: z.number().int(),
@@ -46,6 +54,7 @@ const zTournamentState = z
bettingCloseTick: z.number().int().safe().optional(),
winnerId: z.number().int().optional(),
bettingSettled: z.boolean().optional(),
npcBettingPlan: z.array(zTournamentBet).optional(),
rewardSettled: z.boolean().optional(),
participantsLockedAt: z.string().optional(),
lastError: z.string().optional(),
@@ -111,14 +120,6 @@ const zTournamentMatch = z
})
.passthrough();
const zTournamentBet = z
.object({
generalId: z.number().int(),
targetId: z.number().int(),
amount: z.number(),
})
.passthrough();
const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T>): T | null => {
if (raw === null) {
return null;
+1
View File
@@ -17,6 +17,7 @@ export interface TournamentState {
bettingCloseTick?: number;
winnerId?: number;
bettingSettled?: boolean;
npcBettingPlan?: TournamentBetEntry[];
rewardSettled?: boolean;
participantsLockedAt?: string;
lastError?: string;
+5 -1
View File
@@ -465,8 +465,12 @@ export const applyPreBattleStage = async (
bettingCloseAt: resolveBettingCloseAt(state),
nextAt: resolveNextAt(state),
};
await store.setState(nextState);
// Keep the opening identity across retries, but do not expose stage 6
// until its initial NPC bets have been written successfully.
await store.setState({ ...state, bettingId: nextState.bettingId });
await seedNpcBets({ prisma, store, state: nextState, baseSeed, daemonTransport });
nextState.npcBettingPlan = undefined;
await store.setState(nextState);
return nextState;
}
+56 -23
View File
@@ -696,31 +696,38 @@ const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback
return fallback;
};
export const seedNpcBets = async (options: {
const buildNpcBettingPlan = async (options: {
prisma: TournamentPrismaClient;
store: TournamentStore;
state: TournamentState;
baseSeed: string;
daemonTransport: TurnDaemonTransport;
}): Promise<void> => {
const { prisma, store, state, baseSeed, daemonTransport } = options;
}): Promise<TournamentBetEntry[]> => {
const { prisma, store, state, baseSeed } = options;
const existing = await store.getBettingEntries();
if (existing.length > 0) {
return;
}
const existingBettors = new Set(existing.map((entry) => entry.generalId));
const matches = await store.getMatches();
const candidateIds = Array.from(
new Set(matches.filter((match) => match.stage === 7).flatMap((match) => [match.attackerId, match.defenderId]))
new Set(
matches
.filter((match) => match.stage === 7)
.flatMap((match) => [match.attackerId, match.defenderId])
.filter((id) => id > 0)
)
);
if (candidateIds.length === 0) {
return;
return [];
}
const worldState = await prisma.worldState.findFirst();
const config = asRecord(worldState?.config ?? {});
const constValues = asRecord(config.const ?? config);
const startYear = resolveNumber(constValues, ['startYear', 'startyear'], state.openYear);
const scenarioMeta = asRecord(asRecord(worldState?.meta).scenarioMeta);
const startYear = resolveNumber(
scenarioMeta,
['startYear'],
resolveNumber(constValues, ['startYear', 'startyear'], state.openYear)
);
const currentYear = worldState?.currentYear ?? state.openYear;
const betGold = Math.max(10, Math.floor((3 + currentYear - startYear) * 0.334) * 10);
@@ -733,9 +740,10 @@ export const seedNpcBets = async (options: {
});
const npcBetList = npcList
.map((entry) => asRecord(entry))
.filter((entry) => typeof entry.id === 'number' && typeof entry.gold === 'number');
.filter((entry) => typeof entry.id === 'number' && typeof entry.gold === 'number')
.sort((left, right) => (left.id as number) - (right.id as number));
if (npcBetList.length === 0) {
return;
return [];
}
const rng = createTournamentRng(baseSeed, {
@@ -748,27 +756,52 @@ export const seedNpcBets = async (options: {
extraSeed: `OpenBettingTournament:${state.bettingId ?? 'none'}`,
});
const entries = [...existing];
const entries: TournamentBetEntry[] = [];
for (const npc of npcBetList) {
const targetId = rng.choice(candidateIds);
entries.push({ generalId: npc.id as number, targetId, amount: betGold });
if (!existingBettors.has(npc.id as number)) {
entries.push({ generalId: npc.id as number, targetId, amount: betGold });
}
}
return entries;
};
export const seedNpcBets = async (options: {
prisma: TournamentPrismaClient;
store: TournamentStore;
state: TournamentState;
baseSeed: string;
daemonTransport: TurnDaemonTransport;
}): Promise<void> => {
const { store, state, daemonTransport } = options;
// Persist the selected pool, amounts and targets before any resource command.
// A retry must not reselect after a debit changes an NPC's eligibility.
const plan = state.npcBettingPlan ?? (await buildNpcBettingPlan(options));
if (plan.length === 0) {
return;
}
if (!state.npcBettingPlan) {
const storedState = await store.getState();
if (!storedState) {
throw new Error('Tournament state missing while preparing NPC bets.');
}
await store.setState({ ...storedState, npcBettingPlan: plan });
}
const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`;
await daemonTransport.sendCommand({
type: 'adjustGeneralResources',
requestId: `${requestPrefix}:resources`,
reason: 'tournamentNpcBet',
adjustments: npcBetList.map((npc) => ({
generalId: npc.id as number,
goldDelta: -betGold,
})),
adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })),
});
await daemonTransport.sendCommand({
type: 'adjustGeneralMeta',
requestId: `${requestPrefix}:meta`,
reason: 'tournamentNpcBet',
adjustments: npcBetList.map((npc) => ({
generalId: npc.id as number,
metaDelta: { betgold: betGold },
})),
adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })),
});
await store.setBettingEntries(entries);
const existing = await store.getBettingEntries();
const existingBettors = new Set(existing.map((entry) => entry.generalId));
await store.setBettingEntries(existing.concat(plan.filter((entry) => !existingBettors.has(entry.generalId))));
};
@@ -174,6 +174,7 @@ describe('TournamentStore source revision', () => {
['bettingId', '123'],
['rewardSettled', 'yes'],
['bettingSettled', 1],
['npcBettingPlan', [{ generalId: 3, targetId: 11, amount: '10' }]],
] as const) {
await redis.set(keys.stateKey, JSON.stringify({ ...canonicalState, [field]: value }));
await expect(store.getState(), field).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
@@ -239,5 +240,4 @@ describe('TournamentStore source revision', () => {
await redis.set(keys.matchesKey, JSON.stringify([{ ...match, lastEnergy: { attacker: '90', defender: 0 } }]));
await expect(store.getMatches()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
});
});
+153 -3
View File
@@ -17,6 +17,7 @@ import {
buildBettingPayouts,
resolveBettingCloseAt,
resolveNextAt,
seedNpcBets,
} from '../src/tournament/workerHelpers.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
@@ -149,6 +150,7 @@ const createPrismaMock = (options: {
}>;
baseSeed?: string;
currentYear?: number;
startYear?: number;
}) => {
const applicants = options.applicants ?? [];
const npcs = options.npcs ?? [];
@@ -169,7 +171,11 @@ const createPrismaMock = (options: {
if (isRecord(npcState) && typeof npcState.gte === 'number') {
const gold = isRecord(where.gold) ? where.gold : null;
if (isRecord(gold) && typeof gold.gte === 'number') {
return npcBetting;
const minimumGold = gold.gte;
const minimumNpcState = npcState.gte;
return npcBetting.filter(
(entry) => entry.gold >= minimumGold && entry.npcState >= minimumNpcState
);
}
return npcs;
}
@@ -180,8 +186,8 @@ const createPrismaMock = (options: {
},
worldState: {
findFirst: async () => ({
meta: { hiddenSeed: options.baseSeed ?? 'seed' },
config: { const: { startYear: 1 } },
meta: { hiddenSeed: options.baseSeed ?? 'seed', scenarioMeta: { startYear: options.startYear ?? 1 } },
config: { const: {} },
currentYear: options.currentYear ?? 1,
}),
},
@@ -403,6 +409,150 @@ describe('tournament worker (in-memory)', () => {
});
});
it.each([
[200, 200, 10],
[201, 200, 10],
[203, 200, 20],
[210, 200, 40],
[230, 200, 110],
])('seeds Ref opening bets in year %i from scenario year %i (%i gold)', async (currentYear, startYear, amount) => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening'));
const state = createTournamentState({ stage: 5, openYear: currentYear, bettingId: 123 });
await store.setState(state);
await store.setMatches([
{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 },
{ id: 2, stage: 7, roundIndex: 1, attackerId: -1, defenderId: 13 },
]);
// Existing user bets must not suppress the opening NPC pool.
await store.setBettingEntries([{ generalId: 90, targetId: 11, amount: 100 }]);
const npcBetting = [0, 1, 2, 3, 4, 5, 6].map((npcState) => ({
id: npcState + 1,
name: `NPC${npcState}`,
leadership: 50,
strength: 50,
intel: 50,
meta: {},
npcState,
gold: 500 + amount,
}));
npcBetting.push({ ...npcBetting[2]!, id: 80, gold: 499 + amount });
const prisma = createPrismaMock({ npcBetting, currentYear, startYear });
const commands: TurnDaemonCommand[] = [];
const daemonTransport: TurnDaemonTransport = {
...createNoopDaemonTransport(),
sendCommand: async (command) => {
expect((await store.getState())?.stage).toBe(5);
commands.push(command);
return 'ok';
},
};
const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport);
expect(opened.stage).toBe(6);
const bets = await store.getBettingEntries();
expect(bets[0]).toEqual({ generalId: 90, targetId: 11, amount: 100 });
expect(bets.slice(1).map((bet) => bet.generalId)).toEqual([3, 4, 5, 6, 7]);
expect(bets.slice(1).every((bet) => bet.amount === amount && [11, 12, 13].includes(bet.targetId))).toBe(true);
expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1);
expect(commands).toEqual([
{
type: 'adjustGeneralResources',
requestId: 'tournament:123:npc-bet:resources',
reason: 'tournamentNpcBet',
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, goldDelta: -amount })),
},
{
type: 'adjustGeneralMeta',
requestId: 'tournament:123:npc-bet:meta',
reason: 'tournamentNpcBet',
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, metaDelta: { betgold: amount } })),
},
]);
await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport });
expect(await store.getBettingEntries()).toEqual(bets);
expect(commands).toHaveLength(2);
// A fresh projection with the same seed and DB inputs has the same choices.
await store.setBettingEntries([bets[0]!]);
await seedNpcBets({
prisma,
store,
state: opened,
baseSeed: 'opening-seed',
daemonTransport: createNoopDaemonTransport(),
});
expect(await store.getBettingEntries()).toEqual(bets);
});
it('retries an opening query failure without advancing stage or changing the betting identity', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry'));
const state = createTournamentState({ stage: 5 });
await store.setState(state);
await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]);
const prisma = createPrismaMock({
npcBetting: [
{ id: 3, name: 'n장', leadership: 50, strength: 50, intel: 50, meta: {}, npcState: 2, gold: 1000 },
],
});
const findMany = prisma.general.findMany;
prisma.general.findMany = async () => {
throw new Error('query unavailable');
};
const transport = createNoopDaemonTransport();
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport, () => 1000)).rejects.toThrow(
'query unavailable'
);
const retryState = (await store.getState())!;
expect(retryState).toMatchObject({ stage: 5, bettingId: 1000 });
expect(await store.getBettingEntries()).toEqual([]);
prisma.general.findMany = findMany;
const opened = await applyPreBattleStage(store, prisma, retryState, 'seed', transport, () => 2000);
expect(opened).toMatchObject({ stage: 6, bettingId: 1000 });
expect(await store.getBettingEntries()).toHaveLength(1);
});
it('reuses durable command identities after a partial enqueue failure', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry'));
const state = createTournamentState({ stage: 5, bettingId: 456 });
await store.setState(state);
await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]);
const prisma = createPrismaMock({
npcBetting: [
{ id: 3, name: 'n장', leadership: 50, strength: 50, intel: 50, meta: {}, npcState: 2, gold: 1000 },
],
});
const commands: TurnDaemonCommand[] = [];
let fail = true;
const transport: TurnDaemonTransport = {
...createNoopDaemonTransport(),
sendCommand: async (command) => {
commands.push(command);
if (command.type === 'adjustGeneralMeta' && fail) {
fail = false;
// The accepted debit may already have committed before retry.
prisma.general.findMany = async () => [];
throw new Error('enqueue unavailable');
}
return 'ok';
},
};
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow(
'enqueue unavailable'
);
expect(await store.getState()).toMatchObject({
stage: 5,
npcBettingPlan: [{ generalId: 3, targetId: expect.any(Number), amount: 10 }],
});
expect(await store.getBettingEntries()).toEqual([]);
await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport);
expect(commands.slice(2)).toEqual(commands.slice(0, 2));
expect(commands.map((command) => command.requestId)).toEqual([
'tournament:456:npc-bet:resources',
'tournament:456:npc-bet:meta',
'tournament:456:npc-bet:resources',
'tournament:456:npc-bet:meta',
]);
expect(await store.getBettingEntries()).toHaveLength(1);
});
it('runs all four tournament types and emits enough rank and NPC-betting commands for a top ten', async () => {
for (const type of [
TournamentType.TOTAL,