토너먼트 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))));
};