feat: 토너먼트 상태 취소 기능 추가 및 관리자 메시지 수정
This commit is contained in:
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { TournamentType } from '@sammo-ts/logic';
|
||||
import type { TournamentState } from '../../tournament/types.js';
|
||||
|
||||
import { TournamentStore } from '../../tournament/store.js';
|
||||
import { buildTournamentKeys } from '../../tournament/keys.js';
|
||||
@@ -15,6 +16,16 @@ const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||
return roles.some((role) => role === 'admin.tournament' || role === `admin.tournament:${profileName}`);
|
||||
};
|
||||
|
||||
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const adminProcedure = authedProcedure.use(({ ctx, next }) => {
|
||||
const roles = ctx.auth?.user.roles ?? [];
|
||||
if (!hasAdminRole(roles, ctx.profile.name)) {
|
||||
@@ -49,6 +60,14 @@ const zParticipant = z.object({
|
||||
strength: z.number().int().min(0),
|
||||
intel: z.number().int().min(0),
|
||||
level: z.number().int().min(0),
|
||||
groupId: z.number().int().optional(),
|
||||
groupNo: z.number().int().optional(),
|
||||
win: z.number().int().optional(),
|
||||
draw: z.number().int().optional(),
|
||||
lose: z.number().int().optional(),
|
||||
gl: z.number().int().optional(),
|
||||
seedRank: z.number().int().optional(),
|
||||
finalRank: z.number().int().optional(),
|
||||
});
|
||||
|
||||
const zMatch = z.object({
|
||||
@@ -246,6 +265,12 @@ export const tournamentRouter = router({
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '장수 정보를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const nextMeta = { ...asRecord(general.meta), tnmt: 1 };
|
||||
await ctx.db.general.update({
|
||||
where: { id: general.id },
|
||||
data: { meta: nextMeta },
|
||||
});
|
||||
|
||||
const already = participants.find((entry) => entry.id === general.id);
|
||||
if (already) {
|
||||
return { ok: true, count: participants.length };
|
||||
@@ -269,6 +294,68 @@ export const tournamentRouter = router({
|
||||
await store.setParticipants(next);
|
||||
return { ok: true, count: next.length };
|
||||
}),
|
||||
cancel: adminProcedure.mutation(async ({ ctx }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
const state = await store.getState();
|
||||
if (!state) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
||||
}
|
||||
|
||||
const [participants, bets] = await Promise.all([
|
||||
store.getParticipants(),
|
||||
store.getBettingEntries(),
|
||||
]);
|
||||
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
const config = asRecord(worldState?.config ?? {});
|
||||
const constValues = asRecord(config.const ?? config);
|
||||
const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
||||
|
||||
const refundMap = new Map<number, number>();
|
||||
for (const participant of participants) {
|
||||
if (participant.id <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (participant.groupId !== undefined && participant.groupId >= 0 && participant.groupId < 8) {
|
||||
refundMap.set(participant.id, (refundMap.get(participant.id) ?? 0) + develCost);
|
||||
}
|
||||
}
|
||||
for (const bet of bets) {
|
||||
refundMap.set(bet.generalId, (refundMap.get(bet.generalId) ?? 0) + bet.amount);
|
||||
}
|
||||
|
||||
if (refundMap.size > 0) {
|
||||
await ctx.turnDaemon.sendCommand({
|
||||
type: 'tournamentRefund',
|
||||
refunds: Array.from(refundMap.entries()).map(([generalId, amount]) => ({
|
||||
generalId,
|
||||
amount,
|
||||
})),
|
||||
reason: 'cancel',
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
store.setParticipants([]),
|
||||
store.setMatches([]),
|
||||
store.setBettingEntries([]),
|
||||
]);
|
||||
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: 0,
|
||||
phase: 0,
|
||||
auto: false,
|
||||
winnerId: undefined,
|
||||
bettingSettled: true,
|
||||
rewardSettled: false,
|
||||
bettingCloseAt: undefined,
|
||||
participantsLockedAt: undefined,
|
||||
nextAt: new Date().toISOString(),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
return { ok: true };
|
||||
}),
|
||||
placeBet: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -26,6 +26,14 @@ export interface TournamentParticipantEntry {
|
||||
strength: number;
|
||||
intel: number;
|
||||
level: number;
|
||||
groupId?: number;
|
||||
groupNo?: number;
|
||||
win?: number;
|
||||
draw?: number;
|
||||
lose?: number;
|
||||
gl?: number;
|
||||
seedRank?: number;
|
||||
finalRank?: number;
|
||||
}
|
||||
|
||||
export interface TournamentMatchEntry {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createTournamentRng } from '@sammo-ts/common';
|
||||
import { asRecord, createTournamentRng } from '@sammo-ts/common';
|
||||
import { resolveTournamentBattle, TournamentType } from '@sammo-ts/logic';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
@@ -12,7 +12,7 @@ import { RedisTurnDaemonTransport } from '../daemon/redisTransport.js';
|
||||
import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js';
|
||||
import { buildTournamentKeys } from './keys.js';
|
||||
import { TournamentStore } from './store.js';
|
||||
import type { TournamentBetEntry, TournamentMatchEntry, TournamentState } from './types.js';
|
||||
import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js';
|
||||
|
||||
const sleepMs = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -54,6 +54,340 @@ const resolveStatValue = (type: TournamentType, entry: { leadership: number; str
|
||||
}
|
||||
};
|
||||
|
||||
const resolveGroupPair = (stage: number, phase: number): [number, number] | null => {
|
||||
if (stage === 2) {
|
||||
const pairMap: Array<[number, number]> = [
|
||||
[0, 1], [2, 3], [4, 5], [6, 7],
|
||||
[0, 2], [1, 3], [4, 6], [5, 7],
|
||||
[0, 3], [1, 6], [2, 5], [4, 7],
|
||||
[0, 4], [1, 5], [2, 6], [3, 7],
|
||||
[0, 5], [1, 4], [2, 7], [3, 6],
|
||||
[0, 6], [1, 7], [2, 4], [3, 5],
|
||||
[0, 7], [1, 2], [3, 4], [5, 6],
|
||||
];
|
||||
const basePair = pairMap[phase % 28];
|
||||
if (!basePair) {
|
||||
return null;
|
||||
}
|
||||
return phase >= 28 ? [basePair[1], basePair[0]] : basePair;
|
||||
}
|
||||
|
||||
if (stage === 4) {
|
||||
const pairMap: Array<[number, number]> = [
|
||||
[0, 1], [2, 3],
|
||||
[0, 2], [1, 3],
|
||||
[0, 3], [1, 2],
|
||||
];
|
||||
return pairMap[phase % 6] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const assignGroupSlots = (
|
||||
participants: Array<TournamentParticipantEntry & { groupId?: number; groupNo?: number }>,
|
||||
groupCount: number,
|
||||
groupSize: number,
|
||||
groupStart: number
|
||||
): TournamentParticipantEntry[] => {
|
||||
const groupCounts = Array.from({ length: groupCount }, () => 0);
|
||||
for (const entry of participants) {
|
||||
if (entry.groupId !== undefined && entry.groupId >= groupStart && entry.groupId < groupStart + groupCount) {
|
||||
const idx = entry.groupId - groupStart;
|
||||
if (idx >= 0 && idx < groupCount) {
|
||||
groupCounts[idx] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return participants.map((entry) => {
|
||||
if (entry.groupId !== undefined && entry.groupNo !== undefined) {
|
||||
return entry;
|
||||
}
|
||||
const minCount = Math.min(...groupCounts);
|
||||
const groupIdx = groupCounts.findIndex((count) => count === minCount);
|
||||
const groupNo = groupCounts[groupIdx] ?? 0;
|
||||
groupCounts[groupIdx] = groupNo + 1;
|
||||
return {
|
||||
...entry,
|
||||
groupId: groupStart + groupIdx,
|
||||
groupNo: groupNo < groupSize ? groupNo : groupNo % groupSize,
|
||||
win: 0,
|
||||
draw: 0,
|
||||
lose: 0,
|
||||
gl: 0,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const selectWeighted = <T>(rng: ReturnType<typeof createTournamentRng>, pool: Array<{ item: T; weight: number }>): T => {
|
||||
return rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight]));
|
||||
};
|
||||
|
||||
const fillParticipants = async (options: {
|
||||
prisma: ReturnType<typeof createGamePostgresConnector>['prisma'];
|
||||
state: TournamentState;
|
||||
baseSeed: string;
|
||||
current: TournamentParticipantEntry[];
|
||||
limit: number;
|
||||
}): Promise<TournamentParticipantEntry[]> => {
|
||||
const { prisma, state, baseSeed, limit } = options;
|
||||
const takenIds = new Set(options.current.map((entry) => entry.id));
|
||||
const result = [...options.current];
|
||||
|
||||
if (result.length >= limit) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const applicants = await prisma.general.findMany({
|
||||
where: {
|
||||
meta: { path: ['tnmt'], equals: 1 },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
meta: true,
|
||||
npcState: true,
|
||||
},
|
||||
});
|
||||
|
||||
const applicantPool = applicants
|
||||
.filter((entry) => !takenIds.has(entry.id))
|
||||
.map((entry) => {
|
||||
const score = resolveStatValue(state.type, entry);
|
||||
const meta = asRecord(entry.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
return {
|
||||
item: {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
leadership: entry.leadership,
|
||||
strength: entry.strength,
|
||||
intel: entry.intel,
|
||||
level,
|
||||
},
|
||||
weight: Math.max(1, score ** 1.5),
|
||||
};
|
||||
});
|
||||
|
||||
const applicantRng = createTournamentRng(baseSeed, {
|
||||
openYear: state.openYear,
|
||||
openMonth: state.openMonth,
|
||||
stage: 1,
|
||||
phase: state.phase,
|
||||
matchIndex: 0,
|
||||
participantIndex: 0,
|
||||
extraSeed: 'fill:applicants',
|
||||
});
|
||||
|
||||
while (result.length < limit && applicantPool.length > 0) {
|
||||
const picked = selectWeighted(applicantRng, applicantPool);
|
||||
applicantPool.splice(applicantPool.findIndex((entry) => entry.item.id === picked.id), 1);
|
||||
takenIds.add(picked.id);
|
||||
result.push(picked);
|
||||
}
|
||||
|
||||
if (result.length >= limit) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const npcRows = await prisma.general.findMany({
|
||||
where: {
|
||||
npcState: { gte: 2 },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
meta: true,
|
||||
npcState: true,
|
||||
},
|
||||
});
|
||||
|
||||
const npcPool = npcRows
|
||||
.filter((entry) => !takenIds.has(entry.id))
|
||||
.map((entry) => {
|
||||
const score = resolveStatValue(state.type, entry);
|
||||
const meta = asRecord(entry.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
return {
|
||||
item: {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
leadership: entry.leadership,
|
||||
strength: entry.strength,
|
||||
intel: entry.intel,
|
||||
level,
|
||||
},
|
||||
weight: Math.max(1, score ** 1.5),
|
||||
};
|
||||
});
|
||||
|
||||
const npcRng = createTournamentRng(baseSeed, {
|
||||
openYear: state.openYear,
|
||||
openMonth: state.openMonth,
|
||||
stage: 1,
|
||||
phase: state.phase,
|
||||
matchIndex: 0,
|
||||
participantIndex: 1,
|
||||
extraSeed: 'fill:npc',
|
||||
});
|
||||
|
||||
while (result.length < limit && npcPool.length > 0) {
|
||||
const picked = selectWeighted(npcRng, npcPool);
|
||||
npcPool.splice(npcPool.findIndex((entry) => entry.item.id === picked.id), 1);
|
||||
takenIds.add(picked.id);
|
||||
result.push(picked);
|
||||
}
|
||||
|
||||
let dummyId = -1;
|
||||
while (result.length < limit) {
|
||||
while (takenIds.has(dummyId)) {
|
||||
dummyId -= 1;
|
||||
}
|
||||
takenIds.add(dummyId);
|
||||
result.push({
|
||||
id: dummyId,
|
||||
name: '무명장수',
|
||||
leadership: 10,
|
||||
strength: 10,
|
||||
intel: 10,
|
||||
level: 10,
|
||||
});
|
||||
dummyId -= 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const applyGroupMatch = (
|
||||
participants: TournamentParticipantEntry[],
|
||||
attacker: TournamentParticipantEntry,
|
||||
defender: TournamentParticipantEntry,
|
||||
state: TournamentState,
|
||||
baseSeed: string,
|
||||
matchIndex: number
|
||||
): TournamentParticipantEntry[] => {
|
||||
const result = resolveTournamentBattle({
|
||||
type: state.type,
|
||||
battleType: 0,
|
||||
attacker: {
|
||||
id: attacker.id,
|
||||
name: attacker.name,
|
||||
stats: {
|
||||
leadership: attacker.leadership,
|
||||
strength: attacker.strength,
|
||||
intel: attacker.intel,
|
||||
},
|
||||
level: attacker.level,
|
||||
},
|
||||
defender: {
|
||||
id: defender.id,
|
||||
name: defender.name,
|
||||
stats: {
|
||||
leadership: defender.leadership,
|
||||
strength: defender.strength,
|
||||
intel: defender.intel,
|
||||
},
|
||||
level: defender.level,
|
||||
},
|
||||
context: {
|
||||
openYear: state.openYear,
|
||||
openMonth: state.openMonth,
|
||||
stage: state.stage,
|
||||
phase: state.phase,
|
||||
matchIndex,
|
||||
},
|
||||
baseSeed,
|
||||
});
|
||||
|
||||
const glDelta = Math.round((result.totalDamage.defender - result.totalDamage.attacker) / 50);
|
||||
|
||||
return participants.map((entry) => {
|
||||
if (entry.id !== attacker.id && entry.id !== defender.id) {
|
||||
return entry;
|
||||
}
|
||||
const next = {
|
||||
...entry,
|
||||
win: entry.win ?? 0,
|
||||
draw: entry.draw ?? 0,
|
||||
lose: entry.lose ?? 0,
|
||||
gl: entry.gl ?? 0,
|
||||
};
|
||||
if (result.draw) {
|
||||
next.draw += 1;
|
||||
return next;
|
||||
}
|
||||
if (result.winnerId === entry.id) {
|
||||
next.win += 1;
|
||||
next.gl += glDelta;
|
||||
return next;
|
||||
}
|
||||
next.lose += 1;
|
||||
next.gl -= glDelta;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const sortByRanking = (entries: TournamentParticipantEntry[]): TournamentParticipantEntry[] => {
|
||||
return [...entries].sort((lhs, rhs) => {
|
||||
const lhsPoints = (lhs.win ?? 0) * 3 + (lhs.draw ?? 0);
|
||||
const rhsPoints = (rhs.win ?? 0) * 3 + (rhs.draw ?? 0);
|
||||
if (lhsPoints !== rhsPoints) {
|
||||
return rhsPoints - lhsPoints;
|
||||
}
|
||||
const lhsGl = lhs.gl ?? 0;
|
||||
const rhsGl = rhs.gl ?? 0;
|
||||
if (lhsGl !== rhsGl) {
|
||||
return rhsGl - lhsGl;
|
||||
}
|
||||
return lhs.id - rhs.id;
|
||||
});
|
||||
};
|
||||
|
||||
const buildFinal16MatchesFromGroups = (
|
||||
state: TournamentState,
|
||||
participants: TournamentParticipantEntry[]
|
||||
): TournamentMatchEntry[] | null => {
|
||||
const groupOrder = [10, 14, 11, 15, 12, 16, 13, 17, 14, 10, 15, 11, 16, 12, 17, 13];
|
||||
const rankOrder = [1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2];
|
||||
|
||||
const selected: number[] = [];
|
||||
for (let i = 0; i < groupOrder.length; i += 1) {
|
||||
const groupId = groupOrder[i]!;
|
||||
const rank = rankOrder[i]!;
|
||||
const entry = participants.find((p) => p.groupId === groupId && p.finalRank === rank);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
selected.push(entry.id);
|
||||
}
|
||||
|
||||
return selected.reduce<TournamentMatchEntry[]>((acc, _id, idx) => {
|
||||
if (idx % 2 !== 0) {
|
||||
return acc;
|
||||
}
|
||||
const attackerId = selected[idx];
|
||||
const defenderId = selected[idx + 1];
|
||||
if (attackerId === undefined || defenderId === undefined) {
|
||||
return acc;
|
||||
}
|
||||
acc.push({
|
||||
id: acc.length + 1,
|
||||
stage: 7,
|
||||
roundIndex: acc.length,
|
||||
attackerId,
|
||||
defenderId,
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const pickFinalists = (state: TournamentState, participants: Array<{ id: number; leadership: number; strength: number; intel: number }>): number[] =>
|
||||
participants
|
||||
.slice()
|
||||
@@ -310,22 +644,114 @@ const buildTournamentRewardPayload = (
|
||||
};
|
||||
};
|
||||
|
||||
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const seedNpcBets = async (options: {
|
||||
prisma: ReturnType<typeof createGamePostgresConnector>['prisma'];
|
||||
store: TournamentStore;
|
||||
state: TournamentState;
|
||||
baseSeed: string;
|
||||
}): Promise<void> => {
|
||||
const { prisma, store, state, baseSeed } = options;
|
||||
const existing = await store.getBettingEntries();
|
||||
if (existing.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = await store.getMatches();
|
||||
const candidateIds = Array.from(
|
||||
new Set(
|
||||
matches
|
||||
.filter((match) => match.stage === 7)
|
||||
.flatMap((match) => [match.attackerId, match.defenderId])
|
||||
)
|
||||
);
|
||||
if (candidateIds.length === 0) {
|
||||
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 currentYear = worldState?.currentYear ?? state.openYear;
|
||||
const betGold = Math.max(10, Math.floor((3 + currentYear - startYear) * 0.334) * 10);
|
||||
|
||||
const npcList = await prisma.general.findMany({
|
||||
where: {
|
||||
npcState: { gte: 2 },
|
||||
gold: { gte: 500 + betGold },
|
||||
},
|
||||
select: { id: true, gold: true },
|
||||
});
|
||||
if (npcList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rng = createTournamentRng(baseSeed, {
|
||||
openYear: state.openYear,
|
||||
openMonth: state.openMonth,
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
matchIndex: 0,
|
||||
participantIndex: 0,
|
||||
extraSeed: `OpenBettingTournament:${state.bettingId ?? 'none'}`,
|
||||
});
|
||||
|
||||
const entries = [...existing];
|
||||
for (const npc of npcList) {
|
||||
const targetId = rng.choice(candidateIds);
|
||||
entries.push({ generalId: npc.id, targetId, amount: betGold });
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
npcList.map((npc) =>
|
||||
prisma.general.update({
|
||||
where: { id: npc.id },
|
||||
data: { gold: npc.gold - betGold },
|
||||
})
|
||||
)
|
||||
);
|
||||
await store.setBettingEntries(entries);
|
||||
};
|
||||
|
||||
const applyPreBattleStage = async (
|
||||
store: TournamentStore,
|
||||
prisma: ReturnType<typeof createGamePostgresConnector>['prisma'],
|
||||
state: TournamentState,
|
||||
baseSeed: string
|
||||
): Promise<TournamentState> => {
|
||||
const participants = await store.getParticipants();
|
||||
|
||||
if (state.stage === 1) {
|
||||
let nextParticipants = participants;
|
||||
if (participants.length < 64) {
|
||||
const waitingState: TournamentState = {
|
||||
...state,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(waitingState);
|
||||
return waitingState;
|
||||
nextParticipants = await fillParticipants({
|
||||
prisma,
|
||||
state,
|
||||
baseSeed,
|
||||
current: participants,
|
||||
limit: 64,
|
||||
});
|
||||
}
|
||||
const grouped = assignGroupSlots(nextParticipants, 8, 8, 0).map((entry) => ({
|
||||
...entry,
|
||||
win: 0,
|
||||
draw: 0,
|
||||
lose: 0,
|
||||
gl: 0,
|
||||
seedRank: 0,
|
||||
finalRank: 0,
|
||||
}));
|
||||
await store.setParticipants(grouped);
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: 2,
|
||||
@@ -338,13 +764,52 @@ const applyPreBattleStage = async (
|
||||
}
|
||||
|
||||
if (state.stage === 2) {
|
||||
const maxPhase = 27;
|
||||
const nextPhase = Math.min(maxPhase, state.phase + 1);
|
||||
const isComplete = nextPhase >= maxPhase;
|
||||
const pair = resolveGroupPair(2, state.phase);
|
||||
if (!pair) {
|
||||
throw new Error('예선 매치 구성을 찾을 수 없습니다.');
|
||||
}
|
||||
let updated = participants.some((entry) => entry.groupId === undefined)
|
||||
? assignGroupSlots(participants, 8, 8, 0)
|
||||
: participants;
|
||||
for (let groupId = 0; groupId < 8; groupId += 1) {
|
||||
const groupEntries = updated.filter((entry) => entry.groupId === groupId);
|
||||
const attacker = groupEntries.find((entry) => entry.groupNo === pair[0]);
|
||||
const defender = groupEntries.find((entry) => entry.groupNo === pair[1]);
|
||||
if (!attacker || !defender) {
|
||||
continue;
|
||||
}
|
||||
updated = applyGroupMatch(updated, attacker, defender, state, baseSeed, groupId);
|
||||
}
|
||||
await store.setParticipants(updated);
|
||||
|
||||
const maxPhase = 55;
|
||||
const isComplete = state.phase >= maxPhase;
|
||||
if (isComplete) {
|
||||
const ranked = updated;
|
||||
for (let groupId = 0; groupId < 8; groupId += 1) {
|
||||
const groupEntries = ranked.filter((entry) => entry.groupId === groupId);
|
||||
const ordered = sortByRanking(groupEntries);
|
||||
ordered.slice(0, 4).forEach((entry, idx) => {
|
||||
const target = ranked.find((item) => item.id === entry.id);
|
||||
if (target) {
|
||||
target.seedRank = idx + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
await store.setParticipants(ranked);
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: 3,
|
||||
phase: 0,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: isComplete ? 3 : state.stage,
|
||||
phase: isComplete ? 0 : nextPhase,
|
||||
phase: state.phase + 1,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
@@ -352,10 +817,66 @@ const applyPreBattleStage = async (
|
||||
}
|
||||
|
||||
if (state.stage === 3) {
|
||||
const phase = state.phase;
|
||||
const groupId = 10 + (phase % 8);
|
||||
const groupNo = Math.floor(phase / 8);
|
||||
const seedTarget = phase < 8 ? 1 : phase < 16 ? 2 : 3;
|
||||
|
||||
const candidates = participants.filter((entry) => {
|
||||
if (entry.groupId !== undefined && entry.groupId >= 10) {
|
||||
return false;
|
||||
}
|
||||
if (seedTarget === 3) {
|
||||
return (entry.seedRank ?? 0) > 2;
|
||||
}
|
||||
return (entry.seedRank ?? 0) === seedTarget;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new Error('본선 추첨 후보가 없습니다.');
|
||||
}
|
||||
|
||||
const rng = createTournamentRng(baseSeed, {
|
||||
openYear: state.openYear,
|
||||
openMonth: state.openMonth,
|
||||
stage: 3,
|
||||
phase,
|
||||
matchIndex: groupId,
|
||||
participantIndex: 0,
|
||||
extraSeed: `selection:${seedTarget}:${candidates.map((c) => c.id).join('-')}`,
|
||||
});
|
||||
const picked = rng.choice(candidates);
|
||||
|
||||
const nextParticipants = participants.map((entry) => {
|
||||
if (entry.id !== picked.id) {
|
||||
return entry;
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
groupId,
|
||||
groupNo,
|
||||
win: 0,
|
||||
draw: 0,
|
||||
lose: 0,
|
||||
gl: 0,
|
||||
};
|
||||
});
|
||||
await store.setParticipants(nextParticipants);
|
||||
|
||||
if (phase >= 31) {
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: 4,
|
||||
phase: 0,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: 4,
|
||||
phase: 0,
|
||||
phase: phase + 1,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
@@ -363,13 +884,48 @@ const applyPreBattleStage = async (
|
||||
}
|
||||
|
||||
if (state.stage === 4) {
|
||||
const pair = resolveGroupPair(4, state.phase);
|
||||
if (!pair) {
|
||||
throw new Error('본선 매치 구성을 찾을 수 없습니다.');
|
||||
}
|
||||
let updated = participants;
|
||||
for (let groupId = 10; groupId < 18; groupId += 1) {
|
||||
const groupEntries = updated.filter((entry) => entry.groupId === groupId);
|
||||
const attacker = groupEntries.find((entry) => entry.groupNo === pair[0]);
|
||||
const defender = groupEntries.find((entry) => entry.groupNo === pair[1]);
|
||||
if (!attacker || !defender) {
|
||||
continue;
|
||||
}
|
||||
updated = applyGroupMatch(updated, attacker, defender, state, baseSeed, groupId);
|
||||
}
|
||||
await store.setParticipants(updated);
|
||||
|
||||
const maxPhase = 5;
|
||||
const nextPhase = Math.min(maxPhase, state.phase + 1);
|
||||
const isComplete = nextPhase >= maxPhase;
|
||||
if (state.phase >= maxPhase) {
|
||||
for (let groupId = 10; groupId < 18; groupId += 1) {
|
||||
const groupEntries = updated.filter((entry) => entry.groupId === groupId);
|
||||
const ordered = sortByRanking(groupEntries);
|
||||
ordered.slice(0, 2).forEach((entry, idx) => {
|
||||
const target = updated.find((item) => item.id === entry.id);
|
||||
if (target) {
|
||||
target.finalRank = idx + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
await store.setParticipants(updated);
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: 5,
|
||||
phase: 0,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: isComplete ? 5 : state.stage,
|
||||
phase: isComplete ? 0 : nextPhase,
|
||||
phase: state.phase + 1,
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
@@ -379,18 +935,23 @@ const applyPreBattleStage = async (
|
||||
if (state.stage === 5) {
|
||||
const matches = await store.getMatches();
|
||||
if (matches.length === 0) {
|
||||
const participantIds = pickFinalists(state, participants);
|
||||
const initialMatches = buildInitialMatches(state, baseSeed, participantIds);
|
||||
const fixedMatches = buildFinal16MatchesFromGroups(state, participants);
|
||||
const participantIds = fixedMatches
|
||||
? fixedMatches.flatMap((entry) => [entry.attackerId, entry.defenderId])
|
||||
: pickFinalists(state, participants);
|
||||
const initialMatches = fixedMatches ?? buildInitialMatches(state, baseSeed, participantIds);
|
||||
await store.setMatches(initialMatches);
|
||||
}
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
bettingId: state.bettingId ?? Date.now(),
|
||||
bettingCloseAt: resolveBettingCloseAt(state),
|
||||
nextAt: resolveNextAt(state),
|
||||
};
|
||||
await store.setState(nextState);
|
||||
await seedNpcBets({ prisma, store, state: nextState, baseSeed });
|
||||
return nextState;
|
||||
}
|
||||
|
||||
@@ -463,7 +1024,7 @@ export const runTournamentWorker = async (): Promise<void> => {
|
||||
if (isBattleStage(state.stage)) {
|
||||
nextState = await applyBattle(store, state, String(baseSeed));
|
||||
} else if (isPreBattleStage(state.stage)) {
|
||||
nextState = await applyPreBattleStage(store, state, String(baseSeed));
|
||||
nextState = await applyPreBattleStage(store, postgres.prisma, state, String(baseSeed));
|
||||
}
|
||||
|
||||
if (nextState.stage === 0 && nextState.winnerId) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { JosaUtil, asRecord } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector } from '@sammo-ts/infra';
|
||||
import { ActionLogger, LogFormat, type TournamentType } from '@sammo-ts/logic';
|
||||
import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
@@ -24,6 +24,20 @@ const resolveTournamentLabel = (type: TournamentType): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveTournamentRankPrefix = (type: TournamentType): string => {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return 'tl';
|
||||
case 2:
|
||||
return 'ts';
|
||||
case 3:
|
||||
return 'ti';
|
||||
case 0:
|
||||
default:
|
||||
return 'tt';
|
||||
}
|
||||
};
|
||||
|
||||
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
@@ -124,7 +138,9 @@ export const createTournamentRewardFinalizer = async (options: {
|
||||
}
|
||||
}
|
||||
|
||||
const tournamentLabel = resolveTournamentLabel(command.tournamentType as TournamentType);
|
||||
const tournamentType = command.tournamentType as TournamentType;
|
||||
const tournamentLabel = resolveTournamentLabel(tournamentType);
|
||||
const rankPrefix = resolveTournamentRankPrefix(tournamentType);
|
||||
const logs: ReturnType<ActionLogger['flush']> = [];
|
||||
let rewarded = 0;
|
||||
let missing = 0;
|
||||
@@ -137,9 +153,36 @@ export const createTournamentRewardFinalizer = async (options: {
|
||||
missing += 1;
|
||||
continue;
|
||||
}
|
||||
const rankKey = `${rankPrefix}g`;
|
||||
const currentRankValue = typeof general.meta[rankKey] === 'number' ? Number(general.meta[rankKey]) : 0;
|
||||
let rankDelta = 0;
|
||||
if (reward.label === '16강 진출') {
|
||||
rankDelta = 1;
|
||||
} else if (reward.label === '8강 진출') {
|
||||
rankDelta = 1;
|
||||
} else if (reward.label === '4강 진출') {
|
||||
rankDelta = 2;
|
||||
} else if (reward.label === '준우승') {
|
||||
rankDelta = 2;
|
||||
} else if (reward.label === '우승') {
|
||||
rankDelta = 2;
|
||||
}
|
||||
|
||||
const rankMetaNext: Record<string, TriggerValue> = {
|
||||
[rankKey]: currentRankValue + rankDelta,
|
||||
};
|
||||
if (reward.label === '우승') {
|
||||
const pointKey = `${rankPrefix}p`;
|
||||
const currentPoint = typeof general.meta[pointKey] === 'number' ? Number(general.meta[pointKey]) : 0;
|
||||
rankMetaNext[pointKey] = currentPoint + 1;
|
||||
}
|
||||
world.updateGeneral(generalId, {
|
||||
gold: general.gold + reward.gold,
|
||||
experience: general.experience + reward.exp,
|
||||
meta: {
|
||||
...general.meta,
|
||||
...rankMetaNext,
|
||||
},
|
||||
});
|
||||
totalGold += reward.gold;
|
||||
totalExp += reward.exp;
|
||||
|
||||
@@ -285,13 +285,9 @@ const progressSummary = computed(() => {
|
||||
const adminMessage = ref<string | null>(null);
|
||||
|
||||
const adminStopTournament = async () => {
|
||||
if (!snapshot.value?.state) {
|
||||
adminMessage.value = '토너먼트 상태를 찾을 수 없습니다.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await trpc.tournament.patchState.mutate({ auto: false });
|
||||
adminMessage.value = '자동 진행이 중지되었습니다.';
|
||||
await trpc.tournament.cancel.mutate();
|
||||
adminMessage.value = '토너먼트가 취소되었습니다.';
|
||||
await loadTournament();
|
||||
} catch (err) {
|
||||
adminMessage.value = resolveErrorMessage(err);
|
||||
|
||||
Reference in New Issue
Block a user