feat: complete tournament API lifecycle
This commit is contained in:
@@ -7,7 +7,8 @@ import type { TournamentState } from '../../tournament/types.js';
|
||||
|
||||
import { TournamentStore } from '../../tournament/store.js';
|
||||
import { buildTournamentKeys } from '../../tournament/keys.js';
|
||||
import { authedProcedure, procedure, router } from '../../trpc.js';
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
|
||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||
@@ -110,13 +111,24 @@ const zSeedParticipants = z.object({
|
||||
includeNpc: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const tournamentRankTypes = ['tt', 'tl', 'ts', 'ti'] as const;
|
||||
|
||||
const tournamentRankInfo = {
|
||||
tt: { title: '전 력 전', statLabel: '종합' },
|
||||
tl: { title: '통 솔 전', statLabel: '통솔' },
|
||||
ts: { title: '일 기 토', statLabel: '무력' },
|
||||
ti: { title: '설 전', statLabel: '지력' },
|
||||
} as const;
|
||||
|
||||
export const tournamentRouter = router({
|
||||
getState: procedure.query(async ({ ctx }) => {
|
||||
getState: authedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
return store.getState();
|
||||
}),
|
||||
getAdminStatus: adminProcedure.query(async () => ({ ok: true })),
|
||||
getSnapshot: procedure.query(async ({ ctx }) => {
|
||||
getSnapshot: authedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
const [state, participants, matches, bets] = await Promise.all([
|
||||
store.getState(),
|
||||
@@ -124,66 +136,156 @@ export const tournamentRouter = router({
|
||||
store.getMatches(),
|
||||
store.getBettingEntries(),
|
||||
]);
|
||||
return { state, participants, matches, bets };
|
||||
return { state, participants, matches, betCount: bets.length };
|
||||
}),
|
||||
getRankings: authedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
const rankTypeNames = tournamentRankTypes.flatMap((prefix) => [
|
||||
`${prefix}p`,
|
||||
`${prefix}g`,
|
||||
`${prefix}w`,
|
||||
`${prefix}d`,
|
||||
`${prefix}l`,
|
||||
]);
|
||||
const candidateRows = await Promise.all(
|
||||
tournamentRankTypes.map((prefix) =>
|
||||
ctx.db.rankData.findMany({
|
||||
where: { type: `${prefix}g` },
|
||||
orderBy: { value: 'desc' },
|
||||
take: 40,
|
||||
select: { generalId: true },
|
||||
})
|
||||
)
|
||||
);
|
||||
const candidateIdsByPrefix = new Map(
|
||||
tournamentRankTypes.map((prefix, index) => [
|
||||
prefix,
|
||||
new Set((candidateRows[index] ?? []).map((row) => row.generalId)),
|
||||
])
|
||||
);
|
||||
const candidateIds = new Set(candidateRows.flatMap((rows) => rows.map((row) => row.generalId)));
|
||||
const scoreRows = await ctx.db.rankData.findMany({
|
||||
where: { generalId: { in: [...candidateIds] }, type: { in: rankTypeNames } },
|
||||
select: { generalId: true, type: true, value: true },
|
||||
});
|
||||
const rankMap = new Map<number, Record<string, number>>();
|
||||
for (const row of scoreRows) {
|
||||
const ranks = rankMap.get(row.generalId) ?? {};
|
||||
ranks[row.type] = row.value;
|
||||
rankMap.set(row.generalId, ranks);
|
||||
}
|
||||
const generals = await ctx.db.general.findMany({
|
||||
where: { id: { in: [...rankMap.keys()] } },
|
||||
select: { id: true, name: true, npcState: true, leadership: true, strength: true, intel: true },
|
||||
});
|
||||
|
||||
return tournamentRankTypes.map((prefix) => {
|
||||
const entries = generals
|
||||
.filter((general) => candidateIdsByPrefix.get(prefix)?.has(general.id))
|
||||
.map((general) => {
|
||||
const ranks = rankMap.get(general.id) ?? {};
|
||||
const win = ranks[`${prefix}w`] ?? 0;
|
||||
const draw = ranks[`${prefix}d`] ?? 0;
|
||||
const lose = ranks[`${prefix}l`] ?? 0;
|
||||
const score = ranks[`${prefix}g`] ?? 0;
|
||||
const stat =
|
||||
prefix === 'tt'
|
||||
? general.leadership + general.strength + general.intel
|
||||
: prefix === 'tl'
|
||||
? general.leadership
|
||||
: prefix === 'ts'
|
||||
? general.strength
|
||||
: general.intel;
|
||||
return {
|
||||
generalId: general.id,
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
stat,
|
||||
games: win + draw + lose,
|
||||
win,
|
||||
draw,
|
||||
lose,
|
||||
score,
|
||||
prizes: ranks[`${prefix}p`] ?? 0,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(lhs, rhs) =>
|
||||
rhs.score - lhs.score ||
|
||||
rhs.games - lhs.games ||
|
||||
rhs.win - lhs.win ||
|
||||
rhs.draw - lhs.draw ||
|
||||
lhs.lose - rhs.lose
|
||||
)
|
||||
.slice(0, 30)
|
||||
.map((entry, index) => ({ rank: index + 1, ...entry }));
|
||||
return { prefix, ...tournamentRankInfo[prefix], entries };
|
||||
});
|
||||
}),
|
||||
setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
await store.setState({
|
||||
...input,
|
||||
type: input.type as TournamentType,
|
||||
return store.withMutationLock(async () => {
|
||||
await store.setState({
|
||||
...input,
|
||||
type: input.type as TournamentType,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
return { ok: true };
|
||||
}),
|
||||
patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
const current = await store.getState();
|
||||
if (!current) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
||||
}
|
||||
const next = {
|
||||
...current,
|
||||
...input,
|
||||
type: (input.type !== undefined ? input.type : current.type) as TournamentType,
|
||||
};
|
||||
await store.setState(next);
|
||||
return { ok: true };
|
||||
return store.withMutationLock(async () => {
|
||||
const current = await store.getState();
|
||||
if (!current) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
||||
}
|
||||
const next = {
|
||||
...current,
|
||||
...input,
|
||||
type: (input.type !== undefined ? input.type : current.type) as TournamentType,
|
||||
};
|
||||
await store.setState(next);
|
||||
return { ok: true };
|
||||
});
|
||||
}),
|
||||
setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
await store.setParticipants(input);
|
||||
return { ok: true, count: input.length };
|
||||
return store.withMutationLock(async () => {
|
||||
await store.setParticipants(input);
|
||||
return { ok: true, count: input.length };
|
||||
});
|
||||
}),
|
||||
setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
await store.setMatches(input);
|
||||
return { ok: true, count: input.length };
|
||||
return store.withMutationLock(async () => {
|
||||
await store.setMatches(input);
|
||||
return { ok: true, count: input.length };
|
||||
});
|
||||
}),
|
||||
setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => {
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
await store.setBettingEntries(input);
|
||||
return { ok: true, count: input.length };
|
||||
return store.withMutationLock(async () => {
|
||||
await store.setBettingEntries(input);
|
||||
return { ok: true, count: input.length };
|
||||
});
|
||||
}),
|
||||
seedParticipants: adminProcedure.input(zSeedParticipants).mutation(async ({ ctx, input }) => {
|
||||
const limit = input.limit ?? 64;
|
||||
const includeNpc = input.includeNpc ?? true;
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
|
||||
const generals = input.generalIds && input.generalIds.length > 0
|
||||
? await ctx.db.general.findMany({
|
||||
where: { id: { in: input.generalIds } },
|
||||
select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true },
|
||||
})
|
||||
: await ctx.db.general.findMany({
|
||||
where: includeNpc ? {} : { npcState: 0 },
|
||||
orderBy: [
|
||||
{ leadership: 'desc' },
|
||||
{ strength: 'desc' },
|
||||
{ intel: 'desc' },
|
||||
{ id: 'asc' },
|
||||
],
|
||||
take: limit,
|
||||
select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true },
|
||||
});
|
||||
const generals =
|
||||
input.generalIds && input.generalIds.length > 0
|
||||
? await ctx.db.general.findMany({
|
||||
where: { id: { in: input.generalIds } },
|
||||
select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true },
|
||||
})
|
||||
: await ctx.db.general.findMany({
|
||||
where: includeNpc ? {} : { npcState: 0 },
|
||||
orderBy: [{ leadership: 'desc' }, { strength: 'desc' }, { intel: 'desc' }, { id: 'asc' }],
|
||||
take: limit,
|
||||
select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true },
|
||||
});
|
||||
|
||||
const participants = generals.map((general) => {
|
||||
const meta = asRecord(general.meta);
|
||||
@@ -198,10 +300,13 @@ export const tournamentRouter = router({
|
||||
};
|
||||
});
|
||||
|
||||
await store.setParticipants(participants);
|
||||
return { ok: true, count: participants.length };
|
||||
return store.withMutationLock(async () => {
|
||||
await store.setParticipants(participants);
|
||||
return { ok: true, count: participants.length };
|
||||
});
|
||||
}),
|
||||
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
const [state, entries, matches] = await Promise.all([
|
||||
store.getState(),
|
||||
@@ -225,18 +330,13 @@ export const tournamentRouter = router({
|
||||
const myTotals: Record<number, number> = {};
|
||||
let totalAmount = 0;
|
||||
let myAmount = 0;
|
||||
const userId = ctx.auth?.user.id;
|
||||
const general = userId
|
||||
? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } })
|
||||
: null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!candidateIds.has(entry.targetId)) {
|
||||
continue;
|
||||
}
|
||||
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
|
||||
totalAmount += entry.amount;
|
||||
if (general && entry.generalId === general.id) {
|
||||
if (entry.generalId === general.id) {
|
||||
myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount;
|
||||
myAmount += entry.amount;
|
||||
}
|
||||
@@ -245,199 +345,254 @@ export const tournamentRouter = router({
|
||||
return { state, totals, myTotals, totalAmount, myAmount };
|
||||
}),
|
||||
join: authedProcedure.mutation(async ({ ctx }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const general = await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
const state = await store.getState();
|
||||
if (!state || state.stage !== 1 || state.participantsLockedAt) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' });
|
||||
}
|
||||
return store.withMutationLock(async () => {
|
||||
const state = await store.getState();
|
||||
if (!state || state.stage !== 1 || state.participantsLockedAt) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' });
|
||||
}
|
||||
|
||||
const participants = await store.getParticipants();
|
||||
const [participants, worldState] = await Promise.all([
|
||||
store.getParticipants(),
|
||||
ctx.db.worldState.findFirst(),
|
||||
]);
|
||||
if (participants.some((entry) => entry.id === general.id)) {
|
||||
return { ok: true, count: participants.length };
|
||||
}
|
||||
if (participants.length >= 64) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 인원이 가득 찼습니다.' });
|
||||
}
|
||||
|
||||
const general = await ctx.db.general.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true },
|
||||
const config = asRecord(worldState?.config ?? {});
|
||||
const constValues = asRecord(config.const ?? config);
|
||||
const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
||||
const feeResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
|
||||
});
|
||||
if (!feeResult || feeResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!feeResult.ok || feeResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: feeResult.ok ? '금이 부족합니다.' : feeResult.reason,
|
||||
});
|
||||
}
|
||||
|
||||
const settingResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'setMySetting',
|
||||
generalId: general.id,
|
||||
settings: { tnmt: 1 },
|
||||
});
|
||||
if (!settingResult || settingResult.type !== 'setMySetting' || !settingResult.ok) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentJoinRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
settingResult && settingResult.type === 'setMySetting'
|
||||
? (settingResult.reason ?? '요청에 실패했습니다.')
|
||||
: 'Unexpected response',
|
||||
});
|
||||
}
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
const next = participants.concat({
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intel: general.intel,
|
||||
level,
|
||||
});
|
||||
|
||||
try {
|
||||
await store.setParticipants(next);
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentJoinRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
||||
}),
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'setMySetting',
|
||||
generalId: general.id,
|
||||
settings: { tnmt: 0 },
|
||||
}),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
return { ok: true, count: next.length };
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '장수 정보를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'setMySetting',
|
||||
generalId: general.id,
|
||||
settings: { tnmt: 1 },
|
||||
});
|
||||
if (!result || result.type !== 'setMySetting') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason ?? '요청에 실패했습니다.' });
|
||||
}
|
||||
|
||||
const already = participants.find((entry) => entry.id === general.id);
|
||||
if (already) {
|
||||
return { ok: true, count: participants.length };
|
||||
}
|
||||
|
||||
if (participants.length >= 64) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 인원이 가득 찼습니다.' });
|
||||
}
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
const next = participants.concat({
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intel: general.intel,
|
||||
level,
|
||||
});
|
||||
|
||||
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;
|
||||
return store.withMutationLock(async () => {
|
||||
const state = await store.getState();
|
||||
if (!state) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
||||
}
|
||||
if (participant.groupId !== undefined && participant.groupId >= 0 && participant.groupId < 8) {
|
||||
refundMap.set(participant.id, (refundMap.get(participant.id) ?? 0) + develCost);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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',
|
||||
});
|
||||
}
|
||||
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([]),
|
||||
]);
|
||||
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 };
|
||||
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({
|
||||
targetId: z.number().int().positive(),
|
||||
amount: z.number().int().positive(),
|
||||
amount: z.number().int().min(10),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const general = await getMyGeneral(ctx);
|
||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||
const state = await store.getState();
|
||||
if (!state || state.stage !== 6) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
|
||||
}
|
||||
const closeAt = state.bettingCloseAt ? new Date(state.bettingCloseAt).getTime() : 0;
|
||||
if (closeAt && closeAt <= Date.now()) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅이 마감되었습니다.' });
|
||||
}
|
||||
|
||||
const matches = await store.getMatches();
|
||||
const candidateIds = new Set<number>();
|
||||
for (const match of matches) {
|
||||
if (match.stage === 7) {
|
||||
candidateIds.add(match.attackerId);
|
||||
candidateIds.add(match.defenderId);
|
||||
return store.withMutationLock(async () => {
|
||||
const state = await store.getState();
|
||||
if (!state || state.stage !== 6) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
|
||||
}
|
||||
const closeAt = state.bettingCloseAt ? new Date(state.bettingCloseAt).getTime() : 0;
|
||||
if (closeAt && closeAt <= Date.now()) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅이 마감되었습니다.' });
|
||||
}
|
||||
}
|
||||
if (!candidateIds.has(input.targetId)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
|
||||
}
|
||||
|
||||
const general = await ctx.db.general.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, gold: true },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
||||
}
|
||||
const [matches, entries] = await Promise.all([store.getMatches(), store.getBettingEntries()]);
|
||||
const candidateIds = new Set<number>();
|
||||
for (const match of matches) {
|
||||
if (match.stage === 7) {
|
||||
candidateIds.add(match.attackerId);
|
||||
candidateIds.add(match.defenderId);
|
||||
}
|
||||
}
|
||||
if (!candidateIds.has(input.targetId)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
|
||||
}
|
||||
|
||||
const minRemainGold = 500;
|
||||
if (general.gold - input.amount < minRemainGold) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '소지금이 부족합니다.' });
|
||||
}
|
||||
const previousBetAmount = entries
|
||||
.filter((entry) => entry.generalId === general.id)
|
||||
.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
if (previousBetAmount + input.amount > 1_000) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${1_000 - previousBetAmount}금까지만 베팅 가능합니다.`,
|
||||
});
|
||||
}
|
||||
|
||||
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount }],
|
||||
});
|
||||
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!adjustResult.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: adjustResult.reason });
|
||||
}
|
||||
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
||||
});
|
||||
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!adjustResult.ok || adjustResult.processed !== 1) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: adjustResult.ok ? '금이 부족합니다.' : adjustResult.reason,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.turnDaemon.sendCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [
|
||||
{
|
||||
const rankResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
reason: 'tournamentBet',
|
||||
adjustments: [
|
||||
{
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: input.amount },
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
||||
await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
});
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '베팅 기록을 저장하지 못했습니다.' });
|
||||
}
|
||||
|
||||
try {
|
||||
await store.appendBettingEntry({
|
||||
generalId: general.id,
|
||||
metaDelta: { rank_betgold: input.amount },
|
||||
},
|
||||
],
|
||||
targetId: input.targetId,
|
||||
amount: input.amount,
|
||||
});
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||
}),
|
||||
ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralMeta',
|
||||
reason: 'tournamentBetRollback',
|
||||
adjustments: [
|
||||
{
|
||||
generalId: general.id,
|
||||
metaDelta: { betgold: -input.amount },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await store.appendBettingEntry({
|
||||
generalId: general.id,
|
||||
targetId: input.targetId,
|
||||
amount: input.amount,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { TournamentKeys } from './keys.js';
|
||||
import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js';
|
||||
|
||||
interface RedisClientLike {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string): Promise<unknown>;
|
||||
set(
|
||||
key: string,
|
||||
value: string,
|
||||
options?: {
|
||||
NX?: boolean;
|
||||
PX?: number;
|
||||
}
|
||||
): Promise<unknown>;
|
||||
del?(key: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
const safeJsonParse = <T>(raw: string | null): T | null => {
|
||||
@@ -18,7 +28,34 @@ const safeJsonParse = <T>(raw: string | null): T | null => {
|
||||
};
|
||||
|
||||
export class TournamentStore {
|
||||
constructor(private readonly redis: RedisClientLike, private readonly keys: TournamentKeys) {}
|
||||
constructor(
|
||||
private readonly redis: RedisClientLike,
|
||||
private readonly keys: TournamentKeys
|
||||
) {}
|
||||
|
||||
async withMutationLock<T>(operation: () => Promise<T>, timeoutMs = 2_000): Promise<T> {
|
||||
if (!this.redis.del) {
|
||||
return operation();
|
||||
}
|
||||
|
||||
const lockKey = `${this.keys.stateKey}:mutation-lock`;
|
||||
const token = randomUUID();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
||||
if (acquired) {
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
if ((await this.redis.get(lockKey)) === token) {
|
||||
await this.redis.del(lockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error('토너먼트 요청이 처리 중입니다. 잠시 후 다시 시도해주세요.');
|
||||
}
|
||||
|
||||
async getState(): Promise<TournamentState | null> {
|
||||
return safeJsonParse<TournamentState>(await this.redis.get(this.keys.stateKey));
|
||||
@@ -54,7 +91,14 @@ export class TournamentStore {
|
||||
|
||||
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
|
||||
const entries = await this.getBettingEntries();
|
||||
entries.push(entry);
|
||||
const existing = entries.find(
|
||||
(candidate) => candidate.generalId === entry.generalId && candidate.targetId === entry.targetId
|
||||
);
|
||||
if (existing) {
|
||||
existing.amount += entry.amount;
|
||||
} else {
|
||||
entries.push(entry);
|
||||
}
|
||||
await this.setBettingEntries(entries);
|
||||
return entries;
|
||||
}
|
||||
|
||||
@@ -518,7 +518,6 @@ export const settleTournamentOutcome = async (options: {
|
||||
return settledState;
|
||||
};
|
||||
|
||||
|
||||
export const runTournamentWorker = async (): Promise<void> => {
|
||||
const config = resolveGameApiConfigFromEnv();
|
||||
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
||||
@@ -555,25 +554,36 @@ export const runTournamentWorker = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
try {
|
||||
const worldState = await postgres.prisma.worldState.findFirst();
|
||||
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
||||
let nextState = state;
|
||||
if (isBattleStage(state.stage)) {
|
||||
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
|
||||
} else if (isPreBattleStage(state.stage)) {
|
||||
nextState = await applyPreBattleStage(
|
||||
store,
|
||||
postgres.prisma,
|
||||
state,
|
||||
String(baseSeed),
|
||||
daemonTransport
|
||||
);
|
||||
}
|
||||
await store.withMutationLock(async () => {
|
||||
const lockedState = await store.getState();
|
||||
if (!lockedState || !lockedState.auto) {
|
||||
return;
|
||||
}
|
||||
const lockedNextAt = new Date(lockedState.nextAt).getTime();
|
||||
if (Number.isFinite(lockedNextAt) && lockedNextAt > Date.now()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await settleTournamentOutcome({
|
||||
store,
|
||||
daemonTransport,
|
||||
state: nextState,
|
||||
const worldState = await postgres.prisma.worldState.findFirst();
|
||||
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
||||
let nextState = lockedState;
|
||||
if (isBattleStage(lockedState.stage)) {
|
||||
nextState = await applyBattle(store, lockedState, String(baseSeed), daemonTransport);
|
||||
} else if (isPreBattleStage(lockedState.stage)) {
|
||||
nextState = await applyPreBattleStage(
|
||||
store,
|
||||
postgres.prisma,
|
||||
lockedState,
|
||||
String(baseSeed),
|
||||
daemonTransport
|
||||
);
|
||||
}
|
||||
|
||||
await settleTournamentOutcome({
|
||||
store,
|
||||
daemonTransport,
|
||||
state: nextState,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
@@ -652,7 +652,7 @@ export const seedNpcBets = async (options: {
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: npcBetList.map((npc) => ({
|
||||
generalId: npc.id as number,
|
||||
metaDelta: { rank_betgold: betGold },
|
||||
metaDelta: { betgold: betGold },
|
||||
})),
|
||||
});
|
||||
await store.setBettingEntries(entries);
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
class MemoryRedis {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
async set(key: string, value: string, options?: { NX?: boolean }): Promise<string | null> {
|
||||
if (options?.NX && this.values.has(key)) {
|
||||
return null;
|
||||
}
|
||||
this.values.set(key, value);
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
async del(key: string): Promise<number> {
|
||||
return this.values.delete(key) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
class TournamentTransport implements TurnDaemonTransport {
|
||||
readonly commands: TurnDaemonCommand[] = [];
|
||||
readonly gold = new Map<number, number>();
|
||||
failNextRankUpdate = false;
|
||||
|
||||
async sendCommand(command: TurnDaemonCommand): Promise<string> {
|
||||
this.commands.push(command);
|
||||
return `command-${this.commands.length}`;
|
||||
}
|
||||
|
||||
async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> {
|
||||
this.commands.push(command);
|
||||
if (command.type === 'adjustGeneralResources') {
|
||||
const adjustment = command.adjustments[0];
|
||||
if (!adjustment) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
||||
}
|
||||
const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0);
|
||||
if (nextGold < (adjustment.minGoldAfter ?? 0)) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
|
||||
}
|
||||
this.gold.set(adjustment.generalId, nextGold);
|
||||
return {
|
||||
type: 'adjustGeneralResources',
|
||||
ok: true,
|
||||
processed: 1,
|
||||
missing: 0,
|
||||
totalGoldDelta: adjustment.goldDelta ?? 0,
|
||||
totalRiceDelta: 0,
|
||||
};
|
||||
}
|
||||
if (command.type === 'setMySetting') {
|
||||
return { type: 'setMySetting', ok: true, generalId: command.generalId };
|
||||
}
|
||||
if (command.type === 'adjustGeneralMeta') {
|
||||
if (this.failNextRankUpdate) {
|
||||
this.failNextRankUpdate = false;
|
||||
return { type: 'adjustGeneralMeta', ok: false, reason: 'rank update failed' };
|
||||
}
|
||||
return { type: 'adjustGeneralMeta', ok: true, processed: 1, missing: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async requestStatus() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const buildGeneral = (id: number, userId: string, gold = 2_000): GeneralRow =>
|
||||
({
|
||||
id,
|
||||
userId,
|
||||
name: `장수${id}`,
|
||||
leadership: 70 + id,
|
||||
strength: 60 + id,
|
||||
intel: 50 + id,
|
||||
gold,
|
||||
meta: { explevel: 5 },
|
||||
}) as unknown as GeneralRow;
|
||||
|
||||
const buildAuth = (userId: string, roles: string[] = []): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: `session-${userId}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: userId,
|
||||
displayName: userId,
|
||||
roles,
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildContext = (options: {
|
||||
redis: MemoryRedis;
|
||||
transport: TournamentTransport;
|
||||
generals: GeneralRow[];
|
||||
userId: string;
|
||||
roles?: string[];
|
||||
develCost?: number;
|
||||
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
||||
}): GameApiContext => {
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: async ({ where }: { where: { userId: string } }) =>
|
||||
options.generals.find((general) => general.userId === where.userId) ?? null,
|
||||
findMany: async ({ where }: { where: { id: { in: number[] } } }) =>
|
||||
options.generals.filter((general) => where.id.in.includes(general.id)),
|
||||
},
|
||||
rankData: {
|
||||
findMany: async () => options.rankRows ?? [],
|
||||
},
|
||||
worldState: {
|
||||
findFirst: async () => ({ config: { const: { develCost: options.develCost ?? 200 } } }),
|
||||
},
|
||||
} as unknown as DatabaseClient;
|
||||
return {
|
||||
db,
|
||||
redis: options.redis as unknown as RedisConnector['client'],
|
||||
turnDaemon: options.transport,
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
auth: buildAuth(options.userId, options.roles),
|
||||
accessTokenStore: new RedisAccessTokenStore(options.redis, 'che:default'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
};
|
||||
|
||||
const setTournamentFixture = async (redis: MemoryRedis, state: Record<string, unknown>) => {
|
||||
const prefix = 'sammo:che:default:tournament';
|
||||
await redis.set(`${prefix}:state`, JSON.stringify(state));
|
||||
await redis.set(
|
||||
`${prefix}:participants`,
|
||||
JSON.stringify([
|
||||
{ id: 11, name: '후보11', leadership: 80, strength: 70, intel: 60, level: 5 },
|
||||
{ id: 12, name: '후보12', leadership: 70, strength: 80, intel: 60, level: 5 },
|
||||
])
|
||||
);
|
||||
await redis.set(
|
||||
`${prefix}:matches`,
|
||||
JSON.stringify([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }])
|
||||
);
|
||||
await redis.set(`${prefix}:betting`, '[]');
|
||||
};
|
||||
|
||||
describe('tournament router permissions and mutations', () => {
|
||||
it('charges the authenticated general once when joining', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1');
|
||||
transport.gold.set(general.id, general.gold);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 1,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
});
|
||||
await redis.set('sammo:che:default:tournament:participants', '[]');
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-1', develCost: 200 })
|
||||
);
|
||||
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
|
||||
expect(transport.gold.get(general.id)).toBe(1_800);
|
||||
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('serializes concurrent bets and enforces the legacy per-user 1000 limit', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1', 3_000);
|
||||
transport.gold.set(general.id, general.gold);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-1' })
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
caller.tournament.placeBet({ targetId: 11, amount: 600 }),
|
||||
caller.tournament.placeBet({ targetId: 12, amount: 600 }),
|
||||
]);
|
||||
|
||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
||||
const summary = await caller.tournament.getBettingSummary();
|
||||
expect(summary.myAmount).toBe(600);
|
||||
expect(summary.totalAmount).toBe(600);
|
||||
expect(transport.gold.get(general.id)).toBe(2_400);
|
||||
});
|
||||
|
||||
it('keeps another user from reading my bet identity and requires that user to own a general', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const owner = buildGeneral(1, 'user-1');
|
||||
const other = buildGeneral(2, 'user-2');
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
});
|
||||
await redis.set(
|
||||
'sammo:che:default:tournament:betting',
|
||||
JSON.stringify([{ generalId: owner.id, targetId: 11, amount: 300 }])
|
||||
);
|
||||
|
||||
const otherCaller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [owner, other], userId: 'user-2' })
|
||||
);
|
||||
const snapshot = await otherCaller.tournament.getSnapshot();
|
||||
expect(snapshot).toMatchObject({ betCount: 1 });
|
||||
expect(snapshot).not.toHaveProperty('bets');
|
||||
expect((await otherCaller.tournament.getBettingSummary()).myAmount).toBe(0);
|
||||
|
||||
const generalLessCaller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [owner], userId: 'user-3' })
|
||||
);
|
||||
await expect(generalLessCaller.tournament.getSnapshot()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
await expect(generalLessCaller.tournament.join()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('limits tournament administration to global or profile-scoped roles', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1');
|
||||
const userCaller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-1', roles: ['user'] })
|
||||
);
|
||||
await expect(userCaller.tournament.getAdminStatus()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
|
||||
const adminCaller = appRouter.createCaller(
|
||||
buildContext({
|
||||
redis,
|
||||
transport,
|
||||
generals: [general],
|
||||
userId: 'user-1',
|
||||
roles: ['admin.tournament:che:default'],
|
||||
})
|
||||
);
|
||||
await expect(adminCaller.tournament.getAdminStatus()).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns the legacy tournament rank ordering only to a user who owns a general', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const first = buildGeneral(1, 'user-1');
|
||||
const second = buildGeneral(2, 'user-2');
|
||||
const rankRows = [
|
||||
{ generalId: first.id, type: 'ttg', value: 20 },
|
||||
{ generalId: first.id, type: 'ttw', value: 3 },
|
||||
{ generalId: second.id, type: 'ttg', value: 20 },
|
||||
{ generalId: second.id, type: 'ttw', value: 4 },
|
||||
];
|
||||
const ownerCaller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [first, second], userId: 'user-1', rankRows })
|
||||
);
|
||||
const sections = await ownerCaller.tournament.getRankings();
|
||||
expect(sections).toHaveLength(4);
|
||||
expect(sections[0]?.entries.map((entry) => entry.generalId)).toEqual([second.id, first.id]);
|
||||
|
||||
const generalLessCaller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [first, second], userId: 'user-3', rankRows })
|
||||
);
|
||||
await expect(generalLessCaller.tournament.getRankings()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('refunds gold when the tournament bet rank update fails', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1', 2_000);
|
||||
transport.gold.set(general.id, general.gold);
|
||||
transport.failNextRankUpdate = true;
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 6,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-1' })
|
||||
);
|
||||
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
});
|
||||
expect(transport.gold.get(general.id)).toBe(2_000);
|
||||
await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({
|
||||
totalAmount: 0,
|
||||
myAmount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -209,6 +209,33 @@ const runTournamentToCompletion = async (options: {
|
||||
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe('tournament worker (in-memory)', () => {
|
||||
it('locks 64 applicants into eight groups of eight', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const store = new TournamentStore(redis, buildTournamentKeys('test-groups'));
|
||||
const state = createTournamentState({ stage: 1 });
|
||||
await store.setParticipants(createParticipants(16, 16, 32));
|
||||
await store.setState(state);
|
||||
|
||||
const next = await applyPreBattleStage(
|
||||
store,
|
||||
createPrismaMock({ baseSeed: 'seed' }),
|
||||
state,
|
||||
'seed',
|
||||
createNoopDaemonTransport()
|
||||
);
|
||||
const grouped = await store.getParticipants();
|
||||
|
||||
expect(next).toMatchObject({ stage: 2, phase: 0 });
|
||||
expect(next.participantsLockedAt).toBeTruthy();
|
||||
for (let groupId = 0; groupId < 8; groupId += 1) {
|
||||
const entries = grouped.filter((entry) => entry.groupId === groupId);
|
||||
expect(entries).toHaveLength(8);
|
||||
expect(entries.map((entry) => entry.groupNo).sort((a, b) => Number(a) - Number(b))).toEqual([
|
||||
0, 1, 2, 3, 4, 5, 6, 7,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('64명 고정 seed 대진에서 15번 참가자가 결승을 이긴다', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const store = new TournamentStore(redis, buildTournamentKeys('test'));
|
||||
|
||||
@@ -198,6 +198,7 @@ const zAdjustGeneralResources = z.object({
|
||||
generalId: zFiniteNumber,
|
||||
goldDelta: zFiniteNumber.optional(),
|
||||
riceDelta: zFiniteNumber.optional(),
|
||||
minGoldAfter: zFiniteNumber.optional(),
|
||||
})
|
||||
.refine((value) => value.goldDelta !== undefined || value.riceDelta !== undefined)
|
||||
)
|
||||
|
||||
@@ -147,7 +147,8 @@ async function handleAdjustGeneralResources(
|
||||
}
|
||||
const nextGold = general.gold + (adjustment.goldDelta ?? 0);
|
||||
const nextRice = general.rice + (adjustment.riceDelta ?? 0);
|
||||
if (nextGold < 0 || nextRice < 0) {
|
||||
const minGoldAfter = adjustment.minGoldAfter ?? 0;
|
||||
if (nextGold < minGoldAfter || nextRice < 0) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
|
||||
}
|
||||
}
|
||||
@@ -260,7 +261,7 @@ async function handleTournamentMatchResult(
|
||||
};
|
||||
}
|
||||
|
||||
const rankKey = (suffix: string): string => `rank_${prefix}${suffix}`;
|
||||
const rankKey = (suffix: string): string => `${prefix}${suffix}`;
|
||||
const getRankNumber = (general: TurnGeneral | null, key: string): number =>
|
||||
general && typeof general.meta[key] === 'number' ? Number(general.meta[key]) : 0;
|
||||
|
||||
@@ -1074,8 +1075,8 @@ async function handleTournamentBettingPayout(
|
||||
continue;
|
||||
}
|
||||
const nextMeta = { ...general.meta } as TurnGeneral['meta'];
|
||||
const betwinKey = 'rank_betwin';
|
||||
const betwingoldKey = 'rank_betwingold';
|
||||
const betwinKey = 'betwin';
|
||||
const betwingoldKey = 'betwingold';
|
||||
const currentBetwin = typeof nextMeta[betwinKey] === 'number' ? Number(nextMeta[betwinKey]) : 0;
|
||||
const currentBetwingold = typeof nextMeta[betwingoldKey] === 'number' ? Number(nextMeta[betwingoldKey]) : 0;
|
||||
nextMeta[betwinKey] = currentBetwin + delta.betwin;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
|
||||
const buildGeneral = (id: number, meta: Record<string, number> = {}): TurnGeneral => ({
|
||||
id,
|
||||
name: `장수${id}`,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
turnTime: new Date('0180-01-01T00:00:00Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24, ...meta },
|
||||
penalty: {},
|
||||
officerLevel: 1,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 100,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
});
|
||||
|
||||
const buildWorld = (generals: TurnGeneral[]): InMemoryTurnWorld => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 180,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
|
||||
meta: {},
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals,
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'test' },
|
||||
},
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
};
|
||||
return new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
};
|
||||
|
||||
describe('tournament world commands', () => {
|
||||
it('updates the persisted tt rank keys for a tournament match', async () => {
|
||||
const world = buildWorld([
|
||||
buildGeneral(1, { ttg: 10, ttw: 2 }),
|
||||
buildGeneral(2, { ttg: 5, ttl: 1 }),
|
||||
]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'tournamentMatchResult',
|
||||
tournamentType: 0,
|
||||
attackerId: 1,
|
||||
defenderId: 2,
|
||||
result: 'attacker',
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
expect(world.getGeneralById(1)?.meta).toMatchObject({ ttg: 11, ttw: 3 });
|
||||
expect(world.getGeneralById(2)?.meta).toMatchObject({ ttg: 5, ttl: 2 });
|
||||
expect(world.getGeneralById(1)?.meta).not.toHaveProperty('rank_ttg');
|
||||
});
|
||||
|
||||
it('updates persisted betting ranks when paying a winner', async () => {
|
||||
const world = buildWorld([buildGeneral(1, { betwin: 2, betwingold: 100 })]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'tournamentBettingPayout',
|
||||
bettingId: 1,
|
||||
payouts: [{ generalId: 1, amount: 500 }],
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, totalPayout: 500 });
|
||||
|
||||
expect(world.getGeneralById(1)).toMatchObject({
|
||||
gold: 1_500,
|
||||
meta: { betwin: 3, betwingold: 600 },
|
||||
});
|
||||
expect(world.getGeneralById(1)?.meta).not.toHaveProperty('rank_betwin');
|
||||
});
|
||||
|
||||
it('enforces a command-specific minimum remaining gold atomically', async () => {
|
||||
const world = buildWorld([buildGeneral(1)]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'adjustGeneralResources',
|
||||
adjustments: [{ generalId: 1, goldDelta: -501, minGoldAfter: 500 }],
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false });
|
||||
expect(world.getGeneralById(1)?.gold).toBe(1_000);
|
||||
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'adjustGeneralResources',
|
||||
adjustments: [{ generalId: 1, goldDelta: -500, minGoldAfter: 500 }],
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(world.getGeneralById(1)?.gold).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -4,18 +4,10 @@ import path from 'node:path';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
resolvePostgresConfigFromEnv,
|
||||
type GatewayPrisma,
|
||||
} from '@sammo-ts/infra';
|
||||
import { createGamePostgresConnector, resolvePostgresConfigFromEnv, type GatewayPrisma } from '@sammo-ts/infra';
|
||||
|
||||
import { procedure, router } from './trpc.js';
|
||||
import {
|
||||
listScenarioPreviews,
|
||||
resolveGitBranchCommitSha,
|
||||
resolveGitCommitSha,
|
||||
} from './scenario/scenarioCatalog.js';
|
||||
import { listScenarioPreviews, resolveGitBranchCommitSha, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
|
||||
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
||||
import { toPublicUser } from './auth/userRepository.js';
|
||||
import type { AdminAuthContext } from './adminAuth.js';
|
||||
@@ -40,15 +32,7 @@ const zServerAction = z.enum([
|
||||
]);
|
||||
|
||||
const TURN_TERM_MINUTES = [1, 2, 5, 10, 20, 30, 60, 120] as const;
|
||||
const AUTORUN_USER_OPTIONS = [
|
||||
'develop',
|
||||
'warp',
|
||||
'recruit',
|
||||
'recruit_high',
|
||||
'train',
|
||||
'battle',
|
||||
'chief',
|
||||
] as const;
|
||||
const AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const;
|
||||
|
||||
const ADMIN_ROLE_PREFIX = 'admin.';
|
||||
const ADMIN_ROLE_SUPERUSER = 'admin.superuser';
|
||||
@@ -254,9 +238,12 @@ const isUniqueConstraintError = (error: unknown): boolean =>
|
||||
|
||||
const zInstallOptions = z.object({
|
||||
scenarioId: z.number().int().min(0),
|
||||
turnTermMinutes: z.number().int().refine((value) => isAllowedTurnTerm(value), {
|
||||
message: 'turnTermMinutes must divide 120.',
|
||||
}),
|
||||
turnTermMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) => isAllowedTurnTerm(value), {
|
||||
message: 'turnTermMinutes must divide 120.',
|
||||
}),
|
||||
sync: z.boolean(),
|
||||
fiction: z.number().int().min(0).max(1),
|
||||
extend: z.boolean(),
|
||||
@@ -771,55 +758,51 @@ export const adminRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
cancel: adminProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
const previous = await ctx.profiles.getOperation(input.id);
|
||||
if (!previous) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
||||
}
|
||||
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
|
||||
const cancelled = await ctx.profiles.cancelOperation(input.id);
|
||||
if (!cancelled) {
|
||||
cancel: adminProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
const previous = await ctx.profiles.getOperation(input.id);
|
||||
if (!previous) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
||||
}
|
||||
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
|
||||
const cancelled = await ctx.profiles.cancelOperation(input.id);
|
||||
if (!cancelled) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Only queued operations can be cancelled.',
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
retry: adminProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
const previous = await ctx.profiles.getOperation(input.id);
|
||||
if (!previous) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
||||
}
|
||||
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
|
||||
try {
|
||||
const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id);
|
||||
if (!operation) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Only queued operations can be cancelled.',
|
||||
message: 'Only failed or cancelled operations can be retried.',
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
retry: adminProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
const previous = await ctx.profiles.getOperation(input.id);
|
||||
if (!previous) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
||||
return operation;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
|
||||
try {
|
||||
const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id);
|
||||
if (!operation) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Only failed or cancelled operations can be retried.',
|
||||
});
|
||||
}
|
||||
return operation;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
if (!isUniqueConstraintError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'This profile already has a queued or running operation.',
|
||||
});
|
||||
if (!isUniqueConstraintError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'This profile already has a queued or running operation.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
profiles: router({
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
@@ -834,6 +817,7 @@ export const adminRouter = router({
|
||||
profileName: profile.profileName,
|
||||
apiRunning: false,
|
||||
daemonRunning: false,
|
||||
tournamentRunning: false,
|
||||
},
|
||||
}));
|
||||
}),
|
||||
|
||||
@@ -26,6 +26,7 @@ export type LobbyProfileStatus = {
|
||||
runtime: {
|
||||
apiRunning: boolean;
|
||||
daemonRunning: boolean;
|
||||
tournamentRunning: boolean;
|
||||
};
|
||||
korName: string;
|
||||
color: string;
|
||||
@@ -68,7 +69,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
||||
|
||||
private mapProfile(
|
||||
row: GatewayProfileRecord,
|
||||
runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean }>
|
||||
runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }>
|
||||
): LobbyProfileStatus {
|
||||
const meta = row.meta;
|
||||
return {
|
||||
@@ -80,6 +81,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
||||
runtime: runtimeMap.get(row.profileName) ?? {
|
||||
apiRunning: false,
|
||||
daemonRunning: false,
|
||||
tournamentRunning: false,
|
||||
},
|
||||
korName: (meta.korName as string | undefined) ?? row.profile,
|
||||
color: (meta.color as string | undefined) ?? '#ffffff',
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions {
|
||||
export interface ProfileRuntimeState {
|
||||
apiRunning: boolean;
|
||||
daemonRunning: boolean;
|
||||
tournamentRunning: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileRuntimeSnapshot extends ProfileRuntimeState {
|
||||
@@ -65,13 +66,13 @@ export const planProfileReconcile = (
|
||||
): { shouldStart: boolean; shouldStop: boolean } => {
|
||||
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
||||
return {
|
||||
shouldStart: !(runtime.apiRunning && runtime.daemonRunning),
|
||||
shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning),
|
||||
shouldStop: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldStart: false,
|
||||
shouldStop: runtime.apiRunning || runtime.daemonRunning,
|
||||
shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -197,14 +198,18 @@ const parseInstallOptions = (
|
||||
: undefined;
|
||||
const sync = typeof install.sync === 'boolean' ? install.sync : undefined;
|
||||
const fiction =
|
||||
typeof install.fiction === 'number' && Number.isFinite(install.fiction) ? Math.floor(install.fiction) : undefined;
|
||||
typeof install.fiction === 'number' && Number.isFinite(install.fiction)
|
||||
? Math.floor(install.fiction)
|
||||
: undefined;
|
||||
const extend = typeof install.extend === 'boolean' ? install.extend : undefined;
|
||||
const blockGeneralCreate =
|
||||
typeof install.blockGeneralCreate === 'number' && Number.isFinite(install.blockGeneralCreate)
|
||||
? Math.floor(install.blockGeneralCreate)
|
||||
: undefined;
|
||||
const npcMode =
|
||||
typeof install.npcMode === 'number' && Number.isFinite(install.npcMode) ? Math.floor(install.npcMode) : undefined;
|
||||
typeof install.npcMode === 'number' && Number.isFinite(install.npcMode)
|
||||
? Math.floor(install.npcMode)
|
||||
: undefined;
|
||||
const showImgLevel =
|
||||
typeof install.showImgLevel === 'number' && Number.isFinite(install.showImgLevel)
|
||||
? Math.floor(install.showImgLevel)
|
||||
@@ -241,9 +246,7 @@ const parseInstallOptions = (
|
||||
? install.adminUser.username
|
||||
: install.adminUser.id,
|
||||
displayName:
|
||||
typeof install.adminUser.displayName === 'string'
|
||||
? install.adminUser.displayName
|
||||
: undefined,
|
||||
typeof install.adminUser.displayName === 'string' ? install.adminUser.displayName : undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -270,8 +273,8 @@ const parseInstallOptions = (
|
||||
};
|
||||
};
|
||||
|
||||
const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string =>
|
||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`;
|
||||
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string =>
|
||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`;
|
||||
|
||||
const isMissingProcessError = (error: unknown): boolean =>
|
||||
error instanceof Error && /process or namespace not found/i.test(error.message);
|
||||
@@ -282,10 +285,12 @@ export const buildProcessDefinitions = (
|
||||
): {
|
||||
api: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||
daemon: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||
} => {
|
||||
const baseEnv = { ...(config.baseEnv ?? {}) };
|
||||
const apiName = buildProcessName(profile.profileName, 'api');
|
||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
||||
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
||||
const daemonCwd = path.join(runtimeWorkspace, 'app', 'game-engine');
|
||||
@@ -322,6 +327,15 @@ export const buildProcessDefinitions = (
|
||||
cwd: daemonCwd,
|
||||
env: daemonEnv,
|
||||
},
|
||||
tournament: {
|
||||
name: tournamentName,
|
||||
script: apiScript,
|
||||
cwd: apiCwd,
|
||||
env: {
|
||||
...apiEnv,
|
||||
GAME_API_ROLE: 'tournament-worker',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -362,10 +376,12 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map<string, bool
|
||||
profileNames.map((profileName) => {
|
||||
const apiName = buildProcessName(profileName, 'api');
|
||||
const daemonName = buildProcessName(profileName, 'daemon');
|
||||
const tournamentName = buildProcessName(profileName, 'tournament');
|
||||
return {
|
||||
profileName,
|
||||
apiRunning: processNames.get(apiName) ?? false,
|
||||
daemonRunning: processNames.get(daemonName) ?? false,
|
||||
tournamentRunning: processNames.get(tournamentName) ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -756,8 +772,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
this.buildInFlight = true;
|
||||
this.resetInFlight.add(profile.profileName);
|
||||
try {
|
||||
const { installOptions, scenarioId: installScenarioId, adminUser, openAt, preopenAt } =
|
||||
parseInstallOptions(action);
|
||||
const {
|
||||
installOptions,
|
||||
scenarioId: installScenarioId,
|
||||
adminUser,
|
||||
openAt,
|
||||
preopenAt,
|
||||
} = parseInstallOptions(action);
|
||||
const tickOverride =
|
||||
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
|
||||
const seedInfo = await this.resolveResetSeedInfo(profile, {
|
||||
@@ -850,7 +871,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
private async resolveResetSeedInfo(
|
||||
profile: GatewayProfileRecord,
|
||||
overrides?: { scenarioId?: number | null; tickSeconds?: number }
|
||||
): Promise<{ databaseUrl: string; scenarioId: number | null; tickSeconds?: number; meta: Record<string, unknown> } > {
|
||||
): Promise<{
|
||||
databaseUrl: string;
|
||||
scenarioId: number | null;
|
||||
tickSeconds?: number;
|
||||
meta: Record<string, unknown>;
|
||||
}> {
|
||||
const databaseUrl = resolvePostgresConfigFromEnv({
|
||||
env: this.processConfig.baseEnv ?? process.env,
|
||||
schema: profile.profile,
|
||||
@@ -871,7 +897,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
scenarioId = resolvedScenario;
|
||||
}
|
||||
}
|
||||
if (tickSeconds === undefined && typeof row.tickSeconds === 'number' && Number.isFinite(row.tickSeconds)) {
|
||||
if (
|
||||
tickSeconds === undefined &&
|
||||
typeof row.tickSeconds === 'number' &&
|
||||
Number.isFinite(row.tickSeconds)
|
||||
) {
|
||||
tickSeconds = row.tickSeconds;
|
||||
}
|
||||
meta = normalizeMeta(row.meta);
|
||||
@@ -889,11 +919,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
const workspace = await this.workspaceManager.prepare(commitSha);
|
||||
const lastUsedAt = this.now().toISOString();
|
||||
await this.repository.updateWorkspaceUsage(profileName, workspace.root, lastUsedAt);
|
||||
const commands = buildWorkspaceCommands(
|
||||
workspace.root,
|
||||
workspace.needsInstall,
|
||||
this.processConfig.baseEnv
|
||||
);
|
||||
const commands = buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv);
|
||||
return this.buildRunner.run(commands);
|
||||
}
|
||||
|
||||
@@ -955,6 +981,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
try {
|
||||
await this.processManager.start(definitions.api);
|
||||
await this.processManager.start(definitions.daemon);
|
||||
await this.processManager.start(definitions.tournament);
|
||||
await this.repository.updateLastError(profile.profileName, null);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -969,9 +996,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
||||
const apiName = buildProcessName(profile.profileName, 'api');
|
||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
|
||||
const failures: string[] = [];
|
||||
for (const name of [apiName, daemonName]) {
|
||||
for (const name of [apiName, daemonName, tournamentName]) {
|
||||
if (!existingNames.has(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ const createHarness = (
|
||||
? [
|
||||
{ name: 'sammo:che:2:game-api', status: 'online' },
|
||||
{ name: 'sammo:che:2:turn-daemon', status: 'online' },
|
||||
{ name: 'sammo:che:2:tournament-worker', status: 'online' },
|
||||
]
|
||||
: [],
|
||||
start: async (definition) => {
|
||||
@@ -146,6 +147,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
expect(harness.started.map((definition) => definition.name)).toEqual([
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:tournament-worker',
|
||||
]);
|
||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||
});
|
||||
@@ -156,8 +158,16 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
await harness.orchestrator.runOperationsNow();
|
||||
|
||||
expect(harness.statuses).toEqual(['STOPPED']);
|
||||
expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
|
||||
expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
|
||||
expect(harness.stopped).toEqual([
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:tournament-worker',
|
||||
]);
|
||||
expect(harness.deleted).toEqual([
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:tournament-worker',
|
||||
]);
|
||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||
});
|
||||
|
||||
@@ -177,7 +187,11 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
|
||||
await harness.orchestrator.runOperationsNow();
|
||||
|
||||
expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
|
||||
expect(harness.deleted).toEqual([
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:tournament-worker',
|
||||
]);
|
||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||
});
|
||||
|
||||
@@ -194,8 +208,16 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
|
||||
await harness.orchestrator.runOperationsNow();
|
||||
|
||||
expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
|
||||
expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
|
||||
expect(harness.stopped).toEqual([
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:tournament-worker',
|
||||
]);
|
||||
expect(harness.deleted).toEqual([
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:tournament-worker',
|
||||
]);
|
||||
expect(harness.completions).toEqual(['FAILED']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('planProfileReconcile', () => {
|
||||
planProfileReconcile('RUNNING', {
|
||||
apiRunning: true,
|
||||
daemonRunning: false,
|
||||
tournamentRunning: true,
|
||||
})
|
||||
).toEqual({ shouldStart: true, shouldStop: false });
|
||||
});
|
||||
@@ -38,6 +39,7 @@ describe('planProfileReconcile', () => {
|
||||
planProfileReconcile('PREOPEN', {
|
||||
apiRunning: false,
|
||||
daemonRunning: false,
|
||||
tournamentRunning: false,
|
||||
})
|
||||
).toEqual({ shouldStart: true, shouldStop: false });
|
||||
});
|
||||
@@ -47,6 +49,7 @@ describe('planProfileReconcile', () => {
|
||||
planProfileReconcile('RUNNING', {
|
||||
apiRunning: true,
|
||||
daemonRunning: true,
|
||||
tournamentRunning: true,
|
||||
})
|
||||
).toEqual({ shouldStart: false, shouldStop: false });
|
||||
});
|
||||
@@ -56,6 +59,7 @@ describe('planProfileReconcile', () => {
|
||||
planProfileReconcile('STOPPED', {
|
||||
apiRunning: false,
|
||||
daemonRunning: true,
|
||||
tournamentRunning: false,
|
||||
})
|
||||
).toEqual({ shouldStart: false, shouldStop: true });
|
||||
});
|
||||
@@ -65,6 +69,7 @@ describe('planProfileReconcile', () => {
|
||||
planProfileReconcile('RESERVED', {
|
||||
apiRunning: false,
|
||||
daemonRunning: false,
|
||||
tournamentRunning: false,
|
||||
})
|
||||
).toEqual({ shouldStart: false, shouldStop: false });
|
||||
});
|
||||
@@ -90,6 +95,11 @@ describe('buildProcessDefinitions', () => {
|
||||
});
|
||||
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
|
||||
expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
|
||||
expect(definitions.tournament).toMatchObject({
|
||||
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
||||
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
||||
env: { GAME_API_ROLE: 'tournament-worker' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps main as the runtime for profiles without a commit worktree', () => {
|
||||
@@ -97,6 +107,7 @@ describe('buildProcessDefinitions', () => {
|
||||
|
||||
expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
|
||||
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({
|
||||
profileName: 'che:2',
|
||||
apiRunning: runtimeRunning,
|
||||
daemonRunning: runtimeRunning,
|
||||
tournamentRunning: runtimeRunning,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -147,13 +148,17 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
await expect(page.getByTestId('source-help')).toContainText('실제로 시작될 때');
|
||||
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
|
||||
|
||||
const desktopGeometry = await page.getByTestId('server-operations-page').locator('section').first().evaluate((section) => {
|
||||
const children = Array.from(section.children).map((child) => {
|
||||
const rect = child.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
const desktopGeometry = await page
|
||||
.getByTestId('server-operations-page')
|
||||
.locator('section')
|
||||
.first()
|
||||
.evaluate((section) => {
|
||||
const children = Array.from(section.children).map((child) => {
|
||||
const rect = child.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
});
|
||||
return children;
|
||||
});
|
||||
return children;
|
||||
});
|
||||
expect(desktopGeometry).toHaveLength(2);
|
||||
expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x);
|
||||
const sourceInput = page.getByTestId('source-ref');
|
||||
@@ -191,13 +196,17 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const mobileGeometry = await page.getByTestId('server-operations-page').locator('section').first().evaluate((section) => {
|
||||
const children = Array.from(section.children).map((child) => {
|
||||
const rect = child.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width };
|
||||
const mobileGeometry = await page
|
||||
.getByTestId('server-operations-page')
|
||||
.locator('section')
|
||||
.first()
|
||||
.evaluate((section) => {
|
||||
const children = Array.from(section.children).map((child) => {
|
||||
const rect = child.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width };
|
||||
});
|
||||
return children;
|
||||
});
|
||||
return children;
|
||||
});
|
||||
expect(mobileGeometry[1]!.y).toBeGreaterThan(mobileGeometry[0]!.y);
|
||||
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390);
|
||||
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
|
||||
|
||||
@@ -70,6 +70,7 @@ type AdminProfile = {
|
||||
runtime: {
|
||||
apiRunning: boolean;
|
||||
daemonRunning: boolean;
|
||||
tournamentRunning: boolean;
|
||||
};
|
||||
buildCommitSha?: string;
|
||||
meta: Record<string, unknown>;
|
||||
@@ -121,15 +122,7 @@ type InstallFormState = {
|
||||
};
|
||||
|
||||
type AdminAction =
|
||||
| 'RESUME'
|
||||
| 'PAUSE'
|
||||
| 'STOP'
|
||||
| 'ACCELERATE'
|
||||
| 'DELAY'
|
||||
| 'RESET_NOW'
|
||||
| 'RESET_SCHEDULED'
|
||||
| 'OPEN_SURVEY'
|
||||
| 'SHUTDOWN';
|
||||
'RESUME' | 'PAUSE' | 'STOP' | 'ACCELERATE' | 'DELAY' | 'RESET_NOW' | 'RESET_SCHEDULED' | 'OPEN_SURVEY' | 'SHUTDOWN';
|
||||
|
||||
type AdminClient = {
|
||||
system: {
|
||||
@@ -436,8 +429,7 @@ const toLocalInputValue = (value: unknown): string => {
|
||||
const readNumber = (value: unknown, fallback: number): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
const readBoolean = (value: unknown, fallback: boolean): boolean =>
|
||||
typeof value === 'boolean' ? value : fallback;
|
||||
const readBoolean = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback);
|
||||
|
||||
const readString = (value: unknown, fallback: string): string => (typeof value === 'string' ? value : fallback);
|
||||
|
||||
@@ -1090,21 +1082,16 @@ onMounted(() => {
|
||||
<div class="text-xs text-zinc-400 mt-2">제재 상태</div>
|
||||
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap"
|
||||
>{{ JSON.stringify(userResult.sanctions, null, 2) }}
|
||||
</pre
|
||||
>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-base font-semibold">로컬 계정 생성</h4>
|
||||
<span class="text-xs text-zinc-500">
|
||||
ENV {{ localAccountEnabled ? 'ON' : 'OFF' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">
|
||||
카카오 OAuth 없이 로그인 가능한 계정을 생성합니다.
|
||||
<span class="text-xs text-zinc-500"> ENV {{ localAccountEnabled ? 'ON' : 'OFF' }} </span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">카카오 OAuth 없이 로그인 가능한 계정을 생성합니다.</div>
|
||||
<div class="grid gap-2">
|
||||
<input
|
||||
v-model="localAccountForm.username"
|
||||
@@ -1365,7 +1352,8 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="text-xs text-zinc-400">
|
||||
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
||||
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }}
|
||||
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / TOURNAMENT:
|
||||
{{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1504,7 +1492,9 @@ onMounted(() => {
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold">설치/리셋</h4>
|
||||
<span class="text-xs text-zinc-500">{{ profileInstallStatus[profile.profileName] }}</span>
|
||||
<span class="text-xs text-zinc-500">{{
|
||||
profileInstallStatus[profile.profileName]
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="grid lg:grid-cols-2 gap-4">
|
||||
<div class="space-y-3">
|
||||
@@ -1525,7 +1515,9 @@ onMounted(() => {
|
||||
불러오기
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">비워두면 현재 저장소 기준으로 불러옵니다.</div>
|
||||
<div class="text-xs text-zinc-500">
|
||||
비워두면 현재 저장소 기준으로 불러옵니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
@@ -1535,16 +1527,28 @@ onMounted(() => {
|
||||
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
:disabled="getScenarioLoading(profile.profileName)"
|
||||
>
|
||||
<option v-if="getScenarioLoading(profile.profileName)" disabled>불러오는 중...</option>
|
||||
<template v-for="(items, group) in getScenarioGroups(profile.profileName)" :key="group">
|
||||
<option v-if="getScenarioLoading(profile.profileName)" disabled>
|
||||
불러오는 중...
|
||||
</option>
|
||||
<template
|
||||
v-for="(items, group) in getScenarioGroups(profile.profileName)"
|
||||
:key="group"
|
||||
>
|
||||
<optgroup :label="group">
|
||||
<option v-for="scenario in items" :key="scenario.id" :value="scenario.id">
|
||||
<option
|
||||
v-for="scenario in items"
|
||||
:key="scenario.id"
|
||||
:value="scenario.id"
|
||||
>
|
||||
{{ scenario.title }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</template>
|
||||
</select>
|
||||
<div v-if="getScenarioStatus(profile.profileName)" class="text-xs text-red-400">
|
||||
<div
|
||||
v-if="getScenarioStatus(profile.profileName)"
|
||||
class="text-xs text-red-400"
|
||||
>
|
||||
{{ getScenarioStatus(profile.profileName) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1553,7 +1557,9 @@ onMounted(() => {
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs text-zinc-400">턴 시간(분)</label>
|
||||
<select
|
||||
v-model.number="profileInstalls[profile.profileName].turnTermMinutes"
|
||||
v-model.number="
|
||||
profileInstalls[profile.profileName].turnTermMinutes
|
||||
"
|
||||
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
>
|
||||
<option v-for="term in turnTermOptions" :key="term" :value="term">
|
||||
@@ -1664,7 +1670,9 @@ onMounted(() => {
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||
<input
|
||||
v-model.number="profileInstalls[profile.profileName].blockGeneralCreate"
|
||||
v-model.number="
|
||||
profileInstalls[profile.profileName].blockGeneralCreate
|
||||
"
|
||||
class="accent-yellow-500"
|
||||
type="radio"
|
||||
:value="0"
|
||||
@@ -1673,7 +1681,9 @@ onMounted(() => {
|
||||
</label>
|
||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||
<input
|
||||
v-model.number="profileInstalls[profile.profileName].blockGeneralCreate"
|
||||
v-model.number="
|
||||
profileInstalls[profile.profileName].blockGeneralCreate
|
||||
"
|
||||
class="accent-yellow-500"
|
||||
type="radio"
|
||||
:value="2"
|
||||
@@ -1682,7 +1692,9 @@ onMounted(() => {
|
||||
</label>
|
||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||
<input
|
||||
v-model.number="profileInstalls[profile.profileName].blockGeneralCreate"
|
||||
v-model.number="
|
||||
profileInstalls[profile.profileName].blockGeneralCreate
|
||||
"
|
||||
class="accent-yellow-500"
|
||||
type="radio"
|
||||
:value="1"
|
||||
@@ -1732,7 +1744,9 @@ onMounted(() => {
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||
<input
|
||||
v-model.number="profileInstalls[profile.profileName].showImgLevel"
|
||||
v-model.number="
|
||||
profileInstalls[profile.profileName].showImgLevel
|
||||
"
|
||||
class="accent-yellow-500"
|
||||
type="radio"
|
||||
:value="0"
|
||||
@@ -1741,7 +1755,9 @@ onMounted(() => {
|
||||
</label>
|
||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||
<input
|
||||
v-model.number="profileInstalls[profile.profileName].showImgLevel"
|
||||
v-model.number="
|
||||
profileInstalls[profile.profileName].showImgLevel
|
||||
"
|
||||
class="accent-yellow-500"
|
||||
type="radio"
|
||||
:value="1"
|
||||
@@ -1750,7 +1766,9 @@ onMounted(() => {
|
||||
</label>
|
||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||
<input
|
||||
v-model.number="profileInstalls[profile.profileName].showImgLevel"
|
||||
v-model.number="
|
||||
profileInstalls[profile.profileName].showImgLevel
|
||||
"
|
||||
class="accent-yellow-500"
|
||||
type="radio"
|
||||
:value="2"
|
||||
@@ -1759,7 +1777,9 @@ onMounted(() => {
|
||||
</label>
|
||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||
<input
|
||||
v-model.number="profileInstalls[profile.profileName].showImgLevel"
|
||||
v-model.number="
|
||||
profileInstalls[profile.profileName].showImgLevel
|
||||
"
|
||||
class="accent-yellow-500"
|
||||
type="radio"
|
||||
:value="3"
|
||||
@@ -1802,7 +1822,11 @@ onMounted(() => {
|
||||
class="flex items-center gap-1 text-xs text-zinc-300"
|
||||
>
|
||||
<input
|
||||
v-model="profileInstalls[profile.profileName].autorunUserOptions[option.key]"
|
||||
v-model="
|
||||
profileInstalls[profile.profileName].autorunUserOptions[
|
||||
option.key
|
||||
]
|
||||
"
|
||||
class="accent-yellow-500"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -1874,30 +1898,41 @@ onMounted(() => {
|
||||
설치 적용
|
||||
</button>
|
||||
|
||||
<div v-if="getScenarioPreview(profile.profileName)" class="bg-zinc-950 border border-zinc-800 rounded p-3 text-xs text-zinc-300 space-y-2">
|
||||
<div
|
||||
v-if="getScenarioPreview(profile.profileName)"
|
||||
class="bg-zinc-950 border border-zinc-800 rounded p-3 text-xs text-zinc-300 space-y-2"
|
||||
>
|
||||
<div class="font-semibold text-zinc-200">
|
||||
{{ getScenarioPreview(profile.profileName)?.title }}
|
||||
</div>
|
||||
<div>시작 연도: {{ getScenarioPreview(profile.profileName)?.year ?? '-' }}년</div>
|
||||
<div>
|
||||
시작 연도: {{ getScenarioPreview(profile.profileName)?.year ?? '-' }}년
|
||||
</div>
|
||||
<div>
|
||||
NPC: {{ getScenarioPreview(profile.profileName)?.npcCount }}명
|
||||
<span v-if="getScenarioPreview(profile.profileName)?.npcExCount">+{{ getScenarioPreview(profile.profileName)?.npcExCount }}명</span>
|
||||
<span v-if="getScenarioPreview(profile.profileName)?.npcExCount"
|
||||
>+{{ getScenarioPreview(profile.profileName)?.npcExCount }}명</span
|
||||
>
|
||||
<span v-if="getScenarioPreview(profile.profileName)?.npcNeutralCount">
|
||||
/ 중립 {{ getScenarioPreview(profile.profileName)?.npcNeutralCount }}명
|
||||
/ 중립
|
||||
{{ getScenarioPreview(profile.profileName)?.npcNeutralCount }}명
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-zinc-400">국가</div>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="nation in getScenarioPreview(profile.profileName)?.nations ?? []"
|
||||
v-for="nation in getScenarioPreview(profile.profileName)
|
||||
?.nations ?? []"
|
||||
:key="nation.id"
|
||||
class="text-[11px]"
|
||||
>
|
||||
<span :style="{ color: nation.color }">{{ nation.name }}</span>
|
||||
{{ nation.generals }}명
|
||||
<span v-if="nation.generalsEx">(+{{ nation.generalsEx }})</span>
|
||||
<span class="text-zinc-500">· {{ nation.cities.join(', ') }}</span>
|
||||
<span class="text-zinc-500"
|
||||
>· {{ nation.cities.join(', ') }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ type Profile = {
|
||||
buildWorkspace?: string;
|
||||
buildError?: string;
|
||||
lastError?: string;
|
||||
runtime: { apiRunning: boolean; daemonRunning: boolean };
|
||||
runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean };
|
||||
};
|
||||
|
||||
type Scenario = {
|
||||
@@ -192,9 +192,7 @@ const requestRuntime = async (action: 'START' | 'STOP') => {
|
||||
}
|
||||
};
|
||||
|
||||
const selectedAutorunOptions = (): Array<
|
||||
'develop' | 'warp' | 'recruit' | 'train' | 'battle'
|
||||
> => {
|
||||
const selectedAutorunOptions = (): Array<'develop' | 'warp' | 'recruit' | 'train' | 'battle'> => {
|
||||
const options: Array<'develop' | 'warp' | 'recruit' | 'train' | 'battle'> = [];
|
||||
if (form.autorunDevelop) options.push('develop');
|
||||
if (form.autorunWarp) options.push('warp');
|
||||
@@ -331,7 +329,10 @@ onBeforeUnmount(() => {
|
||||
<div v-if="errorMessage" class="rounded border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-200">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<div v-if="message" class="rounded border border-emerald-800 bg-emerald-950/40 px-4 py-3 text-sm text-emerald-200">
|
||||
<div
|
||||
v-if="message"
|
||||
class="rounded border border-emerald-800 bg-emerald-950/40 px-4 py-3 text-sm text-emerald-200"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
@@ -376,12 +377,27 @@ onBeforeUnmount(() => {
|
||||
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-zinc-950 p-3">
|
||||
<div class="text-xs text-zinc-500">Tournament worker</div>
|
||||
<div
|
||||
:class="
|
||||
selectedProfile.runtime.tournamentRunning ? 'text-emerald-400' : 'text-zinc-500'
|
||||
"
|
||||
>
|
||||
{{ selectedProfile.runtime.tournamentRunning ? 'RUNNING' : 'STOPPED' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedProfile" class="space-y-1 text-xs text-zinc-500">
|
||||
<div>현재 커밋: <span class="font-mono text-zinc-300">{{ shortSha(selectedProfile.buildCommitSha) }}</span></div>
|
||||
<div>
|
||||
현재 커밋:
|
||||
<span class="font-mono text-zinc-300">{{ shortSha(selectedProfile.buildCommitSha) }}</span>
|
||||
</div>
|
||||
<div class="break-all">worktree: {{ selectedProfile.buildWorkspace ?? '기본 workspace' }}</div>
|
||||
<div v-if="selectedProfile.buildError" class="text-red-400">{{ selectedProfile.buildError }}</div>
|
||||
<div v-if="selectedProfile.buildError" class="text-red-400">
|
||||
{{ selectedProfile.buildError }}
|
||||
</div>
|
||||
<div v-if="selectedProfile.lastError" class="text-red-400">{{ selectedProfile.lastError }}</div>
|
||||
</div>
|
||||
|
||||
@@ -405,10 +421,16 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-5" @submit.prevent="requestReset">
|
||||
<form
|
||||
class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-5"
|
||||
@submit.prevent="requestReset"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">시나리오 초기화</h3>
|
||||
<span v-if="activeOperation" class="rounded-full bg-amber-500/15 px-3 py-1 text-xs text-amber-300">
|
||||
<span
|
||||
v-if="activeOperation"
|
||||
class="rounded-full bg-amber-500/15 px-3 py-1 text-xs text-amber-300"
|
||||
>
|
||||
{{ activeOperation.type }} · {{ activeOperation.status }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -417,11 +439,21 @@ onBeforeUnmount(() => {
|
||||
<legend class="text-xs text-zinc-400">소스 종류</legend>
|
||||
<div class="flex gap-5">
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="form.sourceMode" type="radio" value="BRANCH" data-testid="source-branch" />
|
||||
<input
|
||||
v-model="form.sourceMode"
|
||||
type="radio"
|
||||
value="BRANCH"
|
||||
data-testid="source-branch"
|
||||
/>
|
||||
브랜치
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="form.sourceMode" type="radio" value="COMMIT" data-testid="source-commit" />
|
||||
<input
|
||||
v-model="form.sourceMode"
|
||||
type="radio"
|
||||
value="COMMIT"
|
||||
data-testid="source-commit"
|
||||
/>
|
||||
커밋
|
||||
</label>
|
||||
</div>
|
||||
@@ -432,7 +464,11 @@ onBeforeUnmount(() => {
|
||||
<input
|
||||
v-model="form.sourceRef"
|
||||
class="rounded border border-zinc-700 bg-zinc-950 px-3 py-2 font-mono text-sm text-white"
|
||||
:placeholder="form.sourceMode === 'BRANCH' ? '예: main 또는 release/season-12' : '예: 40자리 commit SHA'"
|
||||
:placeholder="
|
||||
form.sourceMode === 'BRANCH'
|
||||
? '예: main 또는 release/season-12'
|
||||
: '예: 40자리 commit SHA'
|
||||
"
|
||||
data-testid="source-ref"
|
||||
/>
|
||||
<button
|
||||
@@ -465,7 +501,11 @@ onBeforeUnmount(() => {
|
||||
v-model.number="form.turnTermMinutes"
|
||||
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
|
||||
>
|
||||
<option v-for="minutes in [1, 2, 5, 10, 20, 30, 60, 120]" :key="minutes" :value="minutes">
|
||||
<option
|
||||
v-for="minutes in [1, 2, 5, 10, 20, 30, 60, 120]"
|
||||
:key="minutes"
|
||||
:value="minutes"
|
||||
>
|
||||
{{ minutes }}분
|
||||
</option>
|
||||
</select>
|
||||
@@ -475,39 +515,59 @@ onBeforeUnmount(() => {
|
||||
<details class="rounded border border-zinc-800 bg-zinc-950/50 p-4">
|
||||
<summary class="cursor-pointer text-sm font-semibold">고급 시나리오 옵션</summary>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 text-sm">
|
||||
<label>동기화
|
||||
<label
|
||||
>동기화
|
||||
<select v-model="form.sync" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option :value="true">사용</option><option :value="false">미사용</option>
|
||||
<option :value="true">사용</option>
|
||||
<option :value="false">미사용</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>가상 장수
|
||||
<label
|
||||
>가상 장수
|
||||
<select v-model.number="form.fiction" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option :value="1">허용</option><option :value="0">금지</option>
|
||||
<option :value="1">허용</option>
|
||||
<option :value="0">금지</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>연장
|
||||
<label
|
||||
>연장
|
||||
<select v-model="form.extend" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option :value="true">사용</option><option :value="false">미사용</option>
|
||||
<option :value="true">사용</option>
|
||||
<option :value="false">미사용</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>가입 방식
|
||||
<label
|
||||
>가입 방식
|
||||
<select v-model="form.joinMode" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option value="full">전체</option><option value="onlyRandom">랜덤만</option>
|
||||
<option value="full">전체</option>
|
||||
<option value="onlyRandom">랜덤만</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>장수 생성 제한
|
||||
<select v-model.number="form.blockGeneralCreate" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option :value="0">없음</option><option :value="1">제한</option><option :value="2">차단</option>
|
||||
<label
|
||||
>장수 생성 제한
|
||||
<select
|
||||
v-model.number="form.blockGeneralCreate"
|
||||
class="ml-2 rounded bg-zinc-900 px-2 py-1"
|
||||
>
|
||||
<option :value="0">없음</option>
|
||||
<option :value="1">제한</option>
|
||||
<option :value="2">차단</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>NPC 모드
|
||||
<label
|
||||
>NPC 모드
|
||||
<select v-model.number="form.npcMode" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option :value="0">기본</option><option :value="1">확장</option><option :value="2">전체</option>
|
||||
<option :value="0">기본</option>
|
||||
<option :value="1">확장</option>
|
||||
<option :value="2">전체</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>이미지 표시
|
||||
<label
|
||||
>이미지 표시
|
||||
<select v-model.number="form.showImgLevel" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
||||
<option v-for="level in [0, 1, 2, 3]" :key="level" :value="level">{{ level }}</option>
|
||||
<option v-for="level in [0, 1, 2, 3]" :key="level" :value="level">
|
||||
{{ level }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
@@ -536,14 +596,29 @@ onBeforeUnmount(() => {
|
||||
</details>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="text-xs text-zinc-400">작업 예약
|
||||
<input v-model="form.scheduledAt" type="datetime-local" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" />
|
||||
<label class="text-xs text-zinc-400"
|
||||
>작업 예약
|
||||
<input
|
||||
v-model="form.scheduledAt"
|
||||
type="datetime-local"
|
||||
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
||||
/>
|
||||
</label>
|
||||
<label class="text-xs text-zinc-400">가오픈
|
||||
<input v-model="form.preopenAt" type="datetime-local" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" />
|
||||
<label class="text-xs text-zinc-400"
|
||||
>가오픈
|
||||
<input
|
||||
v-model="form.preopenAt"
|
||||
type="datetime-local"
|
||||
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
||||
/>
|
||||
</label>
|
||||
<label class="text-xs text-zinc-400">정식 오픈
|
||||
<input v-model="form.openAt" type="datetime-local" class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" />
|
||||
<label class="text-xs text-zinc-400"
|
||||
>정식 오픈
|
||||
<input
|
||||
v-model="form.openAt"
|
||||
type="datetime-local"
|
||||
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -572,13 +647,23 @@ onBeforeUnmount(() => {
|
||||
<table class="w-full min-w-[920px] text-left text-sm" data-testid="operations-table">
|
||||
<thead class="border-b border-zinc-700 text-xs text-zinc-500">
|
||||
<tr>
|
||||
<th class="p-2">요청/예약</th><th class="p-2">프로필</th><th class="p-2">작업</th>
|
||||
<th class="p-2">상태</th><th class="p-2">소스</th><th class="p-2">해석 커밋</th>
|
||||
<th class="p-2">요청자/사유</th><th class="p-2">완료/오류</th><th class="p-2"></th>
|
||||
<th class="p-2">요청/예약</th>
|
||||
<th class="p-2">프로필</th>
|
||||
<th class="p-2">작업</th>
|
||||
<th class="p-2">상태</th>
|
||||
<th class="p-2">소스</th>
|
||||
<th class="p-2">해석 커밋</th>
|
||||
<th class="p-2">요청자/사유</th>
|
||||
<th class="p-2">완료/오류</th>
|
||||
<th class="p-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="operation in operations" :key="operation.id" class="border-b border-zinc-800 align-top">
|
||||
<tr
|
||||
v-for="operation in operations"
|
||||
:key="operation.id"
|
||||
class="border-b border-zinc-800 align-top"
|
||||
>
|
||||
<td class="p-2 text-xs">
|
||||
{{ formatTime(operation.createdAt) }}
|
||||
<div v-if="operation.scheduledAt" class="mt-1 text-amber-300">
|
||||
@@ -588,7 +673,9 @@ onBeforeUnmount(() => {
|
||||
<td class="p-2">{{ operation.profileName }}</td>
|
||||
<td class="p-2">{{ operation.type }}</td>
|
||||
<td class="p-2 font-semibold">{{ operation.status }}</td>
|
||||
<td class="p-2 font-mono text-xs">{{ operation.sourceMode ?? '-' }}<br />{{ operation.sourceRef ?? '' }}</td>
|
||||
<td class="p-2 font-mono text-xs">
|
||||
{{ operation.sourceMode ?? '-' }}<br />{{ operation.sourceRef ?? '' }}
|
||||
</td>
|
||||
<td class="p-2 font-mono text-xs">{{ shortSha(operation.resolvedCommitSha) }}</td>
|
||||
<td class="max-w-xs p-2 text-xs">
|
||||
<div class="font-mono">{{ operation.requestedBy }}</div>
|
||||
|
||||
@@ -155,56 +155,57 @@ export type TurnDaemonCommand =
|
||||
updates: Record<string, unknown>;
|
||||
expectedUpdatedAt?: string;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
requestId?: string;
|
||||
reason?: string;
|
||||
adjustments: Array<{
|
||||
generalId: number;
|
||||
goldDelta?: number;
|
||||
riceDelta?: number;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralMeta';
|
||||
requestId?: string;
|
||||
reason?: string;
|
||||
adjustments: Array<{
|
||||
generalId: number;
|
||||
metaDelta: Record<string, number>;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: 'tournamentMatchResult';
|
||||
requestId?: string;
|
||||
tournamentType: number;
|
||||
attackerId: number;
|
||||
defenderId: number;
|
||||
result: 'attacker' | 'defender' | 'draw';
|
||||
}
|
||||
| {
|
||||
type: 'patchGeneral';
|
||||
requestId?: string;
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
requestId?: string;
|
||||
reason?: string;
|
||||
adjustments: Array<{
|
||||
generalId: number;
|
||||
patch: {
|
||||
meta?: Record<string, unknown>;
|
||||
turnTime?: string;
|
||||
stats?: {
|
||||
leadership?: number;
|
||||
strength?: number;
|
||||
intelligence?: number;
|
||||
};
|
||||
specialWar?: string;
|
||||
goldDelta?: number;
|
||||
riceDelta?: number;
|
||||
minGoldAfter?: number;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralMeta';
|
||||
requestId?: string;
|
||||
reason?: string;
|
||||
adjustments: Array<{
|
||||
generalId: number;
|
||||
metaDelta: Record<string, number>;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: 'tournamentMatchResult';
|
||||
requestId?: string;
|
||||
tournamentType: number;
|
||||
attackerId: number;
|
||||
defenderId: number;
|
||||
result: 'attacker' | 'defender' | 'draw';
|
||||
}
|
||||
| {
|
||||
type: 'patchGeneral';
|
||||
requestId?: string;
|
||||
generalId: number;
|
||||
patch: {
|
||||
meta?: Record<string, unknown>;
|
||||
turnTime?: string;
|
||||
stats?: {
|
||||
leadership?: number;
|
||||
strength?: number;
|
||||
intelligence?: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'auctionBid';
|
||||
requestId?: string;
|
||||
auctionId: number;
|
||||
generalId: number;
|
||||
amount: number;
|
||||
tryExtendCloseDate?: boolean;
|
||||
specialWar?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'auctionBid';
|
||||
requestId?: string;
|
||||
auctionId: number;
|
||||
generalId: number;
|
||||
amount: number;
|
||||
tryExtendCloseDate?: boolean;
|
||||
};
|
||||
|
||||
export type TurnDaemonCommandResult =
|
||||
| {
|
||||
@@ -385,70 +386,70 @@ export type TurnDaemonCommandResult =
|
||||
reason: string;
|
||||
currentUpdatedAt?: string;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
ok: true;
|
||||
processed: number;
|
||||
missing: number;
|
||||
totalGoldDelta: number;
|
||||
totalRiceDelta: number;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
ok: false;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralMeta';
|
||||
ok: true;
|
||||
processed: number;
|
||||
missing: number;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralMeta';
|
||||
ok: false;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'tournamentMatchResult';
|
||||
ok: true;
|
||||
tournamentType: number;
|
||||
attackerId: number;
|
||||
defenderId: number;
|
||||
result: 'attacker' | 'defender' | 'draw';
|
||||
}
|
||||
| {
|
||||
type: 'tournamentMatchResult';
|
||||
ok: false;
|
||||
tournamentType: number;
|
||||
attackerId: number;
|
||||
defenderId: number;
|
||||
result: 'attacker' | 'defender' | 'draw';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'patchGeneral';
|
||||
ok: true;
|
||||
generalId: number;
|
||||
}
|
||||
| {
|
||||
type: 'patchGeneral';
|
||||
ok: false;
|
||||
generalId: number;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'auctionBid';
|
||||
ok: true;
|
||||
auctionId: number;
|
||||
closeAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'auctionBid';
|
||||
ok: false;
|
||||
auctionId: number;
|
||||
reason: string;
|
||||
};
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
ok: true;
|
||||
processed: number;
|
||||
missing: number;
|
||||
totalGoldDelta: number;
|
||||
totalRiceDelta: number;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralResources';
|
||||
ok: false;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralMeta';
|
||||
ok: true;
|
||||
processed: number;
|
||||
missing: number;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralMeta';
|
||||
ok: false;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'tournamentMatchResult';
|
||||
ok: true;
|
||||
tournamentType: number;
|
||||
attackerId: number;
|
||||
defenderId: number;
|
||||
result: 'attacker' | 'defender' | 'draw';
|
||||
}
|
||||
| {
|
||||
type: 'tournamentMatchResult';
|
||||
ok: false;
|
||||
tournamentType: number;
|
||||
attackerId: number;
|
||||
defenderId: number;
|
||||
result: 'attacker' | 'defender' | 'draw';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'patchGeneral';
|
||||
ok: true;
|
||||
generalId: number;
|
||||
}
|
||||
| {
|
||||
type: 'patchGeneral';
|
||||
ok: false;
|
||||
generalId: number;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'auctionBid';
|
||||
ok: true;
|
||||
auctionId: number;
|
||||
closeAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'auctionBid';
|
||||
ok: false;
|
||||
auctionId: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type TurnDaemonEvent =
|
||||
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
|
||||
|
||||
@@ -172,8 +172,8 @@ const createGameClient = (baseUrl: string, trpcPath: string, accessTokenRef: { v
|
||||
],
|
||||
});
|
||||
|
||||
const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string =>
|
||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`;
|
||||
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string =>
|
||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`;
|
||||
|
||||
const waitForPm2Online = async (manager: Pm2ProcessManager, names: string[], timeoutMs = 30_000) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
@@ -203,10 +203,7 @@ const cleanupPm2 = async (manager: Pm2ProcessManager, names: string[]) => {
|
||||
}
|
||||
};
|
||||
|
||||
const waitForTurnDaemonStatus = async (
|
||||
gameClient: ReturnType<typeof createGameClient>,
|
||||
timeoutMs = 90_000
|
||||
) => {
|
||||
const waitForTurnDaemonStatus = async (gameClient: ReturnType<typeof createGameClient>, timeoutMs = 90_000) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: string | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -415,7 +412,11 @@ describe('pm2 orchestrator e2e', () => {
|
||||
}
|
||||
profileName = `${profile}:${scenario}`;
|
||||
apiPort = Number(process.env.GAME_API_PORT ?? '14000');
|
||||
processNames = [buildProcessName(profileName, 'api'), buildProcessName(profileName, 'daemon')];
|
||||
processNames = [
|
||||
buildProcessName(profileName, 'api'),
|
||||
buildProcessName(profileName, 'daemon'),
|
||||
buildProcessName(profileName, 'tournament'),
|
||||
];
|
||||
|
||||
await resetDatabase(profile);
|
||||
await resetRedis();
|
||||
@@ -439,7 +440,7 @@ describe('pm2 orchestrator e2e', () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('starts game-api/turn-daemon via PM2 and serves commands', async () => {
|
||||
it('starts game-api/turn-daemon/tournament-worker via PM2 and serves commands', async () => {
|
||||
if (!gatewayServer || !pm2Manager) {
|
||||
throw new Error('test setup failed');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user