토너먼트 NPC 개방 베팅을 복구하고 장수 DB 저장을 일괄 처리
This commit is contained in:
@@ -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
|
const zTournamentState = z
|
||||||
.object({
|
.object({
|
||||||
stage: z.number().int(),
|
stage: z.number().int(),
|
||||||
@@ -46,6 +54,7 @@ const zTournamentState = z
|
|||||||
bettingCloseTick: z.number().int().safe().optional(),
|
bettingCloseTick: z.number().int().safe().optional(),
|
||||||
winnerId: z.number().int().optional(),
|
winnerId: z.number().int().optional(),
|
||||||
bettingSettled: z.boolean().optional(),
|
bettingSettled: z.boolean().optional(),
|
||||||
|
npcBettingPlan: z.array(zTournamentBet).optional(),
|
||||||
rewardSettled: z.boolean().optional(),
|
rewardSettled: z.boolean().optional(),
|
||||||
participantsLockedAt: z.string().optional(),
|
participantsLockedAt: z.string().optional(),
|
||||||
lastError: z.string().optional(),
|
lastError: z.string().optional(),
|
||||||
@@ -111,14 +120,6 @@ const zTournamentMatch = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.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 => {
|
const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T>): T | null => {
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface TournamentState {
|
|||||||
bettingCloseTick?: number;
|
bettingCloseTick?: number;
|
||||||
winnerId?: number;
|
winnerId?: number;
|
||||||
bettingSettled?: boolean;
|
bettingSettled?: boolean;
|
||||||
|
npcBettingPlan?: TournamentBetEntry[];
|
||||||
rewardSettled?: boolean;
|
rewardSettled?: boolean;
|
||||||
participantsLockedAt?: string;
|
participantsLockedAt?: string;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
|
|||||||
@@ -465,8 +465,12 @@ export const applyPreBattleStage = async (
|
|||||||
bettingCloseAt: resolveBettingCloseAt(state),
|
bettingCloseAt: resolveBettingCloseAt(state),
|
||||||
nextAt: resolveNextAt(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 });
|
await seedNpcBets({ prisma, store, state: nextState, baseSeed, daemonTransport });
|
||||||
|
nextState.npcBettingPlan = undefined;
|
||||||
|
await store.setState(nextState);
|
||||||
return nextState;
|
return nextState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -696,31 +696,38 @@ const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback
|
|||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const seedNpcBets = async (options: {
|
const buildNpcBettingPlan = async (options: {
|
||||||
prisma: TournamentPrismaClient;
|
prisma: TournamentPrismaClient;
|
||||||
store: TournamentStore;
|
store: TournamentStore;
|
||||||
state: TournamentState;
|
state: TournamentState;
|
||||||
baseSeed: string;
|
baseSeed: string;
|
||||||
daemonTransport: TurnDaemonTransport;
|
}): Promise<TournamentBetEntry[]> => {
|
||||||
}): Promise<void> => {
|
const { prisma, store, state, baseSeed } = options;
|
||||||
const { prisma, store, state, baseSeed, daemonTransport } = options;
|
|
||||||
const existing = await store.getBettingEntries();
|
const existing = await store.getBettingEntries();
|
||||||
if (existing.length > 0) {
|
const existingBettors = new Set(existing.map((entry) => entry.generalId));
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = await store.getMatches();
|
const matches = await store.getMatches();
|
||||||
const candidateIds = Array.from(
|
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) {
|
if (candidateIds.length === 0) {
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const worldState = await prisma.worldState.findFirst();
|
const worldState = await prisma.worldState.findFirst();
|
||||||
const config = asRecord(worldState?.config ?? {});
|
const config = asRecord(worldState?.config ?? {});
|
||||||
const constValues = asRecord(config.const ?? 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 currentYear = worldState?.currentYear ?? state.openYear;
|
||||||
const betGold = Math.max(10, Math.floor((3 + currentYear - startYear) * 0.334) * 10);
|
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
|
const npcBetList = npcList
|
||||||
.map((entry) => asRecord(entry))
|
.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) {
|
if (npcBetList.length === 0) {
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const rng = createTournamentRng(baseSeed, {
|
const rng = createTournamentRng(baseSeed, {
|
||||||
@@ -748,27 +756,52 @@ export const seedNpcBets = async (options: {
|
|||||||
extraSeed: `OpenBettingTournament:${state.bettingId ?? 'none'}`,
|
extraSeed: `OpenBettingTournament:${state.bettingId ?? 'none'}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const entries = [...existing];
|
const entries: TournamentBetEntry[] = [];
|
||||||
for (const npc of npcBetList) {
|
for (const npc of npcBetList) {
|
||||||
const targetId = rng.choice(candidateIds);
|
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({
|
await daemonTransport.sendCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'adjustGeneralResources',
|
||||||
|
requestId: `${requestPrefix}:resources`,
|
||||||
reason: 'tournamentNpcBet',
|
reason: 'tournamentNpcBet',
|
||||||
adjustments: npcBetList.map((npc) => ({
|
adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })),
|
||||||
generalId: npc.id as number,
|
|
||||||
goldDelta: -betGold,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
await daemonTransport.sendCommand({
|
await daemonTransport.sendCommand({
|
||||||
type: 'adjustGeneralMeta',
|
type: 'adjustGeneralMeta',
|
||||||
|
requestId: `${requestPrefix}:meta`,
|
||||||
reason: 'tournamentNpcBet',
|
reason: 'tournamentNpcBet',
|
||||||
adjustments: npcBetList.map((npc) => ({
|
adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })),
|
||||||
generalId: npc.id as number,
|
|
||||||
metaDelta: { betgold: betGold },
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
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'],
|
['bettingId', '123'],
|
||||||
['rewardSettled', 'yes'],
|
['rewardSettled', 'yes'],
|
||||||
['bettingSettled', 1],
|
['bettingSettled', 1],
|
||||||
|
['npcBettingPlan', [{ generalId: 3, targetId: 11, amount: '10' }]],
|
||||||
] as const) {
|
] as const) {
|
||||||
await redis.set(keys.stateKey, JSON.stringify({ ...canonicalState, [field]: value }));
|
await redis.set(keys.stateKey, JSON.stringify({ ...canonicalState, [field]: value }));
|
||||||
await expect(store.getState(), field).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
|
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 redis.set(keys.matchesKey, JSON.stringify([{ ...match, lastEnergy: { attacker: '90', defender: 0 } }]));
|
||||||
await expect(store.getMatches()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
|
await expect(store.getMatches()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
buildBettingPayouts,
|
buildBettingPayouts,
|
||||||
resolveBettingCloseAt,
|
resolveBettingCloseAt,
|
||||||
resolveNextAt,
|
resolveNextAt,
|
||||||
|
seedNpcBets,
|
||||||
} from '../src/tournament/workerHelpers.js';
|
} from '../src/tournament/workerHelpers.js';
|
||||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||||
|
|
||||||
@@ -149,6 +150,7 @@ const createPrismaMock = (options: {
|
|||||||
}>;
|
}>;
|
||||||
baseSeed?: string;
|
baseSeed?: string;
|
||||||
currentYear?: number;
|
currentYear?: number;
|
||||||
|
startYear?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const applicants = options.applicants ?? [];
|
const applicants = options.applicants ?? [];
|
||||||
const npcs = options.npcs ?? [];
|
const npcs = options.npcs ?? [];
|
||||||
@@ -169,7 +171,11 @@ const createPrismaMock = (options: {
|
|||||||
if (isRecord(npcState) && typeof npcState.gte === 'number') {
|
if (isRecord(npcState) && typeof npcState.gte === 'number') {
|
||||||
const gold = isRecord(where.gold) ? where.gold : null;
|
const gold = isRecord(where.gold) ? where.gold : null;
|
||||||
if (isRecord(gold) && typeof gold.gte === 'number') {
|
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;
|
return npcs;
|
||||||
}
|
}
|
||||||
@@ -180,8 +186,8 @@ const createPrismaMock = (options: {
|
|||||||
},
|
},
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: async () => ({
|
findFirst: async () => ({
|
||||||
meta: { hiddenSeed: options.baseSeed ?? 'seed' },
|
meta: { hiddenSeed: options.baseSeed ?? 'seed', scenarioMeta: { startYear: options.startYear ?? 1 } },
|
||||||
config: { const: { startYear: 1 } },
|
config: { const: {} },
|
||||||
currentYear: options.currentYear ?? 1,
|
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 () => {
|
it('runs all four tournament types and emits enough rank and NPC-betting commands for a top ten', async () => {
|
||||||
for (const type of [
|
for (const type of [
|
||||||
TournamentType.TOTAL,
|
TournamentType.TOTAL,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js';
|
||||||
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
import { areSeasonRecordsFinalized } from './seasonRecords.js';
|
||||||
import {
|
import {
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
acquireGameSchemaAdvisoryXactLock,
|
||||||
@@ -1791,32 +1792,26 @@ export const createDatabaseTurnHooks = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...generals
|
persistGeneralUpdates(
|
||||||
.filter((general) => !createdIds.has(general.id))
|
prisma,
|
||||||
.map((general) =>
|
generals
|
||||||
prisma.general.update({
|
.filter((general) => !createdIds.has(general.id))
|
||||||
where: { id: general.id },
|
.map((general) => ({ id: general.id, data: buildGeneralUpdate(general) }))
|
||||||
data: buildGeneralUpdate(general),
|
),
|
||||||
})
|
persistGeneralAccessScores(
|
||||||
),
|
prisma,
|
||||||
...generals
|
generals
|
||||||
.filter(
|
.filter(
|
||||||
(general) =>
|
(general) =>
|
||||||
typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
|
typeof general.refreshScoreTotal === 'number' &&
|
||||||
)
|
Number.isFinite(general.refreshScoreTotal)
|
||||||
.map((general) =>
|
)
|
||||||
prisma.generalAccessLog.upsert({
|
.map((general) => ({
|
||||||
where: { generalId: general.id },
|
generalId: general.id,
|
||||||
update: {
|
userId: general.userId ?? null,
|
||||||
refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0),
|
refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0),
|
||||||
},
|
}))
|
||||||
create: {
|
),
|
||||||
generalId: general.id,
|
|
||||||
userId: general.userId ?? null,
|
|
||||||
refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
),
|
|
||||||
...cities.map((city) =>
|
...cities.map((city) =>
|
||||||
prisma.city.update({
|
prisma.city.update({
|
||||||
where: { id: city.id },
|
where: { id: city.id },
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { GamePrisma, type TurnEngineGeneralUpdateInput } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
// Fixed schema identifiers only; all row values remain bound parameters.
|
||||||
|
const columns = {
|
||||||
|
userId: 'user_id',
|
||||||
|
name: 'name',
|
||||||
|
nationId: 'nation_id',
|
||||||
|
cityId: 'city_id',
|
||||||
|
troopId: 'troop_id',
|
||||||
|
leadership: 'leadership',
|
||||||
|
strength: 'strength',
|
||||||
|
intel: 'intel',
|
||||||
|
experience: 'experience',
|
||||||
|
dedication: 'dedication',
|
||||||
|
officerLevel: 'officer_level',
|
||||||
|
injury: 'injury',
|
||||||
|
gold: 'gold',
|
||||||
|
rice: 'rice',
|
||||||
|
crew: 'crew',
|
||||||
|
crewTypeId: 'crew_type_id',
|
||||||
|
train: 'train',
|
||||||
|
atmos: 'atmos',
|
||||||
|
age: 'age',
|
||||||
|
npcState: 'npc_state',
|
||||||
|
affinity: 'affinity',
|
||||||
|
bornYear: 'born_year',
|
||||||
|
deadYear: 'dead_year',
|
||||||
|
picture: 'picture',
|
||||||
|
imageServer: 'image_server',
|
||||||
|
startAge: 'start_age',
|
||||||
|
horseCode: 'horse_code',
|
||||||
|
weaponCode: 'weapon_code',
|
||||||
|
bookCode: 'book_code',
|
||||||
|
itemCode: 'item_code',
|
||||||
|
personalCode: 'personal_code',
|
||||||
|
specialCode: 'special_code',
|
||||||
|
special2Code: 'special2_code',
|
||||||
|
lastTurn: 'last_turn',
|
||||||
|
penalty: 'penalty',
|
||||||
|
meta: 'meta',
|
||||||
|
turnTime: 'turn_time',
|
||||||
|
turnTick: 'turn_tick',
|
||||||
|
recentWarTime: 'recent_war_time',
|
||||||
|
recentWarTick: 'recent_war_tick',
|
||||||
|
} satisfies Record<keyof TurnEngineGeneralUpdateInput, string>;
|
||||||
|
|
||||||
|
export const GENERAL_UPDATE_BATCH_SIZE = 500;
|
||||||
|
|
||||||
|
export const persistGeneralUpdates = async (
|
||||||
|
database: { $executeRaw(query: GamePrisma.Sql): Promise<number> },
|
||||||
|
updates: Array<{ id: number; data: TurnEngineGeneralUpdateInput }>
|
||||||
|
): Promise<void> => {
|
||||||
|
if (new Set(updates.map((entry) => entry.id)).size !== updates.length) {
|
||||||
|
throw new Error('Duplicate general IDs in persistence batch.');
|
||||||
|
}
|
||||||
|
const assignments = Object.entries(columns).map(([key, column]) => {
|
||||||
|
const identifier = GamePrisma.raw(`"${column}"`);
|
||||||
|
// Prisma omits undefined optional birth/death years instead of clearing them.
|
||||||
|
return key === 'bornYear' || key === 'deadYear'
|
||||||
|
? GamePrisma.sql`${identifier} = COALESCE(source.${identifier}, target.${identifier})`
|
||||||
|
: GamePrisma.sql`${identifier} = source.${identifier}`;
|
||||||
|
});
|
||||||
|
for (let offset = 0; offset < updates.length; offset += GENERAL_UPDATE_BATCH_SIZE) {
|
||||||
|
const batch = updates.slice(offset, offset + GENERAL_UPDATE_BATCH_SIZE);
|
||||||
|
const rows = batch.map(({ id, data }) => ({
|
||||||
|
id,
|
||||||
|
...Object.fromEntries(
|
||||||
|
(Object.keys(columns) as Array<keyof typeof columns>).map((key) => [columns[key], data[key]])
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
const payload = JSON.stringify(rows, (_key, value: unknown) =>
|
||||||
|
typeof value === 'bigint' ? value.toString() : value
|
||||||
|
);
|
||||||
|
const updated = await database.$executeRaw(GamePrisma.sql`
|
||||||
|
UPDATE "general" AS target
|
||||||
|
SET ${GamePrisma.join(assignments)}
|
||||||
|
FROM jsonb_populate_recordset(NULL::"general", ${payload}::jsonb) AS source
|
||||||
|
WHERE target."id" = source."id"
|
||||||
|
`);
|
||||||
|
// Preserve Prisma update's missing-row failure and transaction rollback.
|
||||||
|
if (updated !== batch.length) {
|
||||||
|
throw new Error(`General persistence batch expected ${batch.length} rows, updated ${updated}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const persistGeneralAccessScores = async (
|
||||||
|
database: { $executeRaw(query: GamePrisma.Sql): Promise<number> },
|
||||||
|
updates: Array<{ generalId: number; userId: string | null; refreshScoreTotal: number }>
|
||||||
|
): Promise<void> => {
|
||||||
|
for (let offset = 0; offset < updates.length; offset += GENERAL_UPDATE_BATCH_SIZE) {
|
||||||
|
const batch = updates.slice(offset, offset + GENERAL_UPDATE_BATCH_SIZE);
|
||||||
|
await database.$executeRaw(GamePrisma.sql`
|
||||||
|
INSERT INTO "general_access_log" ("general_id", "user_id", "refresh_score_total")
|
||||||
|
VALUES ${GamePrisma.join(batch.map((row) => GamePrisma.sql`(${row.generalId}, ${row.userId}, ${row.refreshScoreTotal})`))}
|
||||||
|
ON CONFLICT ("general_id") DO UPDATE
|
||||||
|
SET "refresh_score_total" = EXCLUDED."refresh_score_total"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { createGamePostgresConnector, type GamePrismaClient, type TurnEngineGeneralUpdateInput } from '@sammo-ts/infra';
|
||||||
|
import {
|
||||||
|
GENERAL_UPDATE_BATCH_SIZE,
|
||||||
|
persistGeneralAccessScores,
|
||||||
|
persistGeneralUpdates,
|
||||||
|
} from '../src/turn/generalBatchPersistence.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
|
const firstId = 995_000;
|
||||||
|
const at = new Date('0200-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
const dataFor = (index: number): TurnEngineGeneralUpdateInput => ({
|
||||||
|
userId: null,
|
||||||
|
name: `n장 ${index} ' "`,
|
||||||
|
nationId: 0,
|
||||||
|
cityId: 0,
|
||||||
|
troopId: 0,
|
||||||
|
leadership: 51,
|
||||||
|
strength: 52,
|
||||||
|
intel: 53,
|
||||||
|
experience: 123,
|
||||||
|
dedication: 456,
|
||||||
|
officerLevel: 0,
|
||||||
|
injury: 0,
|
||||||
|
gold: 700 + index,
|
||||||
|
rice: 1200 + index,
|
||||||
|
crew: 100,
|
||||||
|
crewTypeId: 1100,
|
||||||
|
train: 70,
|
||||||
|
atmos: 80,
|
||||||
|
age: 21,
|
||||||
|
npcState: 2,
|
||||||
|
affinity: null,
|
||||||
|
bornYear: 179,
|
||||||
|
deadYear: 299,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
startAge: 20,
|
||||||
|
horseCode: 'None',
|
||||||
|
weaponCode: 'None',
|
||||||
|
bookCode: 'None',
|
||||||
|
itemCode: 'None',
|
||||||
|
personalCode: 'None',
|
||||||
|
specialCode: 'None',
|
||||||
|
special2Code: 'None',
|
||||||
|
lastTurn: { command: '휴식', args: { text: "한글 ' 문자열" } },
|
||||||
|
penalty: {},
|
||||||
|
meta: { betgold: index * 10, nested: { preserved: true }, list: [1, null, '값'] },
|
||||||
|
turnTime: at,
|
||||||
|
turnTick: 9_007_199_254_740_993n,
|
||||||
|
recentWarTime: index % 2 ? at : null,
|
||||||
|
recentWarTick: index % 2 ? 123n : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
integration('general batch persistence', () => {
|
||||||
|
let db: GamePrismaClient;
|
||||||
|
let close: () => Promise<void>;
|
||||||
|
beforeAll(async () => {
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
close = () => connector.disconnect();
|
||||||
|
await db.general.createMany({
|
||||||
|
data: Array.from({ length: GENERAL_UPDATE_BATCH_SIZE + 1 }, (_, index) => ({
|
||||||
|
id: firstId + index,
|
||||||
|
name: `original${index}`,
|
||||||
|
turnTime: at,
|
||||||
|
createdAt: at,
|
||||||
|
updatedAt: at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.generalAccessLog.deleteMany({
|
||||||
|
where: { generalId: { gte: firstId, lte: firstId + GENERAL_UPDATE_BATCH_SIZE } },
|
||||||
|
});
|
||||||
|
await db.general.deleteMany({ where: { id: { gte: firstId, lte: firstId + GENERAL_UPDATE_BATCH_SIZE } } });
|
||||||
|
await close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches Prisma row updates including JSON, nulls, dates, bigint and untouched columns', async () => {
|
||||||
|
const data = dataFor(1);
|
||||||
|
await db.general.update({ where: { id: firstId }, data });
|
||||||
|
let writes = 0;
|
||||||
|
await db.$transaction(async (transaction) => {
|
||||||
|
await persistGeneralUpdates(
|
||||||
|
{
|
||||||
|
$executeRaw: (query) => {
|
||||||
|
writes += 1;
|
||||||
|
return transaction.$executeRaw(query);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[{ id: firstId + 1, data }]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const { id: _leftId, ...left } = await db.general.findUniqueOrThrow({ where: { id: firstId } });
|
||||||
|
const { id: _rightId, ...right } = await db.general.findUniqueOrThrow({ where: { id: firstId + 1 } });
|
||||||
|
expect(right).toEqual(left);
|
||||||
|
expect(writes).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes 501 distinct generals in two SQL statements and preserves omitted optional years', async () => {
|
||||||
|
const updates = Array.from({ length: GENERAL_UPDATE_BATCH_SIZE + 1 }, (_, index) => ({
|
||||||
|
id: firstId + index,
|
||||||
|
data: { ...dataFor(index), bornYear: undefined, deadYear: undefined },
|
||||||
|
}));
|
||||||
|
let writes = 0;
|
||||||
|
await db.$transaction(async (transaction) => {
|
||||||
|
await persistGeneralUpdates(
|
||||||
|
{
|
||||||
|
$executeRaw: (query) => {
|
||||||
|
writes += 1;
|
||||||
|
return transaction.$executeRaw(query);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updates
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(writes).toBe(2);
|
||||||
|
const rows = await db.general.findMany({
|
||||||
|
where: { id: { gte: firstId, lte: firstId + GENERAL_UPDATE_BATCH_SIZE } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
gold: 700 + index,
|
||||||
|
rice: 1200 + index,
|
||||||
|
meta: dataFor(index).meta,
|
||||||
|
bornYear: index < 2 ? 179 : 180,
|
||||||
|
deadYear: index < 2 ? 299 : 300,
|
||||||
|
createdAt: at,
|
||||||
|
updatedAt: at,
|
||||||
|
turnTick: 9_007_199_254_740_993n,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back the entire transaction when a target is missing', async () => {
|
||||||
|
const before = await db.general.findUniqueOrThrow({ where: { id: firstId } });
|
||||||
|
await expect(
|
||||||
|
db.$transaction(async (transaction) =>
|
||||||
|
persistGeneralUpdates(transaction, [
|
||||||
|
{ id: firstId, data: dataFor(999) },
|
||||||
|
{ id: firstId - 1, data: dataFor(999) },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
).rejects.toThrow('expected 2 rows, updated 1');
|
||||||
|
expect(await db.general.findUniqueOrThrow({ where: { id: firstId } })).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves unique constraints and rolls back conflicting ownership', async () => {
|
||||||
|
const before = await db.general.findMany({
|
||||||
|
where: { id: { in: [firstId, firstId + 1] } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
db.$transaction(async (transaction) =>
|
||||||
|
persistGeneralUpdates(transaction, [
|
||||||
|
{ id: firstId, data: { ...dataFor(1), userId: 'batch-collision' } },
|
||||||
|
{ id: firstId + 1, data: { ...dataFor(2), userId: 'batch-collision' } },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(
|
||||||
|
await db.general.findMany({ where: { id: { in: [firstId, firstId + 1] } }, orderBy: { id: 'asc' } })
|
||||||
|
).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('batch upserts access totals while preserving existing actor and activity fields', async () => {
|
||||||
|
await db.generalAccessLog.create({
|
||||||
|
data: {
|
||||||
|
generalId: firstId,
|
||||||
|
userId: 'original-owner',
|
||||||
|
lastRefresh: at,
|
||||||
|
lastActionAt: at,
|
||||||
|
refresh: 3,
|
||||||
|
refreshTotal: 7,
|
||||||
|
refreshScore: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const updates = Array.from({ length: GENERAL_UPDATE_BATCH_SIZE + 1 }, (_, index) => ({
|
||||||
|
generalId: firstId + index,
|
||||||
|
userId: null,
|
||||||
|
refreshScoreTotal: index + 10,
|
||||||
|
}));
|
||||||
|
let writes = 0;
|
||||||
|
await db.$transaction(async (transaction) => {
|
||||||
|
await persistGeneralAccessScores(
|
||||||
|
{
|
||||||
|
$executeRaw: (query) => {
|
||||||
|
writes += 1;
|
||||||
|
return transaction.$executeRaw(query);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updates
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(writes).toBe(2);
|
||||||
|
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: firstId } })).toMatchObject({
|
||||||
|
userId: 'original-owner',
|
||||||
|
lastRefresh: at,
|
||||||
|
lastActionAt: at,
|
||||||
|
refresh: 3,
|
||||||
|
refreshTotal: 7,
|
||||||
|
refreshScore: 5,
|
||||||
|
refreshScoreTotal: 10,
|
||||||
|
});
|
||||||
|
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: firstId + 500 } })).toMatchObject({
|
||||||
|
userId: null,
|
||||||
|
lastRefresh: null,
|
||||||
|
refresh: 0,
|
||||||
|
refreshScoreTotal: 510,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not write an empty batch and rejects duplicate IDs before SQL', async () => {
|
||||||
|
let writes = 0;
|
||||||
|
const database = {
|
||||||
|
$executeRaw: async () => {
|
||||||
|
writes += 1;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await persistGeneralUpdates(database, []);
|
||||||
|
await expect(
|
||||||
|
persistGeneralUpdates(database, [
|
||||||
|
{ id: firstId, data: dataFor(1) },
|
||||||
|
{ id: firstId, data: dataFor(2) },
|
||||||
|
])
|
||||||
|
).rejects.toThrow('Duplicate general IDs');
|
||||||
|
expect(writes).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user