Merge branch 'main' into feature/nation-personnel-finance-parity
# Conflicts: # docs/frontend-legacy-parity.md
This commit is contained in:
@@ -7,7 +7,8 @@ import type { TournamentState } from '../../tournament/types.js';
|
|||||||
|
|
||||||
import { TournamentStore } from '../../tournament/store.js';
|
import { TournamentStore } from '../../tournament/store.js';
|
||||||
import { buildTournamentKeys } from '../../tournament/keys.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 => {
|
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||||
@@ -110,13 +111,24 @@ const zSeedParticipants = z.object({
|
|||||||
includeNpc: z.boolean().optional(),
|
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({
|
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));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
return store.getState();
|
return store.getState();
|
||||||
}),
|
}),
|
||||||
getAdminStatus: adminProcedure.query(async () => ({ ok: true })),
|
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 store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
const [state, participants, matches, bets] = await Promise.all([
|
const [state, participants, matches, bets] = await Promise.all([
|
||||||
store.getState(),
|
store.getState(),
|
||||||
@@ -124,66 +136,156 @@ export const tournamentRouter = router({
|
|||||||
store.getMatches(),
|
store.getMatches(),
|
||||||
store.getBettingEntries(),
|
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 }) => {
|
setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => {
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
await store.setState({
|
return store.withMutationLock(async () => {
|
||||||
...input,
|
await store.setState({
|
||||||
type: input.type as TournamentType,
|
...input,
|
||||||
|
type: input.type as TournamentType,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
});
|
});
|
||||||
return { ok: true };
|
|
||||||
}),
|
}),
|
||||||
patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => {
|
patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => {
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
const current = await store.getState();
|
return store.withMutationLock(async () => {
|
||||||
if (!current) {
|
const current = await store.getState();
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
if (!current) {
|
||||||
}
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
||||||
const next = {
|
}
|
||||||
...current,
|
const next = {
|
||||||
...input,
|
...current,
|
||||||
type: (input.type !== undefined ? input.type : current.type) as TournamentType,
|
...input,
|
||||||
};
|
type: (input.type !== undefined ? input.type : current.type) as TournamentType,
|
||||||
await store.setState(next);
|
};
|
||||||
return { ok: true };
|
await store.setState(next);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => {
|
setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => {
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
await store.setParticipants(input);
|
return store.withMutationLock(async () => {
|
||||||
return { ok: true, count: input.length };
|
await store.setParticipants(input);
|
||||||
|
return { ok: true, count: input.length };
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => {
|
setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => {
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
await store.setMatches(input);
|
return store.withMutationLock(async () => {
|
||||||
return { ok: true, count: input.length };
|
await store.setMatches(input);
|
||||||
|
return { ok: true, count: input.length };
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => {
|
setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => {
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
await store.setBettingEntries(input);
|
return store.withMutationLock(async () => {
|
||||||
return { ok: true, count: input.length };
|
await store.setBettingEntries(input);
|
||||||
|
return { ok: true, count: input.length };
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
seedParticipants: adminProcedure.input(zSeedParticipants).mutation(async ({ ctx, input }) => {
|
seedParticipants: adminProcedure.input(zSeedParticipants).mutation(async ({ ctx, input }) => {
|
||||||
const limit = input.limit ?? 64;
|
const limit = input.limit ?? 64;
|
||||||
const includeNpc = input.includeNpc ?? true;
|
const includeNpc = input.includeNpc ?? true;
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
|
|
||||||
const generals = input.generalIds && input.generalIds.length > 0
|
const generals =
|
||||||
? await ctx.db.general.findMany({
|
input.generalIds && input.generalIds.length > 0
|
||||||
where: { id: { in: input.generalIds } },
|
? await ctx.db.general.findMany({
|
||||||
select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true },
|
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 },
|
: await ctx.db.general.findMany({
|
||||||
orderBy: [
|
where: includeNpc ? {} : { npcState: 0 },
|
||||||
{ leadership: 'desc' },
|
orderBy: [{ leadership: 'desc' }, { strength: 'desc' }, { intel: 'desc' }, { id: 'asc' }],
|
||||||
{ strength: 'desc' },
|
take: limit,
|
||||||
{ intel: 'desc' },
|
select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true },
|
||||||
{ id: 'asc' },
|
});
|
||||||
],
|
|
||||||
take: limit,
|
|
||||||
select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const participants = generals.map((general) => {
|
const participants = generals.map((general) => {
|
||||||
const meta = asRecord(general.meta);
|
const meta = asRecord(general.meta);
|
||||||
@@ -198,10 +300,13 @@ export const tournamentRouter = router({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.setParticipants(participants);
|
return store.withMutationLock(async () => {
|
||||||
return { ok: true, count: participants.length };
|
await store.setParticipants(participants);
|
||||||
|
return { ok: true, count: participants.length };
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
|
getBettingSummary: authedProcedure.query(async ({ ctx }) => {
|
||||||
|
const general = await getMyGeneral(ctx);
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
const [state, entries, matches] = await Promise.all([
|
const [state, entries, matches] = await Promise.all([
|
||||||
store.getState(),
|
store.getState(),
|
||||||
@@ -225,18 +330,13 @@ export const tournamentRouter = router({
|
|||||||
const myTotals: Record<number, number> = {};
|
const myTotals: Record<number, number> = {};
|
||||||
let totalAmount = 0;
|
let totalAmount = 0;
|
||||||
let myAmount = 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) {
|
for (const entry of entries) {
|
||||||
if (!candidateIds.has(entry.targetId)) {
|
if (!candidateIds.has(entry.targetId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
|
totals[entry.targetId] = (totals[entry.targetId] ?? 0) + entry.amount;
|
||||||
totalAmount += 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;
|
myTotals[entry.targetId] = (myTotals[entry.targetId] ?? 0) + entry.amount;
|
||||||
myAmount += entry.amount;
|
myAmount += entry.amount;
|
||||||
}
|
}
|
||||||
@@ -245,199 +345,254 @@ export const tournamentRouter = router({
|
|||||||
return { state, totals, myTotals, totalAmount, myAmount };
|
return { state, totals, myTotals, totalAmount, myAmount };
|
||||||
}),
|
}),
|
||||||
join: authedProcedure.mutation(async ({ ctx }) => {
|
join: authedProcedure.mutation(async ({ ctx }) => {
|
||||||
const userId = ctx.auth?.user.id;
|
const general = await getMyGeneral(ctx);
|
||||||
if (!userId) {
|
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
||||||
}
|
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
const state = await store.getState();
|
return store.withMutationLock(async () => {
|
||||||
if (!state || state.stage !== 1 || state.participantsLockedAt) {
|
const state = await store.getState();
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' });
|
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({
|
const config = asRecord(worldState?.config ?? {});
|
||||||
where: { userId },
|
const constValues = asRecord(config.const ?? config);
|
||||||
select: { id: true, name: true, leadership: true, strength: true, intel: true, meta: true },
|
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 }) => {
|
cancel: adminProcedure.mutation(async ({ ctx }) => {
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
const state = await store.getState();
|
return store.withMutationLock(async () => {
|
||||||
if (!state) {
|
const state = await store.getState();
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
if (!state) {
|
||||||
}
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
||||||
|
|
||||||
const [participants, bets] = await Promise.all([
|
|
||||||
store.getParticipants(),
|
|
||||||
store.getBettingEntries(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const worldState = await ctx.db.worldState.findFirst();
|
|
||||||
const config = asRecord(worldState?.config ?? {});
|
|
||||||
const constValues = asRecord(config.const ?? config);
|
|
||||||
const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
|
||||||
|
|
||||||
const refundMap = new Map<number, number>();
|
|
||||||
for (const participant of participants) {
|
|
||||||
if (participant.id <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (participant.groupId !== undefined && participant.groupId >= 0 && participant.groupId < 8) {
|
|
||||||
refundMap.set(participant.id, (refundMap.get(participant.id) ?? 0) + develCost);
|
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) {
|
if (refundMap.size > 0) {
|
||||||
await ctx.turnDaemon.sendCommand({
|
await ctx.turnDaemon.sendCommand({
|
||||||
type: 'tournamentRefund',
|
type: 'tournamentRefund',
|
||||||
refunds: Array.from(refundMap.entries()).map(([generalId, amount]) => ({
|
refunds: Array.from(refundMap.entries()).map(([generalId, amount]) => ({
|
||||||
generalId,
|
generalId,
|
||||||
amount,
|
amount,
|
||||||
})),
|
})),
|
||||||
reason: 'cancel',
|
reason: 'cancel',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([store.setParticipants([]), store.setMatches([]), store.setBettingEntries([])]);
|
||||||
store.setParticipants([]),
|
|
||||||
store.setMatches([]),
|
|
||||||
store.setBettingEntries([]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const nextState: TournamentState = {
|
const nextState: TournamentState = {
|
||||||
...state,
|
...state,
|
||||||
stage: 0,
|
stage: 0,
|
||||||
phase: 0,
|
phase: 0,
|
||||||
auto: false,
|
auto: false,
|
||||||
winnerId: undefined,
|
winnerId: undefined,
|
||||||
bettingSettled: true,
|
bettingSettled: true,
|
||||||
rewardSettled: false,
|
rewardSettled: false,
|
||||||
bettingCloseAt: undefined,
|
bettingCloseAt: undefined,
|
||||||
participantsLockedAt: undefined,
|
participantsLockedAt: undefined,
|
||||||
nextAt: new Date().toISOString(),
|
nextAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await store.setState(nextState);
|
await store.setState(nextState);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
placeBet: authedProcedure
|
placeBet: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
targetId: z.number().int().positive(),
|
targetId: z.number().int().positive(),
|
||||||
amount: z.number().int().positive(),
|
amount: z.number().int().min(10),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.auth?.user.id;
|
const general = await getMyGeneral(ctx);
|
||||||
if (!userId) {
|
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
||||||
}
|
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
const state = await store.getState();
|
return store.withMutationLock(async () => {
|
||||||
if (!state || state.stage !== 6) {
|
const state = await store.getState();
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
|
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()) {
|
const closeAt = state.bettingCloseAt ? new Date(state.bettingCloseAt).getTime() : 0;
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅이 마감되었습니다.' });
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!candidateIds.has(input.targetId)) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const general = await ctx.db.general.findFirst({
|
const [matches, entries] = await Promise.all([store.getMatches(), store.getBettingEntries()]);
|
||||||
where: { userId },
|
const candidateIds = new Set<number>();
|
||||||
select: { id: true, gold: true },
|
for (const match of matches) {
|
||||||
});
|
if (match.stage === 7) {
|
||||||
if (!general) {
|
candidateIds.add(match.attackerId);
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
candidateIds.add(match.defenderId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!candidateIds.has(input.targetId)) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 베팅 대상입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
const minRemainGold = 500;
|
const previousBetAmount = entries
|
||||||
if (general.gold - input.amount < minRemainGold) {
|
.filter((entry) => entry.generalId === general.id)
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '소지금이 부족합니다.' });
|
.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({
|
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'adjustGeneralResources',
|
||||||
reason: 'tournamentBet',
|
reason: 'tournamentBet',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount }],
|
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
||||||
});
|
});
|
||||||
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') {
|
if (!adjustResult || adjustResult.type !== 'adjustGeneralResources') {
|
||||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||||
}
|
}
|
||||||
if (!adjustResult.ok) {
|
if (!adjustResult.ok || adjustResult.processed !== 1) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: adjustResult.reason });
|
throw new TRPCError({
|
||||||
}
|
code: 'BAD_REQUEST',
|
||||||
|
message: adjustResult.ok ? '금이 부족합니다.' : adjustResult.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.turnDaemon.sendCommand({
|
const rankResult = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralMeta',
|
type: 'adjustGeneralMeta',
|
||||||
reason: 'tournamentBet',
|
reason: 'tournamentBet',
|
||||||
adjustments: [
|
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,
|
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 { TournamentKeys } from './keys.js';
|
||||||
import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js';
|
import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js';
|
||||||
|
|
||||||
interface RedisClientLike {
|
interface RedisClientLike {
|
||||||
get(key: string): Promise<string | null>;
|
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 => {
|
const safeJsonParse = <T>(raw: string | null): T | null => {
|
||||||
@@ -18,7 +28,34 @@ const safeJsonParse = <T>(raw: string | null): T | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class TournamentStore {
|
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> {
|
async getState(): Promise<TournamentState | null> {
|
||||||
return safeJsonParse<TournamentState>(await this.redis.get(this.keys.stateKey));
|
return safeJsonParse<TournamentState>(await this.redis.get(this.keys.stateKey));
|
||||||
@@ -54,7 +91,14 @@ export class TournamentStore {
|
|||||||
|
|
||||||
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
|
async appendBettingEntry(entry: TournamentBetEntry): Promise<TournamentBetEntry[]> {
|
||||||
const entries = await this.getBettingEntries();
|
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);
|
await this.setBettingEntries(entries);
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -518,7 +518,6 @@ export const settleTournamentOutcome = async (options: {
|
|||||||
return settledState;
|
return settledState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const runTournamentWorker = async (): Promise<void> => {
|
export const runTournamentWorker = async (): Promise<void> => {
|
||||||
const config = resolveGameApiConfigFromEnv();
|
const config = resolveGameApiConfigFromEnv();
|
||||||
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
||||||
@@ -555,25 +554,36 @@ export const runTournamentWorker = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const worldState = await postgres.prisma.worldState.findFirst();
|
await store.withMutationLock(async () => {
|
||||||
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
const lockedState = await store.getState();
|
||||||
let nextState = state;
|
if (!lockedState || !lockedState.auto) {
|
||||||
if (isBattleStage(state.stage)) {
|
return;
|
||||||
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
|
}
|
||||||
} else if (isPreBattleStage(state.stage)) {
|
const lockedNextAt = new Date(lockedState.nextAt).getTime();
|
||||||
nextState = await applyPreBattleStage(
|
if (Number.isFinite(lockedNextAt) && lockedNextAt > Date.now()) {
|
||||||
store,
|
return;
|
||||||
postgres.prisma,
|
}
|
||||||
state,
|
|
||||||
String(baseSeed),
|
|
||||||
daemonTransport
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await settleTournamentOutcome({
|
const worldState = await postgres.prisma.worldState.findFirst();
|
||||||
store,
|
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
||||||
daemonTransport,
|
let nextState = lockedState;
|
||||||
state: nextState,
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|||||||
@@ -652,7 +652,7 @@ export const seedNpcBets = async (options: {
|
|||||||
reason: 'tournamentNpcBet',
|
reason: 'tournamentNpcBet',
|
||||||
adjustments: npcBetList.map((npc) => ({
|
adjustments: npcBetList.map((npc) => ({
|
||||||
generalId: npc.id as number,
|
generalId: npc.id as number,
|
||||||
metaDelta: { rank_betgold: betGold },
|
metaDelta: { betgold: betGold },
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
await store.setBettingEntries(entries);
|
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));
|
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
describe('tournament worker (in-memory)', () => {
|
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 () => {
|
it('64명 고정 seed 대진에서 15번 참가자가 결승을 이긴다', async () => {
|
||||||
const redis = new MemoryRedis();
|
const redis = new MemoryRedis();
|
||||||
const store = new TournamentStore(redis, buildTournamentKeys('test'));
|
const store = new TournamentStore(redis, buildTournamentKeys('test'));
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ const zAdjustGeneralResources = z.object({
|
|||||||
generalId: zFiniteNumber,
|
generalId: zFiniteNumber,
|
||||||
goldDelta: zFiniteNumber.optional(),
|
goldDelta: zFiniteNumber.optional(),
|
||||||
riceDelta: zFiniteNumber.optional(),
|
riceDelta: zFiniteNumber.optional(),
|
||||||
|
minGoldAfter: zFiniteNumber.optional(),
|
||||||
})
|
})
|
||||||
.refine((value) => value.goldDelta !== undefined || value.riceDelta !== undefined)
|
.refine((value) => value.goldDelta !== undefined || value.riceDelta !== undefined)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -857,7 +857,8 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
fallbackDefinition: GeneralActionDefinition,
|
fallbackDefinition: GeneralActionDefinition,
|
||||||
command: ReservedTurnEntry,
|
command: ReservedTurnEntry,
|
||||||
applyNextTurnAt: boolean,
|
applyNextTurnAt: boolean,
|
||||||
alternativeDepth = 0
|
alternativeDepth = 0,
|
||||||
|
sharedActionRng?: RandUtil
|
||||||
): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; blockedReason?: string } => {
|
): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; blockedReason?: string } => {
|
||||||
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
|
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
|
||||||
const rawArgs = extractArgsRecord(command.args);
|
const rawArgs = extractArgsRecord(command.args);
|
||||||
@@ -954,11 +955,12 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
uniqueConfig,
|
uniqueConfig,
|
||||||
getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys,
|
getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys,
|
||||||
});
|
});
|
||||||
|
let actionRng = sharedActionRng ?? buildRng(actionKey);
|
||||||
let baseContext: ActionContextBase = {
|
let baseContext: ActionContextBase = {
|
||||||
general: currentGeneral,
|
general: currentGeneral,
|
||||||
city: currentCity,
|
city: currentCity,
|
||||||
nation: currentNation,
|
nation: currentNation,
|
||||||
rng: buildRng(actionKey),
|
rng: actionRng,
|
||||||
uniqueLottery,
|
uniqueLottery,
|
||||||
};
|
};
|
||||||
let specificContext = buildActionContext(
|
let specificContext = buildActionContext(
|
||||||
@@ -985,11 +987,12 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
usedFallback = true;
|
usedFallback = true;
|
||||||
blockedReason = '예약된 명령을 실행하지 못했습니다.';
|
blockedReason = '예약된 명령을 실행하지 못했습니다.';
|
||||||
logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.'));
|
logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.'));
|
||||||
|
actionRng = sharedActionRng ?? buildRng(actionKey);
|
||||||
baseContext = {
|
baseContext = {
|
||||||
general: currentGeneral,
|
general: currentGeneral,
|
||||||
city: currentCity,
|
city: currentCity,
|
||||||
nation: currentNation,
|
nation: currentNation,
|
||||||
rng: buildRng(actionKey),
|
rng: actionRng,
|
||||||
};
|
};
|
||||||
specificContext = baseContext;
|
specificContext = baseContext;
|
||||||
}
|
}
|
||||||
@@ -1287,7 +1290,8 @@ export const createReservedTurnHandler = async (options: {
|
|||||||
args: extractArgsRecord(resolution.alternative.args),
|
args: extractArgsRecord(resolution.alternative.args),
|
||||||
},
|
},
|
||||||
applyNextTurnAt,
|
applyNextTurnAt,
|
||||||
alternativeDepth + 1
|
alternativeDepth + 1,
|
||||||
|
actionRng
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,7 +194,8 @@ async function handleAdjustGeneralResources(
|
|||||||
}
|
}
|
||||||
const nextGold = general.gold + (adjustment.goldDelta ?? 0);
|
const nextGold = general.gold + (adjustment.goldDelta ?? 0);
|
||||||
const nextRice = general.rice + (adjustment.riceDelta ?? 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: '자원이 부족합니다.' };
|
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,7 +308,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 =>
|
const getRankNumber = (general: TurnGeneral | null, key: string): number =>
|
||||||
general && typeof general.meta[key] === 'number' ? Number(general.meta[key]) : 0;
|
general && typeof general.meta[key] === 'number' ? Number(general.meta[key]) : 0;
|
||||||
|
|
||||||
@@ -1394,8 +1395,8 @@ async function handleTournamentBettingPayout(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const nextMeta = { ...general.meta } as TurnGeneral['meta'];
|
const nextMeta = { ...general.meta } as TurnGeneral['meta'];
|
||||||
const betwinKey = 'rank_betwin';
|
const betwinKey = 'betwin';
|
||||||
const betwingoldKey = 'rank_betwingold';
|
const betwingoldKey = 'betwingold';
|
||||||
const currentBetwin = typeof nextMeta[betwinKey] === 'number' ? Number(nextMeta[betwinKey]) : 0;
|
const currentBetwin = typeof nextMeta[betwinKey] === 'number' ? Number(nextMeta[betwinKey]) : 0;
|
||||||
const currentBetwingold = typeof nextMeta[betwingoldKey] === 'number' ? Number(nextMeta[betwingoldKey]) : 0;
|
const currentBetwingold = typeof nextMeta[betwingoldKey] === 'number' ? Number(nextMeta[betwingoldKey]) : 0;
|
||||||
nextMeta[betwinKey] = currentBetwin + delta.betwin;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ import BattleSimulatorView from '../views/BattleSimulatorView.vue';
|
|||||||
import NpcControlView from '../views/NpcControlView.vue';
|
import NpcControlView from '../views/NpcControlView.vue';
|
||||||
import NotFoundView from '../views/NotFoundView.vue';
|
import NotFoundView from '../views/NotFoundView.vue';
|
||||||
import TournamentView from '../views/TournamentView.vue';
|
import TournamentView from '../views/TournamentView.vue';
|
||||||
|
import BettingView from '../views/BettingView.vue';
|
||||||
import MyPageView from '../views/MyPageView.vue';
|
import MyPageView from '../views/MyPageView.vue';
|
||||||
import MySettingsView from '../views/MySettingsView.vue';
|
import MySettingsView from '../views/MySettingsView.vue';
|
||||||
import BoardView from '../views/BoardView.vue';
|
import BoardView from '../views/BoardView.vue';
|
||||||
@@ -300,6 +301,15 @@ const routes = [
|
|||||||
requiresGeneral: true,
|
requiresGeneral: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/betting',
|
||||||
|
name: 'betting',
|
||||||
|
component: BettingView,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresGeneral: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
|
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||||
|
|
||||||
|
const snapshot = ref<Snapshot | null>(null);
|
||||||
|
const summary = ref<Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>> | null>(null);
|
||||||
|
const rankings = ref<Awaited<ReturnType<typeof trpc.tournament.getRankings.query>>>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const message = ref<string | null>(null);
|
||||||
|
const amounts = ref<Record<number, number>>({});
|
||||||
|
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||||
|
const stageNames = [
|
||||||
|
'경기 없음',
|
||||||
|
'참가 모집중',
|
||||||
|
'예선 진행중',
|
||||||
|
'본선 추첨중',
|
||||||
|
'본선 진행중',
|
||||||
|
'16강 배정중',
|
||||||
|
'베팅 진행중',
|
||||||
|
'16강 진행중',
|
||||||
|
'8강 진행중',
|
||||||
|
'4강 진행중',
|
||||||
|
'결승 진행중',
|
||||||
|
];
|
||||||
|
|
||||||
|
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
|
||||||
|
const load = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
[snapshot.value, summary.value, rankings.value] = await Promise.all([
|
||||||
|
trpc.tournament.getSnapshot.query(),
|
||||||
|
trpc.tournament.getBettingSummary.query(),
|
||||||
|
trpc.tournament.getRankings.query(),
|
||||||
|
]);
|
||||||
|
} catch (value) {
|
||||||
|
error.value = errorText(value);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
onMounted(() => void load());
|
||||||
|
|
||||||
|
const participantMap = computed(
|
||||||
|
() => new Map((snapshot.value?.participants ?? []).map((participant) => [participant.id, participant]))
|
||||||
|
);
|
||||||
|
const final16Ids = computed(() =>
|
||||||
|
(snapshot.value?.matches ?? [])
|
||||||
|
.filter((match) => match.stage === 7)
|
||||||
|
.sort((a, b) => a.roundIndex - b.roundIndex)
|
||||||
|
.flatMap((match) => [match.attackerId, match.defenderId])
|
||||||
|
);
|
||||||
|
const candidates = computed(() =>
|
||||||
|
Array.from({ length: 16 }, (_, index) => {
|
||||||
|
const id = final16Ids.value[index] ?? 0;
|
||||||
|
return { id, name: id ? (participantMap.value.get(id)?.name ?? `#${id}`) : '-' };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
||||||
|
const myAmount = computed(() => summary.value?.myAmount ?? 0);
|
||||||
|
const ratio = (id: number) => {
|
||||||
|
const totals = summary.value?.totals as Record<number, number> | undefined;
|
||||||
|
const amount = totals?.[id] ?? 0;
|
||||||
|
return amount ? (totalAmount.value / amount).toFixed(2) : '∞';
|
||||||
|
};
|
||||||
|
const expected = (id: number) => {
|
||||||
|
const myTotals = summary.value?.myTotals as Record<number, number> | undefined;
|
||||||
|
const current = myTotals?.[id] ?? 0;
|
||||||
|
const numericRatio = Number(ratio(id));
|
||||||
|
return Number.isFinite(numericRatio) ? Math.floor(current * numericRatio) : 0;
|
||||||
|
};
|
||||||
|
const bettingOpen = computed(() => {
|
||||||
|
const state = snapshot.value?.state;
|
||||||
|
if (!state || state.stage !== 6) return false;
|
||||||
|
if (!state.bettingCloseAt) return true;
|
||||||
|
return new Date(state.bettingCloseAt).getTime() > Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
const placeBet = async (targetId: number) => {
|
||||||
|
if (!targetId) return;
|
||||||
|
const amount = amounts.value[targetId] ?? 10;
|
||||||
|
message.value = null;
|
||||||
|
try {
|
||||||
|
await trpc.tournament.placeBet.mutate({ targetId, amount });
|
||||||
|
message.value = '베팅이 등록되었습니다.';
|
||||||
|
await load();
|
||||||
|
} catch (value) {
|
||||||
|
message.value = errorText(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main id="tournament-betting-container" class="betting-page">
|
||||||
|
<section class="title bg0">베 팅 장<br /><RouterLink class="close-button" to="/">창 닫기</RouterLink></section>
|
||||||
|
<section class="toolbar bg0">
|
||||||
|
<button type="button" @click="load">갱신</button>
|
||||||
|
<span v-if="loading">불러오는 중...</span>
|
||||||
|
<span v-if="message" role="status">{{ message }}</span>
|
||||||
|
</section>
|
||||||
|
<section v-if="error" class="error bg0" role="alert">{{ error }}</section>
|
||||||
|
<section class="state bg0">
|
||||||
|
<span>{{ typeNames[snapshot?.state?.type ?? 0] }}</span>
|
||||||
|
({{ stageNames[snapshot?.state?.stage ?? 0] }}, {{ snapshot?.state?.termSeconds ?? '-' }}초 간격)
|
||||||
|
</section>
|
||||||
|
<section class="section-title bg2">
|
||||||
|
16강 상황<br />
|
||||||
|
<small>(전체 금액 : {{ totalAmount }} / 내 투자 금액 : {{ myAmount }})</small>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="candidate-table bg0">
|
||||||
|
<div class="candidate-row names">
|
||||||
|
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{ candidate.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="candidate-row ratios">
|
||||||
|
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
|
||||||
|
ratio(candidate.id)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="candidate-row labels">
|
||||||
|
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">∥</span>
|
||||||
|
</div>
|
||||||
|
<div class="candidate-row expected">
|
||||||
|
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
|
||||||
|
expected(candidate.id)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="bettingOpen" class="candidate-row selects">
|
||||||
|
<select
|
||||||
|
v-for="candidate in candidates"
|
||||||
|
:key="candidate.id || candidate.name"
|
||||||
|
v-model.number="amounts[candidate.id]"
|
||||||
|
:aria-label="`${candidate.name} 베팅 금액`"
|
||||||
|
:disabled="!candidate.id"
|
||||||
|
>
|
||||||
|
<option :value="10">금10</option>
|
||||||
|
<option :value="20">금20</option>
|
||||||
|
<option :value="50">금50</option>
|
||||||
|
<option :value="100">금100</option>
|
||||||
|
<option :value="200">금200</option>
|
||||||
|
<option :value="500">금500</option>
|
||||||
|
<option :value="1000">최대</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="bettingOpen" class="candidate-row buttons">
|
||||||
|
<button
|
||||||
|
v-for="candidate in candidates"
|
||||||
|
:key="candidate.id || candidate.name"
|
||||||
|
type="button"
|
||||||
|
:disabled="!candidate.id"
|
||||||
|
@click="placeBet(candidate.id)"
|
||||||
|
>
|
||||||
|
베팅!
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
|
||||||
|
<span class="return-color">적중시 환수금</span><br />
|
||||||
|
<span class="ratio-color">( 베팅후 500원 이하일땐 베팅이 불가능합니다. )</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ranking-title bg2">토너먼트 랭킹</section>
|
||||||
|
<section class="ranking-placeholder bg0">
|
||||||
|
순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
|
||||||
|
</section>
|
||||||
|
<section class="ranking-grid bg0">
|
||||||
|
<table v-for="section in rankings" :key="section.prefix" class="ranking-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan="9">{{ section.title }}</th>
|
||||||
|
</tr>
|
||||||
|
<tr class="bg1">
|
||||||
|
<th>순</th>
|
||||||
|
<th>장수</th>
|
||||||
|
<th>{{ section.statLabel }}</th>
|
||||||
|
<th>경</th>
|
||||||
|
<th>승</th>
|
||||||
|
<th>무</th>
|
||||||
|
<th>패</th>
|
||||||
|
<th>점</th>
|
||||||
|
<th>勝</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="entry in section.entries" :key="entry.generalId">
|
||||||
|
<td>{{ entry.rank }}</td>
|
||||||
|
<td>{{ entry.name }}</td>
|
||||||
|
<td>{{ entry.stat }}</td>
|
||||||
|
<td>{{ entry.games }}</td>
|
||||||
|
<td>{{ entry.win }}</td>
|
||||||
|
<td>{{ entry.draw }}</td>
|
||||||
|
<td>{{ entry.lose }}</td>
|
||||||
|
<td>{{ entry.score }}</td>
|
||||||
|
<td>{{ entry.prizes }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="section.entries.length === 0">
|
||||||
|
<td colspan="9">-</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="guide bg0">
|
||||||
|
ㆍ토너먼트의 16강 대진표가 완성되면, 베팅 기간이 주어집니다.<br />
|
||||||
|
ㆍ유저들의 베팅 상황에 따라 배당률이 실시간 결정됩니다.<br />
|
||||||
|
ㆍ베팅은 16슬롯에 각각 가능하며, 도합 최대 금 1000까지 베팅 가능합니다.<br />
|
||||||
|
ㆍ소지금 500원 이하일땐 베팅이 불가능합니다.
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.betting-page {
|
||||||
|
width: 1120px;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: #fff;
|
||||||
|
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.betting-page,
|
||||||
|
.betting-page * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.bg0 {
|
||||||
|
background: #3a2118 url('/image/game/back_walnut.jpg');
|
||||||
|
}
|
||||||
|
.bg2 {
|
||||||
|
background: #142b42 url('/image/game/back_blue.jpg');
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
height: 55.6875px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 19.1875px;
|
||||||
|
}
|
||||||
|
.close-button {
|
||||||
|
display: block;
|
||||||
|
width: 62px;
|
||||||
|
height: 35.5px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #375a7f;
|
||||||
|
border-radius: 5.25px;
|
||||||
|
background: #375a7f;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
min-height: 36.5px;
|
||||||
|
padding: 1px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 5px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.state {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 5px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.state span {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
min-height: 50px;
|
||||||
|
padding: 5px;
|
||||||
|
color: limegreen;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.section-title small {
|
||||||
|
color: orange;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.candidate-table {
|
||||||
|
border: 1px solid gray;
|
||||||
|
padding: 10px 0;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.candidate-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(16, 70px);
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
.names {
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
.ratios,
|
||||||
|
.ratio-color {
|
||||||
|
color: skyblue;
|
||||||
|
}
|
||||||
|
.expected,
|
||||||
|
.return-color {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
|
.gold-color {
|
||||||
|
color: orange;
|
||||||
|
}
|
||||||
|
select,
|
||||||
|
.buttons button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 27px;
|
||||||
|
padding: 2px 1px;
|
||||||
|
border: 1px solid #555;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
height: 35.5px;
|
||||||
|
color: #fff;
|
||||||
|
background: #444;
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 5.25px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover,
|
||||||
|
button:focus {
|
||||||
|
filter: brightness(1.25);
|
||||||
|
}
|
||||||
|
.close-button:hover,
|
||||||
|
.close-button:focus {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
button:focus-visible,
|
||||||
|
select:focus-visible {
|
||||||
|
outline: 2px solid #f39c12;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
button:disabled,
|
||||||
|
select:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.candidate-table p {
|
||||||
|
min-height: 42px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.ranking-title {
|
||||||
|
min-height: 50px;
|
||||||
|
padding: 8px;
|
||||||
|
color: yellow;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.ranking-placeholder {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 8px;
|
||||||
|
color: skyblue;
|
||||||
|
}
|
||||||
|
.ranking-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 280px);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.ranking-table {
|
||||||
|
width: 280px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ranking-table th,
|
||||||
|
.ranking-table td {
|
||||||
|
height: 22px;
|
||||||
|
padding: 1px;
|
||||||
|
border: 1px solid #555;
|
||||||
|
}
|
||||||
|
.ranking-table thead tr:first-child th {
|
||||||
|
height: 34px;
|
||||||
|
background: #000;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.ranking-table .bg1 {
|
||||||
|
background: #213b52;
|
||||||
|
}
|
||||||
|
.ranking-table td:nth-child(2) {
|
||||||
|
max-width: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.guide {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #ff8080;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,6 +13,7 @@ import MessagePanel from '../components/main/MessagePanel.vue';
|
|||||||
import SelectedCityPanel from '../components/main/SelectedCityPanel.vue';
|
import SelectedCityPanel from '../components/main/SelectedCityPanel.vue';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
import { useMainDashboardStore } from '../stores/mainDashboard';
|
import { useMainDashboardStore } from '../stores/mainDashboard';
|
||||||
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const dashboard = useMainDashboardStore();
|
const dashboard = useMainDashboardStore();
|
||||||
@@ -29,6 +30,7 @@ const mobileTabs = [
|
|||||||
type MobileTabKey = (typeof mobileTabs)[number]['key'];
|
type MobileTabKey = (typeof mobileTabs)[number]['key'];
|
||||||
|
|
||||||
const mobileTab = ref<MobileTabKey>('map');
|
const mobileTab = ref<MobileTabKey>('map');
|
||||||
|
const tournamentStage = ref(0);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
@@ -70,7 +72,8 @@ const shiftNationTurns = (amount: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadMainData = async () => {
|
const loadMainData = async () => {
|
||||||
await dashboard.loadMainData();
|
const [, state] = await Promise.all([dashboard.loadMainData(), trpc.tournament.getState.query().catch(() => null)]);
|
||||||
|
tournamentStage.value = state?.stage ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -116,7 +119,12 @@ watch(
|
|||||||
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
|
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
|
||||||
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||||
<RouterLink class="ghost" to="/my-page">내 정보</RouterLink>
|
<RouterLink class="ghost" to="/my-page">내 정보</RouterLink>
|
||||||
<RouterLink class="ghost" to="/tournament">토너먼트</RouterLink>
|
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
|
||||||
|
>토너먼트</RouterLink
|
||||||
|
>
|
||||||
|
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 6 }" to="/betting"
|
||||||
|
>베팅장</RouterLink
|
||||||
|
>
|
||||||
<RouterLink class="ghost" to="/auction">거래장</RouterLink>
|
<RouterLink class="ghost" to="/auction">거래장</RouterLink>
|
||||||
<RouterLink class="ghost" to="/survey">설문조사</RouterLink>
|
<RouterLink class="ghost" to="/survey">설문조사</RouterLink>
|
||||||
<RouterLink class="ghost" to="/npc-control">NPC 정책</RouterLink>
|
<RouterLink class="ghost" to="/npc-control">NPC 정책</RouterLink>
|
||||||
@@ -355,6 +363,12 @@ button {
|
|||||||
background: rgba(16, 16, 16, 0.6);
|
background: rgba(16, 16, 16, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ghost.highlight {
|
||||||
|
border-color: #f39c12;
|
||||||
|
background: #8a5b13;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.ghost.disabled {
|
.ghost.disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -1,994 +1,438 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useMediaQuery } from '@vueuse/core';
|
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type TournamentSnapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||||
type TournamentBettingSummary = Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>>;
|
|
||||||
type GeneralMe = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
|
||||||
|
|
||||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
|
||||||
|
|
||||||
|
const snapshot = ref<Snapshot | null>(null);
|
||||||
|
const betting = ref<Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>> | null>(null);
|
||||||
|
const myGeneralId = ref(0);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const snapshot = ref<TournamentSnapshot | null>(null);
|
const actionMessage = ref<string | null>(null);
|
||||||
const bettingSummary = ref<TournamentBettingSummary | null>(null);
|
|
||||||
const myGeneral = ref<GeneralMe | null>(null);
|
|
||||||
const lastActionMessage = ref<string | null>(null);
|
|
||||||
const adminEnabled = ref(false);
|
const adminEnabled = ref(false);
|
||||||
|
|
||||||
const mobileTabs = [
|
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||||
{ key: 'status', label: '상태' },
|
const stageNames = [
|
||||||
{ key: 'bracket', label: '대진' },
|
'경기 없음',
|
||||||
{ key: 'betting', label: '베팅' },
|
'참가 모집중',
|
||||||
] as const;
|
'예선 진행중',
|
||||||
|
'본선 추첨중',
|
||||||
|
'본선 진행중',
|
||||||
|
'16강 배정중',
|
||||||
|
'베팅 진행중',
|
||||||
|
'16강 진행중',
|
||||||
|
'8강 진행중',
|
||||||
|
'4강 진행중',
|
||||||
|
'결승 진행중',
|
||||||
|
];
|
||||||
|
|
||||||
type MobileTabKey = (typeof mobileTabs)[number]['key'];
|
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
|
||||||
const mobileTab = ref<MobileTabKey>('status');
|
|
||||||
|
|
||||||
const resolveErrorMessage = (value: unknown): string => {
|
const load = async () => {
|
||||||
if (value instanceof Error) {
|
|
||||||
return value.message;
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return 'unknown_error';
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadTournament = async () => {
|
|
||||||
if (loading.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
lastActionMessage.value = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [snapshotResult, bettingResult, generalResult, adminResult] = await Promise.all([
|
const [nextSnapshot, nextBetting, me, admin] = await Promise.all([
|
||||||
trpc.tournament.getSnapshot.query(),
|
trpc.tournament.getSnapshot.query(),
|
||||||
trpc.tournament.getBettingSummary.query(),
|
trpc.tournament.getBettingSummary.query(),
|
||||||
trpc.general.me.query(),
|
trpc.general.me.query(),
|
||||||
trpc.tournament.getAdminStatus.query().catch(() => null),
|
trpc.tournament.getAdminStatus.query().catch(() => null),
|
||||||
]);
|
]);
|
||||||
snapshot.value = snapshotResult;
|
snapshot.value = nextSnapshot;
|
||||||
bettingSummary.value = bettingResult;
|
betting.value = nextBetting;
|
||||||
myGeneral.value = generalResult;
|
myGeneralId.value = me?.general?.id ?? 0;
|
||||||
adminEnabled.value = !!adminResult?.ok;
|
adminEnabled.value = !!admin?.ok;
|
||||||
} catch (err) {
|
} catch (value) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = errorText(value);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => void load());
|
||||||
void loadTournament();
|
|
||||||
});
|
|
||||||
|
|
||||||
const stageLabelMap: Record<number, string> = {
|
const participantsById = computed(
|
||||||
0: '경기 없음',
|
() => new Map((snapshot.value?.participants ?? []).map((participant) => [participant.id, participant]))
|
||||||
1: '참가 모집중',
|
);
|
||||||
2: '예선 진행중',
|
const matchesAt = (stage: number) =>
|
||||||
3: '본선 추첨중',
|
(snapshot.value?.matches ?? [])
|
||||||
4: '본선 진행중',
|
.filter((match) => match.stage === stage)
|
||||||
5: '16강 배정중',
|
.sort((a, b) => a.roundIndex - b.roundIndex);
|
||||||
6: '베팅 진행중',
|
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
|
||||||
7: '16강 진행중',
|
const roundNames = (stage: number, count: number) => {
|
||||||
8: '8강 진행중',
|
const matches = matchesAt(stage);
|
||||||
9: '4강 진행중',
|
const ids = matches.flatMap((match) => [match.attackerId, match.defenderId]);
|
||||||
10: '결승 진행중',
|
return Array.from({ length: count }, (_, index) => nameOf(ids[index]));
|
||||||
};
|
};
|
||||||
|
const champion = computed(() => {
|
||||||
const typeLabelMap: Record<number, string> = {
|
const winner = snapshot.value?.state?.winnerId ?? matchesAt(10)[0]?.winnerId;
|
||||||
0: '전력전',
|
return nameOf(winner);
|
||||||
1: '통솔전',
|
});
|
||||||
2: '일기토',
|
const finalists = computed(() => roundNames(10, 2));
|
||||||
3: '설전',
|
const semiFinalists = computed(() => roundNames(9, 4));
|
||||||
|
const quarterFinalists = computed(() => roundNames(8, 8));
|
||||||
|
const top16 = computed(() => roundNames(7, 16));
|
||||||
|
const totalBet = computed(() => betting.value?.totalAmount ?? 0);
|
||||||
|
const odds = (id?: number) => {
|
||||||
|
if (!id) return '0';
|
||||||
|
const totals = betting.value?.totals as Record<number, number> | undefined;
|
||||||
|
const amount = totals?.[id] ?? 0;
|
||||||
|
if (!amount) return '∞';
|
||||||
|
return (totalBet.value / amount).toFixed(2);
|
||||||
};
|
};
|
||||||
|
const isParticipant = computed(() =>
|
||||||
const stageLabel = computed(() => {
|
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
|
||||||
const state = snapshot.value?.state;
|
);
|
||||||
if (!state) {
|
const groups = computed(() =>
|
||||||
return '토너먼트 정보를 불러오는 중';
|
Array.from({ length: 8 }, (_, index) =>
|
||||||
}
|
(snapshot.value?.participants ?? [])
|
||||||
if (state.stage === 2 || state.stage === 4) {
|
.filter((participant) => participant.groupId === index + 10)
|
||||||
return `예선 진행중(페이즈 ${state.phase + 1})`;
|
.sort((a, b) => (a.finalRank ?? 99) - (b.finalRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
|
||||||
}
|
)
|
||||||
return stageLabelMap[state.stage] ?? '토너먼트 상태 확인 중';
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const bettingCloseLabel = computed(() => {
|
|
||||||
const closeAt = snapshot.value?.state?.bettingCloseAt;
|
|
||||||
if (!closeAt) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const date = new Date(closeAt);
|
|
||||||
if (!Number.isFinite(date.getTime())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return `${date.toLocaleString('ko-KR')} 마감`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const bracketEntries = computed(() => {
|
|
||||||
const matches = snapshot.value?.matches ?? [];
|
|
||||||
const participants = snapshot.value?.participants ?? [];
|
|
||||||
const nameMap = new Map(participants.map((entry) => [entry.id, entry.name]));
|
|
||||||
|
|
||||||
return matches.map((match) => ({
|
|
||||||
...match,
|
|
||||||
attackerName: nameMap.get(match.attackerId) ?? `#${match.attackerId}`,
|
|
||||||
defenderName: nameMap.get(match.defenderId) ?? `#${match.defenderId}`,
|
|
||||||
winnerName: match.winnerId ? nameMap.get(match.winnerId) ?? `#${match.winnerId}` : null,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
const stageMatches = (stage: number) => bracketEntries.value.filter((match) => match.stage === stage);
|
|
||||||
|
|
||||||
const currentMatch = computed(() => {
|
const currentMatch = computed(() => {
|
||||||
const state = snapshot.value?.state;
|
const state = snapshot.value?.state;
|
||||||
if (!state || state.stage < 7 || state.stage > 10) {
|
if (!state || state.stage < 7 || state.stage > 10) return null;
|
||||||
return null;
|
return matchesAt(state.stage).find((match) => !match.winnerId) ?? matchesAt(state.stage)[state.phase] ?? null;
|
||||||
}
|
|
||||||
const matches = stageMatches(state.stage).sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex);
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const fallbackIndex = Math.min(state.phase, matches.length - 1);
|
|
||||||
return matches[fallbackIndex] ?? matches.find((match) => !match.winnerId) ?? matches[0];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentEnergy = computed(() => {
|
const join = async () => {
|
||||||
const match = currentMatch.value;
|
actionMessage.value = null;
|
||||||
if (!match) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (match.lastEnergy) {
|
|
||||||
return match.lastEnergy;
|
|
||||||
}
|
|
||||||
const lastEntry = match.logEntries?.[match.logEntries.length - 1];
|
|
||||||
if (lastEntry) {
|
|
||||||
return { attacker: lastEntry.attackerEnergy, defender: lastEntry.defenderEnergy };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentEnergyStats = computed(() => {
|
|
||||||
const match = currentMatch.value;
|
|
||||||
if (!match) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const lastEnergy = currentEnergy.value;
|
|
||||||
if (!lastEnergy) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let maxAttacker = lastEnergy.attacker;
|
|
||||||
let maxDefender = lastEnergy.defender;
|
|
||||||
if (match.logEntries && match.logEntries.length > 0) {
|
|
||||||
for (const entry of match.logEntries) {
|
|
||||||
if (entry.attackerEnergy > maxAttacker) {
|
|
||||||
maxAttacker = entry.attackerEnergy;
|
|
||||||
}
|
|
||||||
if (entry.defenderEnergy > maxDefender) {
|
|
||||||
maxDefender = entry.defenderEnergy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
attacker: lastEnergy.attacker,
|
|
||||||
defender: lastEnergy.defender,
|
|
||||||
maxAttacker: Math.max(1, maxAttacker),
|
|
||||||
maxDefender: Math.max(1, maxDefender),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const attackerEnergyPercent = computed(() => {
|
|
||||||
const stats = currentEnergyStats.value;
|
|
||||||
if (!stats) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.max(0, Math.min(100, (stats.attacker / stats.maxAttacker) * 100));
|
|
||||||
});
|
|
||||||
|
|
||||||
const defenderEnergyPercent = computed(() => {
|
|
||||||
const stats = currentEnergyStats.value;
|
|
||||||
if (!stats) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.max(0, Math.min(100, (stats.defender / stats.maxDefender) * 100));
|
|
||||||
});
|
|
||||||
const formattedLogs = computed(() => currentMatch.value?.log ?? []);
|
|
||||||
|
|
||||||
const finalists = computed(() => {
|
|
||||||
const matches = stageMatches(7);
|
|
||||||
const ids = new Set<number>();
|
|
||||||
for (const match of matches) {
|
|
||||||
ids.add(match.attackerId);
|
|
||||||
ids.add(match.defenderId);
|
|
||||||
}
|
|
||||||
return Array.from(ids);
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalistOptions = computed(() => {
|
|
||||||
const participants = snapshot.value?.participants ?? [];
|
|
||||||
const nameMap = new Map(participants.map((entry) => [entry.id, entry.name]));
|
|
||||||
return finalists.value.map((id) => ({ id, name: nameMap.get(id) ?? `#${id}` }));
|
|
||||||
});
|
|
||||||
|
|
||||||
const myGeneralId = computed(() => myGeneral.value?.general?.id ?? 0);
|
|
||||||
const isParticipant = computed(() => {
|
|
||||||
if (!myGeneralId.value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return (snapshot.value?.participants ?? []).some((entry) => entry.id === myGeneralId.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
const showJoinButton = computed(() => snapshot.value?.state?.stage === 1);
|
|
||||||
const joinLockedAt = computed(() => snapshot.value?.state?.participantsLockedAt ?? null);
|
|
||||||
|
|
||||||
const betTargetId = ref<number>(0);
|
|
||||||
const betAmount = ref<number>(0);
|
|
||||||
|
|
||||||
const isBettingOpen = computed(() => {
|
|
||||||
const state = snapshot.value?.state;
|
|
||||||
if (!state || state.stage !== 6) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!state.bettingCloseAt) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const closeAt = new Date(state.bettingCloseAt).getTime();
|
|
||||||
return Number.isFinite(closeAt) ? Date.now() < closeAt : true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const placeBet = async () => {
|
|
||||||
if (!betTargetId.value || betAmount.value <= 0) {
|
|
||||||
lastActionMessage.value = '베팅 대상과 금액을 입력하세요.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await trpc.tournament.placeBet.mutate({ targetId: betTargetId.value, amount: betAmount.value });
|
|
||||||
lastActionMessage.value = '베팅이 등록되었습니다.';
|
|
||||||
await loadTournament();
|
|
||||||
} catch (err) {
|
|
||||||
lastActionMessage.value = resolveErrorMessage(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleJoin = async () => {
|
|
||||||
try {
|
try {
|
||||||
await trpc.tournament.join.mutate();
|
await trpc.tournament.join.mutate();
|
||||||
lastActionMessage.value = '참가 신청이 반영되었습니다.';
|
actionMessage.value = '참가 신청이 반영되었습니다.';
|
||||||
await loadTournament();
|
await load();
|
||||||
} catch (err) {
|
} catch (value) {
|
||||||
lastActionMessage.value = resolveErrorMessage(err);
|
actionMessage.value = errorText(value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const bettingTotals = computed<Record<number, number>>(() => bettingSummary.value?.totals ?? {});
|
const cancel = async () => {
|
||||||
const bettingMine = computed<Record<number, number>>(() => bettingSummary.value?.myTotals ?? {});
|
|
||||||
const totalBetAmount = computed(() => bettingSummary.value?.totalAmount ?? 0);
|
|
||||||
const myBetAmount = computed(() => bettingSummary.value?.myAmount ?? 0);
|
|
||||||
|
|
||||||
const progressSummary = computed(() => {
|
|
||||||
return {
|
|
||||||
participants: snapshot.value?.participants?.length ?? 0,
|
|
||||||
matches: snapshot.value?.matches?.length ?? 0,
|
|
||||||
bets: snapshot.value?.bets?.length ?? 0,
|
|
||||||
nextAt: snapshot.value?.state?.nextAt ?? null,
|
|
||||||
lockedAt: snapshot.value?.state?.participantsLockedAt ?? null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const adminMessage = ref<string | null>(null);
|
|
||||||
|
|
||||||
const adminStopTournament = async () => {
|
|
||||||
try {
|
try {
|
||||||
await trpc.tournament.cancel.mutate();
|
await trpc.tournament.cancel.mutate();
|
||||||
adminMessage.value = '토너먼트가 취소되었습니다.';
|
actionMessage.value = '토너먼트가 중단되었습니다.';
|
||||||
await loadTournament();
|
await load();
|
||||||
} catch (err) {
|
} catch (value) {
|
||||||
adminMessage.value = resolveErrorMessage(err);
|
actionMessage.value = errorText(value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminStartTournament = async () => {
|
const start = async () => {
|
||||||
const current = snapshot.value?.state;
|
const now = new Date();
|
||||||
if (!current) {
|
|
||||||
adminMessage.value = '토너먼트 상태를 찾을 수 없습니다.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
|
||||||
trpc.tournament.setParticipants.mutate([]),
|
|
||||||
trpc.tournament.setMatches.mutate([]),
|
|
||||||
trpc.tournament.setBettingEntries.mutate([]),
|
|
||||||
]);
|
|
||||||
await trpc.tournament.setState.mutate({
|
await trpc.tournament.setState.mutate({
|
||||||
...current,
|
|
||||||
stage: 1,
|
stage: 1,
|
||||||
phase: 0,
|
phase: 0,
|
||||||
|
type: 0,
|
||||||
auto: true,
|
auto: true,
|
||||||
nextAt: new Date().toISOString(),
|
openYear: snapshot.value?.state?.openYear ?? now.getUTCFullYear(),
|
||||||
bettingId: undefined,
|
openMonth: snapshot.value?.state?.openMonth ?? now.getUTCMonth() + 1,
|
||||||
bettingCloseAt: undefined,
|
termSeconds: snapshot.value?.state?.termSeconds ?? 60,
|
||||||
winnerId: undefined,
|
nextAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
bettingSettled: false,
|
bettingSettled: false,
|
||||||
rewardSettled: false,
|
rewardSettled: false,
|
||||||
participantsLockedAt: undefined,
|
|
||||||
});
|
});
|
||||||
adminMessage.value = '임의 개최가 시작되었습니다.';
|
actionMessage.value = '토너먼트를 개최했습니다.';
|
||||||
await loadTournament();
|
await load();
|
||||||
} catch (err) {
|
} catch (value) {
|
||||||
adminMessage.value = resolveErrorMessage(err);
|
actionMessage.value = errorText(value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="tournament-page">
|
<main id="tournament-container" class="legacy-page">
|
||||||
<header class="page-header">
|
<section class="legacy-title bg0">
|
||||||
<div>
|
<div>삼모전 토너먼트</div>
|
||||||
<h1 class="page-title">토너먼트</h1>
|
<RouterLink class="close-button" to="/">창 닫기</RouterLink>
|
||||||
<p class="page-subtitle">{{ stageLabel }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button class="ghost" @click="loadTournament">새로고침</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
|
||||||
<div v-if="lastActionMessage" class="notice">{{ lastActionMessage }}</div>
|
|
||||||
|
|
||||||
<section v-if="isMobile" class="layout-mobile">
|
|
||||||
<div class="mobile-tabs">
|
|
||||||
<button
|
|
||||||
v-for="tab in mobileTabs"
|
|
||||||
:key="tab.key"
|
|
||||||
:class="{ active: mobileTab === tab.key }"
|
|
||||||
@click="mobileTab = tab.key"
|
|
||||||
>
|
|
||||||
{{ tab.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="mobileTab === 'status'" class="mobile-panel">
|
|
||||||
<PanelCard title="토너먼트 상태" :subtitle="bettingCloseLabel ?? undefined">
|
|
||||||
<SkeletonLines v-if="loading" :lines="3" />
|
|
||||||
<div v-else class="status-grid">
|
|
||||||
<div>종목: {{ typeLabelMap[snapshot?.state?.type ?? 0] }}</div>
|
|
||||||
<div>개최: {{ snapshot?.state?.openYear ?? '-' }}년 {{ snapshot?.state?.openMonth ?? '-' }}월</div>
|
|
||||||
<div>페이즈: {{ snapshot?.state?.phase ?? '-' }}</div>
|
|
||||||
<div>자동 진행: {{ snapshot?.state?.auto ? 'ON' : 'OFF' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="status-actions">
|
|
||||||
<button
|
|
||||||
v-if="showJoinButton"
|
|
||||||
class="ghost"
|
|
||||||
:disabled="isParticipant"
|
|
||||||
@click="toggleJoin"
|
|
||||||
>
|
|
||||||
{{ isParticipant ? '참가 완료' : '참가 신청' }}
|
|
||||||
</button>
|
|
||||||
<span v-else class="muted">참가 신청 기간이 아닙니다.</span>
|
|
||||||
<span v-if="joinLockedAt" class="muted">
|
|
||||||
접수 종료: {{ new Date(joinLockedAt).toLocaleString('ko-KR') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard v-if="adminEnabled" title="관리자 진행 상황">
|
|
||||||
<div class="status-grid">
|
|
||||||
<div>참가자: {{ progressSummary.participants }}</div>
|
|
||||||
<div>대진: {{ progressSummary.matches }}</div>
|
|
||||||
<div>베팅: {{ progressSummary.bets }}</div>
|
|
||||||
<div>다음 진행: {{ progressSummary.nextAt ? new Date(progressSummary.nextAt).toLocaleString('ko-KR') : '-' }}</div>
|
|
||||||
<div>접수 종료: {{ progressSummary.lockedAt ? new Date(progressSummary.lockedAt).toLocaleString('ko-KR') : '-' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="status-actions">
|
|
||||||
<button class="ghost" @click="adminStopTournament">중지</button>
|
|
||||||
<button class="ghost" @click="adminStartTournament">임의 개최</button>
|
|
||||||
<span v-if="adminMessage" class="muted">{{ adminMessage }}</span>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="현재 전투 정보">
|
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
|
||||||
<div v-else>
|
|
||||||
<div v-if="currentMatch" class="match-summary">
|
|
||||||
<div class="match-title">
|
|
||||||
{{ currentMatch.attackerName }} vs {{ currentMatch.defenderName }}
|
|
||||||
</div>
|
|
||||||
<div class="match-meta">
|
|
||||||
남은 체력: {{ currentEnergy?.attacker ?? '-' }} / {{ currentEnergy?.defender ?? '-' }}
|
|
||||||
</div>
|
|
||||||
<div v-if="currentEnergyStats" class="energy-bars">
|
|
||||||
<div class="energy-row">
|
|
||||||
<span class="energy-label">공격</span>
|
|
||||||
<div class="energy-track">
|
|
||||||
<div class="energy-fill attacker" :style="{ width: `${attackerEnergyPercent}%` }" />
|
|
||||||
</div>
|
|
||||||
<span class="energy-value">{{ currentEnergyStats.attacker }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="energy-row">
|
|
||||||
<span class="energy-label">수비</span>
|
|
||||||
<div class="energy-track">
|
|
||||||
<div class="energy-fill defender" :style="{ width: `${defenderEnergyPercent}%` }" />
|
|
||||||
</div>
|
|
||||||
<span class="energy-value">{{ currentEnergyStats.defender }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="log-box">
|
|
||||||
<div
|
|
||||||
v-for="(entry, idx) in formattedLogs"
|
|
||||||
:key="idx"
|
|
||||||
class="log-line"
|
|
||||||
v-text="entry"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="placeholder">현재 진행 중인 전투가 없습니다.</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="mobileTab === 'bracket'" class="mobile-panel">
|
|
||||||
<PanelCard title="토너먼트 진출 정보" subtitle="16강 기준">
|
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
|
||||||
<div v-else class="candidate-list">
|
|
||||||
<div
|
|
||||||
v-for="entry in stageMatches(7)"
|
|
||||||
:key="entry.id"
|
|
||||||
class="match-row"
|
|
||||||
>
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
|
||||||
<span class="vs">vs</span>
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="토너먼트 진행">
|
|
||||||
<div class="bracket-section">
|
|
||||||
<h3>8강</h3>
|
|
||||||
<div v-if="stageMatches(8).length === 0" class="placeholder">진행 대기</div>
|
|
||||||
<div v-else>
|
|
||||||
<div v-for="entry in stageMatches(8)" :key="entry.id" class="match-row">
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
|
||||||
<span class="vs">vs</span>
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bracket-section">
|
|
||||||
<h3>4강</h3>
|
|
||||||
<div v-if="stageMatches(9).length === 0" class="placeholder">진행 대기</div>
|
|
||||||
<div v-else>
|
|
||||||
<div v-for="entry in stageMatches(9)" :key="entry.id" class="match-row">
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
|
||||||
<span class="vs">vs</span>
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bracket-section">
|
|
||||||
<h3>결승</h3>
|
|
||||||
<div v-if="stageMatches(10).length === 0" class="placeholder">진행 대기</div>
|
|
||||||
<div v-else>
|
|
||||||
<div v-for="entry in stageMatches(10)" :key="entry.id" class="match-row">
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
|
||||||
<span class="vs">vs</span>
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="mobileTab === 'betting'" class="mobile-panel">
|
|
||||||
<PanelCard title="베팅 현황" :subtitle="bettingCloseLabel ?? undefined">
|
|
||||||
<SkeletonLines v-if="loading" :lines="3" />
|
|
||||||
<div v-else>
|
|
||||||
<div class="status-grid">
|
|
||||||
<div>전체 베팅: {{ totalBetAmount.toLocaleString('ko-KR') }}</div>
|
|
||||||
<div>내 베팅: {{ myBetAmount.toLocaleString('ko-KR') }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="betting-grid">
|
|
||||||
<div v-for="entry in finalistOptions" :key="entry.id" class="betting-row">
|
|
||||||
<span>{{ entry.name }}</span>
|
|
||||||
<span class="muted">전체 {{ (bettingTotals[entry.id] ?? 0).toLocaleString('ko-KR') }}</span>
|
|
||||||
<span class="muted">내 {{ (bettingMine[entry.id] ?? 0).toLocaleString('ko-KR') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="베팅하기">
|
|
||||||
<div v-if="!isBettingOpen" class="placeholder">베팅 기간이 아닙니다.</div>
|
|
||||||
<div v-else class="bet-form">
|
|
||||||
<label>
|
|
||||||
대상
|
|
||||||
<select v-model.number="betTargetId">
|
|
||||||
<option :value="0">선택</option>
|
|
||||||
<option v-for="entry in finalistOptions" :key="entry.id" :value="entry.id">
|
|
||||||
{{ entry.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
금액
|
|
||||||
<input v-model.number="betAmount" type="number" min="1" />
|
|
||||||
</label>
|
|
||||||
<button class="ghost" @click="placeBet">베팅 등록</button>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else class="layout-desktop">
|
<section class="toolbar bg0">
|
||||||
<div class="stack">
|
<button type="button" @click="load">갱신</button>
|
||||||
<PanelCard title="토너먼트 상태" :subtitle="bettingCloseLabel ?? undefined">
|
<button
|
||||||
<SkeletonLines v-if="loading" :lines="3" />
|
v-if="snapshot?.state?.stage === 1 && !isParticipant"
|
||||||
<div v-else class="status-grid">
|
type="button"
|
||||||
<div>종목: {{ typeLabelMap[snapshot?.state?.type ?? 0] }}</div>
|
class="join-button"
|
||||||
<div>개최: {{ snapshot?.state?.openYear ?? '-' }}년 {{ snapshot?.state?.openMonth ?? '-' }}월</div>
|
@click="join"
|
||||||
<div>페이즈: {{ snapshot?.state?.phase ?? '-' }}</div>
|
>
|
||||||
<div>자동 진행: {{ snapshot?.state?.auto ? 'ON' : 'OFF' }}</div>
|
참가
|
||||||
</div>
|
</button>
|
||||||
<div class="status-actions">
|
<span v-if="loading">불러오는 중...</span>
|
||||||
<button
|
<span v-if="actionMessage" role="status">{{ actionMessage }}</span>
|
||||||
v-if="showJoinButton"
|
</section>
|
||||||
class="ghost"
|
|
||||||
:disabled="isParticipant"
|
|
||||||
@click="toggleJoin"
|
|
||||||
>
|
|
||||||
{{ isParticipant ? '참가 완료' : '참가 신청' }}
|
|
||||||
</button>
|
|
||||||
<span v-else class="muted">참가 신청 기간이 아닙니다.</span>
|
|
||||||
<span v-if="joinLockedAt" class="muted">
|
|
||||||
접수 종료: {{ new Date(joinLockedAt).toLocaleString('ko-KR') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard v-if="adminEnabled" title="관리자 진행 상황">
|
<section v-if="error" class="error-row bg0" role="alert">{{ error }}</section>
|
||||||
<div class="status-grid">
|
<section class="operator-row bg0">운영자 메세지 : <span></span></section>
|
||||||
<div>참가자: {{ progressSummary.participants }}</div>
|
<section class="state-row bg0">
|
||||||
<div>대진: {{ progressSummary.matches }}</div>
|
<span class="type">{{ typeNames[snapshot?.state?.type ?? 0] }}</span>
|
||||||
<div>베팅: {{ progressSummary.bets }}</div>
|
({{ stageNames[snapshot?.state?.stage ?? 0] ?? '상태 확인 중' }},
|
||||||
<div>다음 진행: {{ progressSummary.nextAt ? new Date(progressSummary.nextAt).toLocaleString('ko-KR') : '-' }}</div>
|
{{ snapshot?.state?.termSeconds ?? '-' }}초 간격)
|
||||||
<div>접수 종료: {{ progressSummary.lockedAt ? new Date(progressSummary.lockedAt).toLocaleString('ko-KR') : '-' }}</div>
|
</section>
|
||||||
</div>
|
<section class="section-title bg2">16강 승자전</section>
|
||||||
<div class="status-actions">
|
|
||||||
<button class="ghost" @click="adminStopTournament">중지</button>
|
|
||||||
<button class="ghost" @click="adminStartTournament">임의 개최</button>
|
|
||||||
<span v-if="adminMessage" class="muted">{{ adminMessage }}</span>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="현재 전투 정보">
|
<section class="bracket bg0" aria-label="토너먼트 대진표">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<div class="round champion">
|
||||||
<div v-else>
|
<span>{{ champion }}</span>
|
||||||
<div v-if="currentMatch" class="match-summary">
|
|
||||||
<div class="match-title">
|
|
||||||
{{ currentMatch.attackerName }} vs {{ currentMatch.defenderName }}
|
|
||||||
</div>
|
|
||||||
<div class="match-meta">
|
|
||||||
남은 체력: {{ currentEnergy?.attacker ?? '-' }} / {{ currentEnergy?.defender ?? '-' }}
|
|
||||||
</div>
|
|
||||||
<div v-if="currentEnergyStats" class="energy-bars">
|
|
||||||
<div class="energy-row">
|
|
||||||
<span class="energy-label">공격</span>
|
|
||||||
<div class="energy-track">
|
|
||||||
<div class="energy-fill attacker" :style="{ width: `${attackerEnergyPercent}%` }" />
|
|
||||||
</div>
|
|
||||||
<span class="energy-value">{{ currentEnergyStats.attacker }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="energy-row">
|
|
||||||
<span class="energy-label">수비</span>
|
|
||||||
<div class="energy-track">
|
|
||||||
<div class="energy-fill defender" :style="{ width: `${defenderEnergyPercent}%` }" />
|
|
||||||
</div>
|
|
||||||
<span class="energy-value">{{ currentEnergyStats.defender }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="log-box">
|
|
||||||
<div
|
|
||||||
v-for="(entry, idx) in formattedLogs"
|
|
||||||
:key="idx"
|
|
||||||
class="log-line"
|
|
||||||
v-text="entry"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="placeholder">현재 진행 중인 전투가 없습니다.</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="예선 참가자" subtitle="간단 목록">
|
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
|
||||||
<div v-else class="participant-grid">
|
|
||||||
<div v-for="entry in snapshot?.participants ?? []" :key="entry.id" class="participant-item">
|
|
||||||
<div class="participant-name">{{ entry.name }}</div>
|
|
||||||
<div class="muted">통{{ entry.leadership }} / 무{{ entry.strength }} / 지{{ entry.intel }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="connector">┻</div>
|
||||||
<div class="stack">
|
<div class="round final">
|
||||||
<PanelCard title="토너먼트 진출 정보" subtitle="16강 기준">
|
<span v-for="(name, index) in finalists" :key="index">{{ name }}</span>
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
|
||||||
<div v-else class="candidate-list">
|
|
||||||
<div
|
|
||||||
v-for="entry in stageMatches(7)"
|
|
||||||
:key="entry.id"
|
|
||||||
class="match-row"
|
|
||||||
>
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
|
||||||
<span class="vs">vs</span>
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="토너먼트 진행">
|
|
||||||
<div class="bracket-section">
|
|
||||||
<h3>8강</h3>
|
|
||||||
<div v-if="stageMatches(8).length === 0" class="placeholder">진행 대기</div>
|
|
||||||
<div v-else>
|
|
||||||
<div v-for="entry in stageMatches(8)" :key="entry.id" class="match-row">
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
|
||||||
<span class="vs">vs</span>
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bracket-section">
|
|
||||||
<h3>4강</h3>
|
|
||||||
<div v-if="stageMatches(9).length === 0" class="placeholder">진행 대기</div>
|
|
||||||
<div v-else>
|
|
||||||
<div v-for="entry in stageMatches(9)" :key="entry.id" class="match-row">
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
|
||||||
<span class="vs">vs</span>
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bracket-section">
|
|
||||||
<h3>결승</h3>
|
|
||||||
<div v-if="stageMatches(10).length === 0" class="placeholder">진행 대기</div>
|
|
||||||
<div v-else>
|
|
||||||
<div v-for="entry in stageMatches(10)" :key="entry.id" class="match-row">
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.attackerId }">{{ entry.attackerName }}</span>
|
|
||||||
<span class="vs">vs</span>
|
|
||||||
<span :class="{ winner: entry.winnerId === entry.defenderId }">{{ entry.defenderName }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="베팅 현황" :subtitle="bettingCloseLabel ?? undefined">
|
|
||||||
<SkeletonLines v-if="loading" :lines="3" />
|
|
||||||
<div v-else>
|
|
||||||
<div class="status-grid">
|
|
||||||
<div>전체 베팅: {{ totalBetAmount.toLocaleString('ko-KR') }}</div>
|
|
||||||
<div>내 베팅: {{ myBetAmount.toLocaleString('ko-KR') }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="betting-grid">
|
|
||||||
<div v-for="entry in finalistOptions" :key="entry.id" class="betting-row">
|
|
||||||
<span>{{ entry.name }}</span>
|
|
||||||
<span class="muted">전체 {{ (bettingTotals[entry.id] ?? 0).toLocaleString('ko-KR') }}</span>
|
|
||||||
<span class="muted">내 {{ (bettingMine[entry.id] ?? 0).toLocaleString('ko-KR') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard title="베팅하기">
|
|
||||||
<div v-if="!isBettingOpen" class="placeholder">베팅 기간이 아닙니다.</div>
|
|
||||||
<div v-else class="bet-form">
|
|
||||||
<label>
|
|
||||||
대상
|
|
||||||
<select v-model.number="betTargetId">
|
|
||||||
<option :value="0">선택</option>
|
|
||||||
<option v-for="entry in finalistOptions" :key="entry.id" :value="entry.id">
|
|
||||||
{{ entry.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
금액
|
|
||||||
<input v-model.number="betAmount" type="number" min="1" />
|
|
||||||
</label>
|
|
||||||
<button class="ghost" @click="placeBet">베팅 등록</button>
|
|
||||||
</div>
|
|
||||||
</PanelCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="connector">┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓</div>
|
||||||
|
<div class="round semi">
|
||||||
|
<span v-for="(name, index) in semiFinalists" :key="index">{{ name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="connector">┏━━━━━━━━━━┻━━━━━━━━━━┓ ┏━━━━━━━━━━┻━━━━━━━━━━┓</div>
|
||||||
|
<div class="round quarter">
|
||||||
|
<span v-for="(name, index) in quarterFinalists" :key="index">{{ name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="connector">┏━━━━┻━━━━┓ ┏━━━━┻━━━━┓ ┏━━━━┻━━━━┓ ┏━━━━┻━━━━┓</div>
|
||||||
|
<div class="round top16">
|
||||||
|
<span v-for="(name, index) in top16" :key="index">{{ name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="round odds">
|
||||||
|
<span v-for="(matchName, index) in top16" :key="index" :data-candidate="matchName">
|
||||||
|
{{ odds(matchesAt(7).flatMap((match) => [match.attackerId, match.defenderId])[index]) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p>배당률이 낮을수록 베팅된 금액이 많고 유저들이 우승후보로 많이 선택한 장수입니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="currentMatch" class="fight bg0">
|
||||||
|
<h2>{{ nameOf(currentMatch.attackerId) }} vs {{ nameOf(currentMatch.defenderId) }}</h2>
|
||||||
|
<p v-for="(line, index) in currentMatch.log ?? []" :key="index">{{ line }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-title groups-title bg2">조별 본선 순위</section>
|
||||||
|
<section class="group-grid bg0">
|
||||||
|
<table v-for="(group, groupIndex) in groups" :key="groupIndex">
|
||||||
|
<caption>
|
||||||
|
{{
|
||||||
|
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex]
|
||||||
|
}}조
|
||||||
|
</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>순</th>
|
||||||
|
<th>장수</th>
|
||||||
|
<th>경</th>
|
||||||
|
<th>승</th>
|
||||||
|
<th>무</th>
|
||||||
|
<th>패</th>
|
||||||
|
<th>점</th>
|
||||||
|
<th>득</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="rowIndex in 4" :key="rowIndex">
|
||||||
|
<td>{{ rowIndex }}</td>
|
||||||
|
<td>{{ group[rowIndex - 1]?.name ?? '' }}</td>
|
||||||
|
<td>
|
||||||
|
{{
|
||||||
|
group[rowIndex - 1]
|
||||||
|
? (group[rowIndex - 1]!.win ?? 0) +
|
||||||
|
(group[rowIndex - 1]!.draw ?? 0) +
|
||||||
|
(group[rowIndex - 1]!.lose ?? 0)
|
||||||
|
: ''
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
||||||
|
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
||||||
|
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
||||||
|
<td>
|
||||||
|
{{
|
||||||
|
group[rowIndex - 1]
|
||||||
|
? (group[rowIndex - 1]!.win ?? 0) * 3 + (group[rowIndex - 1]!.draw ?? 0)
|
||||||
|
: ''
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="adminEnabled" class="admin-row bg0">
|
||||||
|
<strong>관리자 메뉴</strong>
|
||||||
|
<button type="button" @click="start">개최</button>
|
||||||
|
<button type="button" @click="cancel">중단</button>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tournament-page {
|
.legacy-page {
|
||||||
|
width: 2000px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 24px;
|
margin: 0 auto;
|
||||||
display: flex;
|
color: #fff;
|
||||||
flex-direction: column;
|
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||||
gap: 16px;
|
font-size: 14px;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.legacy-page,
|
||||||
.page-header {
|
.legacy-page * {
|
||||||
display: flex;
|
box-sizing: border-box;
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
padding-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
.bg0 {
|
||||||
.page-title {
|
background: #3a2118 url('/image/game/back_walnut.jpg');
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
.bg2 {
|
||||||
.page-subtitle {
|
background: #142b42 url('/image/game/back_blue.jpg');
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
}
|
}
|
||||||
|
.legacy-title {
|
||||||
.header-actions {
|
height: 55.6875px;
|
||||||
display: flex;
|
padding: 0;
|
||||||
flex-wrap: wrap;
|
font-size: 14px;
|
||||||
gap: 8px;
|
line-height: 19.1875px;
|
||||||
}
|
}
|
||||||
|
.close-button {
|
||||||
.ghost {
|
display: block;
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
width: 62px;
|
||||||
padding: 6px 12px;
|
height: 35.5px;
|
||||||
font-size: 0.8rem;
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #375a7f;
|
||||||
|
border-radius: 5.25px;
|
||||||
|
background: #375a7f;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
min-height: 36.5px;
|
||||||
|
padding: 1px;
|
||||||
|
}
|
||||||
|
.operator-row,
|
||||||
|
.state-row,
|
||||||
|
.error-row,
|
||||||
|
.admin-row {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
height: 35.5px;
|
||||||
|
margin: 0 2px;
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 5.25px;
|
||||||
|
background: #444;
|
||||||
|
color: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: rgba(16, 16, 16, 0.6);
|
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
button:hover,
|
||||||
.ghost:disabled {
|
button:focus {
|
||||||
opacity: 0.6;
|
filter: brightness(1.25);
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
.close-button:hover,
|
||||||
.error {
|
.close-button:focus {
|
||||||
color: #f5b7b1;
|
filter: brightness(1.2);
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
}
|
||||||
|
button:focus-visible {
|
||||||
.notice {
|
outline: 2px solid #f39c12;
|
||||||
color: #f5d08a;
|
outline-offset: 1px;
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
}
|
||||||
|
.join-button {
|
||||||
.layout-desktop {
|
background: #8a5b13;
|
||||||
|
}
|
||||||
|
.operator-row span {
|
||||||
|
color: orange;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.state-row {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.state-row .type {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 5px;
|
||||||
|
color: magenta;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.bracket {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
.round {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(320px, 1.2fr) minmax(320px, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stack {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-mobile {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-tabs {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-tabs button {
|
|
||||||
padding: 6px 4px;
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
background: rgba(16, 16, 16, 0.6);
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-tabs button.active {
|
|
||||||
background: rgba(201, 164, 90, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-actions {
|
|
||||||
margin-top: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
min-height: 24px;
|
||||||
}
|
}
|
||||||
|
.champion {
|
||||||
.muted {
|
grid-template-columns: 1fr;
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
}
|
||||||
|
.final {
|
||||||
.match-summary {
|
grid-template-columns: repeat(2, 1fr);
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
.semi {
|
||||||
.match-title {
|
grid-template-columns: repeat(4, 1fr);
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
.quarter {
|
||||||
.match-meta {
|
grid-template-columns: repeat(8, 1fr);
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
}
|
}
|
||||||
|
.top16,
|
||||||
.energy-bars {
|
.odds {
|
||||||
margin-top: 6px;
|
grid-template-columns: repeat(16, 125px);
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
.connector {
|
||||||
.energy-row {
|
min-height: 24px;
|
||||||
display: grid;
|
white-space: pre;
|
||||||
grid-template-columns: auto 1fr auto;
|
color: #fff;
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
.odds {
|
||||||
.energy-label {
|
color: skyblue;
|
||||||
width: 36px;
|
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
}
|
}
|
||||||
|
.bracket p {
|
||||||
.energy-track {
|
color: skyblue;
|
||||||
height: 8px;
|
font-size: 18px;
|
||||||
background: rgba(201, 164, 90, 0.15);
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
.fight {
|
||||||
.energy-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.energy-fill.attacker {
|
|
||||||
background: linear-gradient(90deg, rgba(245, 184, 101, 0.9), rgba(245, 184, 101, 0.4));
|
|
||||||
}
|
|
||||||
|
|
||||||
.energy-fill.defender {
|
|
||||||
background: linear-gradient(90deg, rgba(120, 170, 255, 0.9), rgba(120, 170, 255, 0.4));
|
|
||||||
}
|
|
||||||
|
|
||||||
.energy-value {
|
|
||||||
width: 40px;
|
|
||||||
text-align: right;
|
|
||||||
color: rgba(232, 221, 196, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-box {
|
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
text-align: left;
|
||||||
background: rgba(12, 12, 12, 0.6);
|
|
||||||
max-height: 240px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
}
|
||||||
|
.fight h2 {
|
||||||
.log-line {
|
margin: 0;
|
||||||
font-size: 0.8rem;
|
text-align: center;
|
||||||
line-height: 1.4;
|
color: orange;
|
||||||
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
.fight p {
|
||||||
.candidate-list {
|
margin: 2px 10px;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
.groups-title {
|
||||||
.match-row {
|
color: orange;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
|
.group-grid {
|
||||||
.match-row .vs {
|
|
||||||
color: rgba(232, 221, 196, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.winner {
|
|
||||||
color: #f5d08a;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bracket-section {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bracket-section h3 {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.participant-grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
grid-template-columns: repeat(8, 250px);
|
||||||
gap: 8px;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
table {
|
||||||
.participant-item {
|
width: 250px;
|
||||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
border-collapse: collapse;
|
||||||
padding: 8px;
|
table-layout: auto;
|
||||||
background: rgba(12, 12, 12, 0.5);
|
|
||||||
}
|
}
|
||||||
|
caption {
|
||||||
.participant-name {
|
padding: 3px;
|
||||||
font-weight: 600;
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
th {
|
||||||
.betting-grid {
|
background: #154b2a url('/image/game/back_green.jpg');
|
||||||
display: flex;
|
font-weight: 400;
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
}
|
||||||
|
th,
|
||||||
.betting-row {
|
td {
|
||||||
display: grid;
|
height: 22px;
|
||||||
grid-template-columns: 1fr auto auto;
|
border: 1px solid #555;
|
||||||
gap: 8px;
|
padding: 1px 3px;
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
}
|
||||||
|
.admin-row {
|
||||||
.bet-form {
|
text-align: left;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
.error-row {
|
||||||
.bet-form label {
|
color: #ff8080;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
.bet-form input,
|
|
||||||
.bet-form select {
|
|
||||||
padding: 6px;
|
|
||||||
background: rgba(16, 16, 16, 0.6);
|
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -4,18 +4,10 @@ import path from 'node:path';
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import { createGamePostgresConnector, resolvePostgresConfigFromEnv, type GatewayPrisma } from '@sammo-ts/infra';
|
||||||
createGamePostgresConnector,
|
|
||||||
resolvePostgresConfigFromEnv,
|
|
||||||
type GatewayPrisma,
|
|
||||||
} from '@sammo-ts/infra';
|
|
||||||
|
|
||||||
import { procedure, router } from './trpc.js';
|
import { procedure, router } from './trpc.js';
|
||||||
import {
|
import { listScenarioPreviews, resolveGitBranchCommitSha, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
|
||||||
listScenarioPreviews,
|
|
||||||
resolveGitBranchCommitSha,
|
|
||||||
resolveGitCommitSha,
|
|
||||||
} from './scenario/scenarioCatalog.js';
|
|
||||||
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
||||||
import { toPublicUser } from './auth/userRepository.js';
|
import { toPublicUser } from './auth/userRepository.js';
|
||||||
import type { AdminAuthContext } from './adminAuth.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 TURN_TERM_MINUTES = [1, 2, 5, 10, 20, 30, 60, 120] as const;
|
||||||
const AUTORUN_USER_OPTIONS = [
|
const AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const;
|
||||||
'develop',
|
|
||||||
'warp',
|
|
||||||
'recruit',
|
|
||||||
'recruit_high',
|
|
||||||
'train',
|
|
||||||
'battle',
|
|
||||||
'chief',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const ADMIN_ROLE_PREFIX = 'admin.';
|
const ADMIN_ROLE_PREFIX = 'admin.';
|
||||||
const ADMIN_ROLE_SUPERUSER = 'admin.superuser';
|
const ADMIN_ROLE_SUPERUSER = 'admin.superuser';
|
||||||
@@ -254,9 +238,12 @@ const isUniqueConstraintError = (error: unknown): boolean =>
|
|||||||
|
|
||||||
const zInstallOptions = z.object({
|
const zInstallOptions = z.object({
|
||||||
scenarioId: z.number().int().min(0),
|
scenarioId: z.number().int().min(0),
|
||||||
turnTermMinutes: z.number().int().refine((value) => isAllowedTurnTerm(value), {
|
turnTermMinutes: z
|
||||||
message: 'turnTermMinutes must divide 120.',
|
.number()
|
||||||
}),
|
.int()
|
||||||
|
.refine((value) => isAllowedTurnTerm(value), {
|
||||||
|
message: 'turnTermMinutes must divide 120.',
|
||||||
|
}),
|
||||||
sync: z.boolean(),
|
sync: z.boolean(),
|
||||||
fiction: z.number().int().min(0).max(1),
|
fiction: z.number().int().min(0).max(1),
|
||||||
extend: z.boolean(),
|
extend: z.boolean(),
|
||||||
@@ -771,55 +758,51 @@ export const adminRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
cancel: adminProcedure
|
cancel: adminProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
|
||||||
.input(z.object({ id: z.string().uuid() }))
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
.mutation(async ({ ctx, input }) => {
|
const previous = await ctx.profiles.getOperation(input.id);
|
||||||
const adminAuth = requireAdminAuth(ctx);
|
if (!previous) {
|
||||||
const previous = await ctx.profiles.getOperation(input.id);
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
||||||
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);
|
||||||
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
|
if (!cancelled) {
|
||||||
const cancelled = await ctx.profiles.cancelOperation(input.id);
|
throw new TRPCError({
|
||||||
if (!cancelled) {
|
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({
|
throw new TRPCError({
|
||||||
code: 'CONFLICT',
|
code: 'CONFLICT',
|
||||||
message: 'Only queued operations can be cancelled.',
|
message: 'Only failed or cancelled operations can be retried.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return operation;
|
||||||
}),
|
} catch (error) {
|
||||||
retry: adminProcedure
|
if (error instanceof TRPCError) {
|
||||||
.input(z.object({ id: z.string().uuid() }))
|
throw error;
|
||||||
.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);
|
if (!isUniqueConstraintError(error)) {
|
||||||
try {
|
throw error;
|
||||||
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.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}),
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'This profile already has a queued or running operation.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
profiles: router({
|
profiles: router({
|
||||||
list: adminProcedure.query(async ({ ctx }) => {
|
list: adminProcedure.query(async ({ ctx }) => {
|
||||||
@@ -834,6 +817,7 @@ export const adminRouter = router({
|
|||||||
profileName: profile.profileName,
|
profileName: profile.profileName,
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
tournamentRunning: false,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type LobbyProfileStatus = {
|
|||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
tournamentRunning: boolean;
|
||||||
};
|
};
|
||||||
korName: string;
|
korName: string;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -68,7 +69,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
|
|
||||||
private mapProfile(
|
private mapProfile(
|
||||||
row: GatewayProfileRecord,
|
row: GatewayProfileRecord,
|
||||||
runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean }>
|
runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }>
|
||||||
): LobbyProfileStatus {
|
): LobbyProfileStatus {
|
||||||
const meta = row.meta;
|
const meta = row.meta;
|
||||||
return {
|
return {
|
||||||
@@ -80,6 +81,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
runtime: runtimeMap.get(row.profileName) ?? {
|
runtime: runtimeMap.get(row.profileName) ?? {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
tournamentRunning: false,
|
||||||
},
|
},
|
||||||
korName: (meta.korName as string | undefined) ?? row.profile,
|
korName: (meta.korName as string | undefined) ?? row.profile,
|
||||||
color: (meta.color as string | undefined) ?? '#ffffff',
|
color: (meta.color as string | undefined) ?? '#ffffff',
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions {
|
|||||||
export interface ProfileRuntimeState {
|
export interface ProfileRuntimeState {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
tournamentRunning: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileRuntimeSnapshot extends ProfileRuntimeState {
|
export interface ProfileRuntimeSnapshot extends ProfileRuntimeState {
|
||||||
@@ -65,13 +66,13 @@ export const planProfileReconcile = (
|
|||||||
): { shouldStart: boolean; shouldStop: boolean } => {
|
): { shouldStart: boolean; shouldStop: boolean } => {
|
||||||
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
||||||
return {
|
return {
|
||||||
shouldStart: !(runtime.apiRunning && runtime.daemonRunning),
|
shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning),
|
||||||
shouldStop: false,
|
shouldStop: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
shouldStart: false,
|
shouldStart: false,
|
||||||
shouldStop: runtime.apiRunning || runtime.daemonRunning,
|
shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -197,14 +198,18 @@ const parseInstallOptions = (
|
|||||||
: undefined;
|
: undefined;
|
||||||
const sync = typeof install.sync === 'boolean' ? install.sync : undefined;
|
const sync = typeof install.sync === 'boolean' ? install.sync : undefined;
|
||||||
const fiction =
|
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 extend = typeof install.extend === 'boolean' ? install.extend : undefined;
|
||||||
const blockGeneralCreate =
|
const blockGeneralCreate =
|
||||||
typeof install.blockGeneralCreate === 'number' && Number.isFinite(install.blockGeneralCreate)
|
typeof install.blockGeneralCreate === 'number' && Number.isFinite(install.blockGeneralCreate)
|
||||||
? Math.floor(install.blockGeneralCreate)
|
? Math.floor(install.blockGeneralCreate)
|
||||||
: undefined;
|
: undefined;
|
||||||
const npcMode =
|
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 =
|
const showImgLevel =
|
||||||
typeof install.showImgLevel === 'number' && Number.isFinite(install.showImgLevel)
|
typeof install.showImgLevel === 'number' && Number.isFinite(install.showImgLevel)
|
||||||
? Math.floor(install.showImgLevel)
|
? Math.floor(install.showImgLevel)
|
||||||
@@ -241,9 +246,7 @@ const parseInstallOptions = (
|
|||||||
? install.adminUser.username
|
? install.adminUser.username
|
||||||
: install.adminUser.id,
|
: install.adminUser.id,
|
||||||
displayName:
|
displayName:
|
||||||
typeof install.adminUser.displayName === 'string'
|
typeof install.adminUser.displayName === 'string' ? install.adminUser.displayName : undefined,
|
||||||
? install.adminUser.displayName
|
|
||||||
: undefined,
|
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -270,8 +273,8 @@ const parseInstallOptions = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string =>
|
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string =>
|
||||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`;
|
`sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`;
|
||||||
|
|
||||||
const isMissingProcessError = (error: unknown): boolean =>
|
const isMissingProcessError = (error: unknown): boolean =>
|
||||||
error instanceof Error && /process or namespace not found/i.test(error.message);
|
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> };
|
api: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||||
daemon: { 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 baseEnv = { ...(config.baseEnv ?? {}) };
|
||||||
const apiName = buildProcessName(profile.profileName, 'api');
|
const apiName = buildProcessName(profile.profileName, 'api');
|
||||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||||
|
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||||
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
||||||
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
||||||
const daemonCwd = path.join(runtimeWorkspace, 'app', 'game-engine');
|
const daemonCwd = path.join(runtimeWorkspace, 'app', 'game-engine');
|
||||||
@@ -322,6 +327,15 @@ export const buildProcessDefinitions = (
|
|||||||
cwd: daemonCwd,
|
cwd: daemonCwd,
|
||||||
env: daemonEnv,
|
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) => {
|
profileNames.map((profileName) => {
|
||||||
const apiName = buildProcessName(profileName, 'api');
|
const apiName = buildProcessName(profileName, 'api');
|
||||||
const daemonName = buildProcessName(profileName, 'daemon');
|
const daemonName = buildProcessName(profileName, 'daemon');
|
||||||
|
const tournamentName = buildProcessName(profileName, 'tournament');
|
||||||
return {
|
return {
|
||||||
profileName,
|
profileName,
|
||||||
apiRunning: processNames.get(apiName) ?? false,
|
apiRunning: processNames.get(apiName) ?? false,
|
||||||
daemonRunning: processNames.get(daemonName) ?? false,
|
daemonRunning: processNames.get(daemonName) ?? false,
|
||||||
|
tournamentRunning: processNames.get(tournamentName) ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -756,8 +772,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
this.buildInFlight = true;
|
this.buildInFlight = true;
|
||||||
this.resetInFlight.add(profile.profileName);
|
this.resetInFlight.add(profile.profileName);
|
||||||
try {
|
try {
|
||||||
const { installOptions, scenarioId: installScenarioId, adminUser, openAt, preopenAt } =
|
const {
|
||||||
parseInstallOptions(action);
|
installOptions,
|
||||||
|
scenarioId: installScenarioId,
|
||||||
|
adminUser,
|
||||||
|
openAt,
|
||||||
|
preopenAt,
|
||||||
|
} = parseInstallOptions(action);
|
||||||
const tickOverride =
|
const tickOverride =
|
||||||
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
|
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
|
||||||
const seedInfo = await this.resolveResetSeedInfo(profile, {
|
const seedInfo = await this.resolveResetSeedInfo(profile, {
|
||||||
@@ -850,7 +871,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
private async resolveResetSeedInfo(
|
private async resolveResetSeedInfo(
|
||||||
profile: GatewayProfileRecord,
|
profile: GatewayProfileRecord,
|
||||||
overrides?: { scenarioId?: number | null; tickSeconds?: number }
|
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({
|
const databaseUrl = resolvePostgresConfigFromEnv({
|
||||||
env: this.processConfig.baseEnv ?? process.env,
|
env: this.processConfig.baseEnv ?? process.env,
|
||||||
schema: profile.profile,
|
schema: profile.profile,
|
||||||
@@ -871,7 +897,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
scenarioId = resolvedScenario;
|
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;
|
tickSeconds = row.tickSeconds;
|
||||||
}
|
}
|
||||||
meta = normalizeMeta(row.meta);
|
meta = normalizeMeta(row.meta);
|
||||||
@@ -889,11 +919,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
const workspace = await this.workspaceManager.prepare(commitSha);
|
const workspace = await this.workspaceManager.prepare(commitSha);
|
||||||
const lastUsedAt = this.now().toISOString();
|
const lastUsedAt = this.now().toISOString();
|
||||||
await this.repository.updateWorkspaceUsage(profileName, workspace.root, lastUsedAt);
|
await this.repository.updateWorkspaceUsage(profileName, workspace.root, lastUsedAt);
|
||||||
const commands = buildWorkspaceCommands(
|
const commands = buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv);
|
||||||
workspace.root,
|
|
||||||
workspace.needsInstall,
|
|
||||||
this.processConfig.baseEnv
|
|
||||||
);
|
|
||||||
return this.buildRunner.run(commands);
|
return this.buildRunner.run(commands);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,6 +981,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
try {
|
try {
|
||||||
await this.processManager.start(definitions.api);
|
await this.processManager.start(definitions.api);
|
||||||
await this.processManager.start(definitions.daemon);
|
await this.processManager.start(definitions.daemon);
|
||||||
|
await this.processManager.start(definitions.tournament);
|
||||||
await this.repository.updateLastError(profile.profileName, null);
|
await this.repository.updateLastError(profile.profileName, null);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -969,9 +996,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
||||||
const apiName = buildProcessName(profile.profileName, 'api');
|
const apiName = buildProcessName(profile.profileName, 'api');
|
||||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
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 existingNames = new Set((await this.processManager.list()).map((process) => process.name));
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
for (const name of [apiName, daemonName]) {
|
for (const name of [apiName, daemonName, tournamentName]) {
|
||||||
if (!existingNames.has(name)) {
|
if (!existingNames.has(name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const createHarness = (
|
|||||||
? [
|
? [
|
||||||
{ name: 'sammo:che:2:game-api', status: 'online' },
|
{ name: 'sammo:che:2:game-api', status: 'online' },
|
||||||
{ name: 'sammo:che:2:turn-daemon', status: 'online' },
|
{ name: 'sammo:che:2:turn-daemon', status: 'online' },
|
||||||
|
{ name: 'sammo:che:2:tournament-worker', status: 'online' },
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
start: async (definition) => {
|
start: async (definition) => {
|
||||||
@@ -146,6 +147,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.started.map((definition) => definition.name)).toEqual([
|
expect(harness.started.map((definition) => definition.name)).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
});
|
});
|
||||||
@@ -156,8 +158,16 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
|
|
||||||
expect(harness.statuses).toEqual(['STOPPED']);
|
expect(harness.statuses).toEqual(['STOPPED']);
|
||||||
expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
|
expect(harness.stopped).toEqual([
|
||||||
expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
|
'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']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,7 +187,11 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
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']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,8 +208,16 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
|
|
||||||
expect(harness.stopped).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
|
expect(harness.stopped).toEqual([
|
||||||
expect(harness.deleted).toEqual(['sammo:che:2:game-api', 'sammo:che:2:turn-daemon']);
|
'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']);
|
expect(harness.completions).toEqual(['FAILED']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RUNNING', {
|
planProfileReconcile('RUNNING', {
|
||||||
apiRunning: true,
|
apiRunning: true,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
tournamentRunning: true,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: true, shouldStop: false });
|
).toEqual({ shouldStart: true, shouldStop: false });
|
||||||
});
|
});
|
||||||
@@ -38,6 +39,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('PREOPEN', {
|
planProfileReconcile('PREOPEN', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: true, shouldStop: false });
|
).toEqual({ shouldStart: true, shouldStop: false });
|
||||||
});
|
});
|
||||||
@@ -47,6 +49,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RUNNING', {
|
planProfileReconcile('RUNNING', {
|
||||||
apiRunning: true,
|
apiRunning: true,
|
||||||
daemonRunning: true,
|
daemonRunning: true,
|
||||||
|
tournamentRunning: true,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: false });
|
).toEqual({ shouldStart: false, shouldStop: false });
|
||||||
});
|
});
|
||||||
@@ -56,6 +59,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('STOPPED', {
|
planProfileReconcile('STOPPED', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: true,
|
daemonRunning: true,
|
||||||
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: true });
|
).toEqual({ shouldStart: false, shouldStop: true });
|
||||||
});
|
});
|
||||||
@@ -65,6 +69,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RESERVED', {
|
planProfileReconcile('RESERVED', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: 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.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
|
||||||
expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
|
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', () => {
|
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.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||||
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
|
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',
|
profileName: 'che:2',
|
||||||
apiRunning: runtimeRunning,
|
apiRunning: runtimeRunning,
|
||||||
daemonRunning: 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('source-help')).toContainText('실제로 시작될 때');
|
||||||
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
|
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
|
||||||
|
|
||||||
const desktopGeometry = await page.getByTestId('server-operations-page').locator('section').first().evaluate((section) => {
|
const desktopGeometry = await page
|
||||||
const children = Array.from(section.children).map((child) => {
|
.getByTestId('server-operations-page')
|
||||||
const rect = child.getBoundingClientRect();
|
.locator('section')
|
||||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
.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).toHaveLength(2);
|
||||||
expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x);
|
expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x);
|
||||||
const sourceInput = page.getByTestId('source-ref');
|
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');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
const mobileGeometry = await page.getByTestId('server-operations-page').locator('section').first().evaluate((section) => {
|
const mobileGeometry = await page
|
||||||
const children = Array.from(section.children).map((child) => {
|
.getByTestId('server-operations-page')
|
||||||
const rect = child.getBoundingClientRect();
|
.locator('section')
|
||||||
return { x: rect.x, y: rect.y, width: rect.width };
|
.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[1]!.y).toBeGreaterThan(mobileGeometry[0]!.y);
|
||||||
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390);
|
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390);
|
||||||
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true });
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type AdminProfile = {
|
|||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
tournamentRunning: boolean;
|
||||||
};
|
};
|
||||||
buildCommitSha?: string;
|
buildCommitSha?: string;
|
||||||
meta: Record<string, unknown>;
|
meta: Record<string, unknown>;
|
||||||
@@ -121,15 +122,7 @@ type InstallFormState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AdminAction =
|
type AdminAction =
|
||||||
| 'RESUME'
|
'RESUME' | 'PAUSE' | 'STOP' | 'ACCELERATE' | 'DELAY' | 'RESET_NOW' | 'RESET_SCHEDULED' | 'OPEN_SURVEY' | 'SHUTDOWN';
|
||||||
| 'PAUSE'
|
|
||||||
| 'STOP'
|
|
||||||
| 'ACCELERATE'
|
|
||||||
| 'DELAY'
|
|
||||||
| 'RESET_NOW'
|
|
||||||
| 'RESET_SCHEDULED'
|
|
||||||
| 'OPEN_SURVEY'
|
|
||||||
| 'SHUTDOWN';
|
|
||||||
|
|
||||||
type AdminClient = {
|
type AdminClient = {
|
||||||
system: {
|
system: {
|
||||||
@@ -436,8 +429,7 @@ const toLocalInputValue = (value: unknown): string => {
|
|||||||
const readNumber = (value: unknown, fallback: number): number =>
|
const readNumber = (value: unknown, fallback: number): number =>
|
||||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
|
|
||||||
const readBoolean = (value: unknown, fallback: boolean): boolean =>
|
const readBoolean = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback);
|
||||||
typeof value === 'boolean' ? value : fallback;
|
|
||||||
|
|
||||||
const readString = (value: unknown, fallback: string): string => (typeof value === 'string' ? 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>
|
<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"
|
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap"
|
||||||
>{{ JSON.stringify(userResult.sanctions, null, 2) }}
|
>{{ JSON.stringify(userResult.sanctions, null, 2) }}
|
||||||
</pre
|
</pre>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h4 class="text-base font-semibold">로컬 계정 생성</h4>
|
<h4 class="text-base font-semibold">로컬 계정 생성</h4>
|
||||||
<span class="text-xs text-zinc-500">
|
<span class="text-xs text-zinc-500"> ENV {{ localAccountEnabled ? 'ON' : 'OFF' }} </span>
|
||||||
ENV {{ localAccountEnabled ? 'ON' : 'OFF' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-zinc-500">
|
|
||||||
카카오 OAuth 없이 로그인 가능한 계정을 생성합니다.
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">카카오 OAuth 없이 로그인 가능한 계정을 생성합니다.</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="localAccountForm.username"
|
v-model="localAccountForm.username"
|
||||||
@@ -1365,7 +1352,8 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-400">
|
<div class="text-xs text-zinc-400">
|
||||||
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
상태: {{ 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1504,7 +1492,9 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h4 class="text-sm font-semibold">설치/리셋</h4>
|
<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>
|
||||||
<div class="grid lg:grid-cols-2 gap-4">
|
<div class="grid lg:grid-cols-2 gap-4">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -1525,7 +1515,9 @@ onMounted(() => {
|
|||||||
불러오기
|
불러오기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-500">비워두면 현재 저장소 기준으로 불러옵니다.</div>
|
<div class="text-xs text-zinc-500">
|
||||||
|
비워두면 현재 저장소 기준으로 불러옵니다.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<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"
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
:disabled="getScenarioLoading(profile.profileName)"
|
:disabled="getScenarioLoading(profile.profileName)"
|
||||||
>
|
>
|
||||||
<option v-if="getScenarioLoading(profile.profileName)" disabled>불러오는 중...</option>
|
<option v-if="getScenarioLoading(profile.profileName)" disabled>
|
||||||
<template v-for="(items, group) in getScenarioGroups(profile.profileName)" :key="group">
|
불러오는 중...
|
||||||
|
</option>
|
||||||
|
<template
|
||||||
|
v-for="(items, group) in getScenarioGroups(profile.profileName)"
|
||||||
|
:key="group"
|
||||||
|
>
|
||||||
<optgroup :label="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 }}
|
{{ scenario.title }}
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</template>
|
</template>
|
||||||
</select>
|
</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) }}
|
{{ getScenarioStatus(profile.profileName) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1553,7 +1557,9 @@ onMounted(() => {
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<label class="text-xs text-zinc-400">턴 시간(분)</label>
|
<label class="text-xs text-zinc-400">턴 시간(분)</label>
|
||||||
<select
|
<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"
|
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">
|
<option v-for="term in turnTermOptions" :key="term" :value="term">
|
||||||
@@ -1664,7 +1670,9 @@ onMounted(() => {
|
|||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
<input
|
<input
|
||||||
v-model.number="profileInstalls[profile.profileName].blockGeneralCreate"
|
v-model.number="
|
||||||
|
profileInstalls[profile.profileName].blockGeneralCreate
|
||||||
|
"
|
||||||
class="accent-yellow-500"
|
class="accent-yellow-500"
|
||||||
type="radio"
|
type="radio"
|
||||||
:value="0"
|
:value="0"
|
||||||
@@ -1673,7 +1681,9 @@ onMounted(() => {
|
|||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
<input
|
<input
|
||||||
v-model.number="profileInstalls[profile.profileName].blockGeneralCreate"
|
v-model.number="
|
||||||
|
profileInstalls[profile.profileName].blockGeneralCreate
|
||||||
|
"
|
||||||
class="accent-yellow-500"
|
class="accent-yellow-500"
|
||||||
type="radio"
|
type="radio"
|
||||||
:value="2"
|
:value="2"
|
||||||
@@ -1682,7 +1692,9 @@ onMounted(() => {
|
|||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
<input
|
<input
|
||||||
v-model.number="profileInstalls[profile.profileName].blockGeneralCreate"
|
v-model.number="
|
||||||
|
profileInstalls[profile.profileName].blockGeneralCreate
|
||||||
|
"
|
||||||
class="accent-yellow-500"
|
class="accent-yellow-500"
|
||||||
type="radio"
|
type="radio"
|
||||||
:value="1"
|
:value="1"
|
||||||
@@ -1732,7 +1744,9 @@ onMounted(() => {
|
|||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
<input
|
<input
|
||||||
v-model.number="profileInstalls[profile.profileName].showImgLevel"
|
v-model.number="
|
||||||
|
profileInstalls[profile.profileName].showImgLevel
|
||||||
|
"
|
||||||
class="accent-yellow-500"
|
class="accent-yellow-500"
|
||||||
type="radio"
|
type="radio"
|
||||||
:value="0"
|
:value="0"
|
||||||
@@ -1741,7 +1755,9 @@ onMounted(() => {
|
|||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
<input
|
<input
|
||||||
v-model.number="profileInstalls[profile.profileName].showImgLevel"
|
v-model.number="
|
||||||
|
profileInstalls[profile.profileName].showImgLevel
|
||||||
|
"
|
||||||
class="accent-yellow-500"
|
class="accent-yellow-500"
|
||||||
type="radio"
|
type="radio"
|
||||||
:value="1"
|
:value="1"
|
||||||
@@ -1750,7 +1766,9 @@ onMounted(() => {
|
|||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
<input
|
<input
|
||||||
v-model.number="profileInstalls[profile.profileName].showImgLevel"
|
v-model.number="
|
||||||
|
profileInstalls[profile.profileName].showImgLevel
|
||||||
|
"
|
||||||
class="accent-yellow-500"
|
class="accent-yellow-500"
|
||||||
type="radio"
|
type="radio"
|
||||||
:value="2"
|
:value="2"
|
||||||
@@ -1759,7 +1777,9 @@ onMounted(() => {
|
|||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
<label class="flex items-center gap-1 text-xs text-zinc-300">
|
||||||
<input
|
<input
|
||||||
v-model.number="profileInstalls[profile.profileName].showImgLevel"
|
v-model.number="
|
||||||
|
profileInstalls[profile.profileName].showImgLevel
|
||||||
|
"
|
||||||
class="accent-yellow-500"
|
class="accent-yellow-500"
|
||||||
type="radio"
|
type="radio"
|
||||||
:value="3"
|
:value="3"
|
||||||
@@ -1802,7 +1822,11 @@ onMounted(() => {
|
|||||||
class="flex items-center gap-1 text-xs text-zinc-300"
|
class="flex items-center gap-1 text-xs text-zinc-300"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="profileInstalls[profile.profileName].autorunUserOptions[option.key]"
|
v-model="
|
||||||
|
profileInstalls[profile.profileName].autorunUserOptions[
|
||||||
|
option.key
|
||||||
|
]
|
||||||
|
"
|
||||||
class="accent-yellow-500"
|
class="accent-yellow-500"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
@@ -1874,30 +1898,41 @@ onMounted(() => {
|
|||||||
설치 적용
|
설치 적용
|
||||||
</button>
|
</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">
|
<div class="font-semibold text-zinc-200">
|
||||||
{{ getScenarioPreview(profile.profileName)?.title }}
|
{{ getScenarioPreview(profile.profileName)?.title }}
|
||||||
</div>
|
</div>
|
||||||
<div>시작 연도: {{ getScenarioPreview(profile.profileName)?.year ?? '-' }}년</div>
|
<div>
|
||||||
|
시작 연도: {{ getScenarioPreview(profile.profileName)?.year ?? '-' }}년
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
NPC: {{ getScenarioPreview(profile.profileName)?.npcCount }}명
|
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">
|
<span v-if="getScenarioPreview(profile.profileName)?.npcNeutralCount">
|
||||||
/ 중립 {{ getScenarioPreview(profile.profileName)?.npcNeutralCount }}명
|
/ 중립
|
||||||
|
{{ getScenarioPreview(profile.profileName)?.npcNeutralCount }}명
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="text-zinc-400">국가</div>
|
<div class="text-zinc-400">국가</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div
|
<div
|
||||||
v-for="nation in getScenarioPreview(profile.profileName)?.nations ?? []"
|
v-for="nation in getScenarioPreview(profile.profileName)
|
||||||
|
?.nations ?? []"
|
||||||
:key="nation.id"
|
:key="nation.id"
|
||||||
class="text-[11px]"
|
class="text-[11px]"
|
||||||
>
|
>
|
||||||
<span :style="{ color: nation.color }">{{ nation.name }}</span>
|
<span :style="{ color: nation.color }">{{ nation.name }}</span>
|
||||||
{{ nation.generals }}명
|
{{ nation.generals }}명
|
||||||
<span v-if="nation.generalsEx">(+{{ nation.generalsEx }})</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type Profile = {
|
|||||||
buildWorkspace?: string;
|
buildWorkspace?: string;
|
||||||
buildError?: string;
|
buildError?: string;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
runtime: { apiRunning: boolean; daemonRunning: boolean };
|
runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean };
|
||||||
};
|
};
|
||||||
|
|
||||||
type Scenario = {
|
type Scenario = {
|
||||||
@@ -192,9 +192,7 @@ const requestRuntime = async (action: 'START' | 'STOP') => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedAutorunOptions = (): Array<
|
const selectedAutorunOptions = (): Array<'develop' | 'warp' | 'recruit' | 'train' | 'battle'> => {
|
||||||
'develop' | 'warp' | 'recruit' | 'train' | 'battle'
|
|
||||||
> => {
|
|
||||||
const options: Array<'develop' | 'warp' | 'recruit' | 'train' | 'battle'> = [];
|
const options: Array<'develop' | 'warp' | 'recruit' | 'train' | 'battle'> = [];
|
||||||
if (form.autorunDevelop) options.push('develop');
|
if (form.autorunDevelop) options.push('develop');
|
||||||
if (form.autorunWarp) options.push('warp');
|
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">
|
<div v-if="errorMessage" class="rounded border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-200">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</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 }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -376,12 +377,27 @@ onBeforeUnmount(() => {
|
|||||||
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div v-if="selectedProfile" class="space-y-1 text-xs text-zinc-500">
|
<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 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 v-if="selectedProfile.lastError" class="text-red-400">{{ selectedProfile.lastError }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,10 +421,16 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-lg font-semibold">시나리오 초기화</h3>
|
<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 }}
|
{{ activeOperation.type }} · {{ activeOperation.status }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -417,11 +439,21 @@ onBeforeUnmount(() => {
|
|||||||
<legend class="text-xs text-zinc-400">소스 종류</legend>
|
<legend class="text-xs text-zinc-400">소스 종류</legend>
|
||||||
<div class="flex gap-5">
|
<div class="flex gap-5">
|
||||||
<label class="flex items-center gap-2">
|
<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>
|
||||||
<label class="flex items-center gap-2">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,7 +464,11 @@ onBeforeUnmount(() => {
|
|||||||
<input
|
<input
|
||||||
v-model="form.sourceRef"
|
v-model="form.sourceRef"
|
||||||
class="rounded border border-zinc-700 bg-zinc-950 px-3 py-2 font-mono text-sm text-white"
|
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"
|
data-testid="source-ref"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -465,7 +501,11 @@ onBeforeUnmount(() => {
|
|||||||
v-model.number="form.turnTermMinutes"
|
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"
|
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 }}분
|
{{ minutes }}분
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -475,39 +515,59 @@ onBeforeUnmount(() => {
|
|||||||
<details class="rounded border border-zinc-800 bg-zinc-950/50 p-4">
|
<details class="rounded border border-zinc-800 bg-zinc-950/50 p-4">
|
||||||
<summary class="cursor-pointer text-sm font-semibold">고급 시나리오 옵션</summary>
|
<summary class="cursor-pointer text-sm font-semibold">고급 시나리오 옵션</summary>
|
||||||
<div class="mt-4 grid gap-4 md:grid-cols-2 text-sm">
|
<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">
|
<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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>가상 장수
|
<label
|
||||||
|
>가상 장수
|
||||||
<select v-model.number="form.fiction" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
<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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>연장
|
<label
|
||||||
|
>연장
|
||||||
<select v-model="form.extend" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
<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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>가입 방식
|
<label
|
||||||
|
>가입 방식
|
||||||
<select v-model="form.joinMode" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
<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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<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>
|
<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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>NPC 모드
|
<label
|
||||||
|
>NPC 모드
|
||||||
<select v-model.number="form.npcMode" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
<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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>이미지 표시
|
<label
|
||||||
|
>이미지 표시
|
||||||
<select v-model.number="form.showImgLevel" class="ml-2 rounded bg-zinc-900 px-2 py-1">
|
<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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
@@ -536,14 +596,29 @@ onBeforeUnmount(() => {
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-3">
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
<label class="text-xs text-zinc-400">작업 예약
|
<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" />
|
>작업 예약
|
||||||
|
<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>
|
||||||
<label class="text-xs text-zinc-400">가오픈
|
<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" />
|
>가오픈
|
||||||
|
<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>
|
||||||
<label class="text-xs text-zinc-400">정식 오픈
|
<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" />
|
>정식 오픈
|
||||||
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -572,13 +647,23 @@ onBeforeUnmount(() => {
|
|||||||
<table class="w-full min-w-[920px] text-left text-sm" data-testid="operations-table">
|
<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">
|
<thead class="border-b border-zinc-700 text-xs text-zinc-500">
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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">
|
<td class="p-2 text-xs">
|
||||||
{{ formatTime(operation.createdAt) }}
|
{{ formatTime(operation.createdAt) }}
|
||||||
<div v-if="operation.scheduledAt" class="mt-1 text-amber-300">
|
<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.profileName }}</td>
|
||||||
<td class="p-2">{{ operation.type }}</td>
|
<td class="p-2">{{ operation.type }}</td>
|
||||||
<td class="p-2 font-semibold">{{ operation.status }}</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="p-2 font-mono text-xs">{{ shortSha(operation.resolvedCommitSha) }}</td>
|
||||||
<td class="max-w-xs p-2 text-xs">
|
<td class="max-w-xs p-2 text-xs">
|
||||||
<div class="font-mono">{{ operation.requestedBy }}</div>
|
<div class="font-mono">{{ operation.requestedBy }}</div>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ fails on unknown operations. It also serves the checked-out reference image
|
|||||||
tree instead of replacing images with layout-neutral placeholders.
|
tree instead of replacing images with layout-neutral placeholders.
|
||||||
`public-gaps.spec.ts` adds bounded fixtures for nation betting and the public
|
`public-gaps.spec.ts` adds bounded fixtures for nation betting and the public
|
||||||
NPC list, including mutations and recoverable API failures.
|
NPC list, including mutations and recoverable API failures.
|
||||||
|
`tournament-betting.spec.ts` covers the separate tournament and tournament
|
||||||
|
betting routes, including a recoverable failed bet.
|
||||||
|
|
||||||
Run the suite from the core2026 repository root:
|
Run the suite from the core2026 repository root:
|
||||||
|
|
||||||
@@ -24,6 +26,11 @@ Run the suite from the core2026 repository root:
|
|||||||
pnpm test:e2e:frontend-legacy
|
pnpm test:e2e:frontend-legacy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When another worktree occupies the default ports, set
|
||||||
|
`FRONTEND_PARITY_GATEWAY_PORT`, `FRONTEND_PARITY_GAME_PORT`, and
|
||||||
|
`FRONTEND_PARITY_GAME_URL`. `FRONTEND_PARITY_ARTIFACT_DIR` retains the
|
||||||
|
tournament and betting screenshots.
|
||||||
|
|
||||||
The suite starts both applications at their public prefixes:
|
The suite starts both applications at their public prefixes:
|
||||||
|
|
||||||
- gateway: `http://127.0.0.1:15100/gateway/`
|
- gateway: `http://127.0.0.1:15100/gateway/`
|
||||||
@@ -50,6 +57,8 @@ storage, route guards, and image loading.
|
|||||||
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
||||||
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
||||||
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
||||||
|
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
||||||
|
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
||||||
|
|
||||||
The global game baseline is black, white, Pretendard 14px. Legacy texture
|
The global game baseline is black, white, Pretendard 14px. Legacy texture
|
||||||
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
|
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
|
||||||
|
|||||||
@@ -155,56 +155,57 @@ export type TurnDaemonCommand =
|
|||||||
updates: Record<string, unknown>;
|
updates: Record<string, unknown>;
|
||||||
expectedUpdatedAt?: string;
|
expectedUpdatedAt?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'adjustGeneralResources';
|
type: 'adjustGeneralResources';
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
adjustments: Array<{
|
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;
|
|
||||||
generalId: number;
|
generalId: number;
|
||||||
patch: {
|
goldDelta?: number;
|
||||||
meta?: Record<string, unknown>;
|
riceDelta?: number;
|
||||||
turnTime?: string;
|
minGoldAfter?: number;
|
||||||
stats?: {
|
}>;
|
||||||
leadership?: number;
|
}
|
||||||
strength?: number;
|
| {
|
||||||
intelligence?: number;
|
type: 'adjustGeneralMeta';
|
||||||
};
|
requestId?: string;
|
||||||
specialWar?: 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;
|
||||||
};
|
};
|
||||||
}
|
specialWar?: string;
|
||||||
| {
|
|
||||||
type: 'auctionBid';
|
|
||||||
requestId?: string;
|
|
||||||
auctionId: number;
|
|
||||||
generalId: number;
|
|
||||||
amount: number;
|
|
||||||
tryExtendCloseDate?: boolean;
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'auctionBid';
|
||||||
|
requestId?: string;
|
||||||
|
auctionId: number;
|
||||||
|
generalId: number;
|
||||||
|
amount: number;
|
||||||
|
tryExtendCloseDate?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type TurnDaemonCommandResult =
|
export type TurnDaemonCommandResult =
|
||||||
| {
|
| {
|
||||||
@@ -385,70 +386,70 @@ export type TurnDaemonCommandResult =
|
|||||||
reason: string;
|
reason: string;
|
||||||
currentUpdatedAt?: string;
|
currentUpdatedAt?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'adjustGeneralResources';
|
type: 'adjustGeneralResources';
|
||||||
ok: true;
|
ok: true;
|
||||||
processed: number;
|
processed: number;
|
||||||
missing: number;
|
missing: number;
|
||||||
totalGoldDelta: number;
|
totalGoldDelta: number;
|
||||||
totalRiceDelta: number;
|
totalRiceDelta: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'adjustGeneralResources';
|
type: 'adjustGeneralResources';
|
||||||
ok: false;
|
ok: false;
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'adjustGeneralMeta';
|
type: 'adjustGeneralMeta';
|
||||||
ok: true;
|
ok: true;
|
||||||
processed: number;
|
processed: number;
|
||||||
missing: number;
|
missing: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'adjustGeneralMeta';
|
type: 'adjustGeneralMeta';
|
||||||
ok: false;
|
ok: false;
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'tournamentMatchResult';
|
type: 'tournamentMatchResult';
|
||||||
ok: true;
|
ok: true;
|
||||||
tournamentType: number;
|
tournamentType: number;
|
||||||
attackerId: number;
|
attackerId: number;
|
||||||
defenderId: number;
|
defenderId: number;
|
||||||
result: 'attacker' | 'defender' | 'draw';
|
result: 'attacker' | 'defender' | 'draw';
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'tournamentMatchResult';
|
type: 'tournamentMatchResult';
|
||||||
ok: false;
|
ok: false;
|
||||||
tournamentType: number;
|
tournamentType: number;
|
||||||
attackerId: number;
|
attackerId: number;
|
||||||
defenderId: number;
|
defenderId: number;
|
||||||
result: 'attacker' | 'defender' | 'draw';
|
result: 'attacker' | 'defender' | 'draw';
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'patchGeneral';
|
type: 'patchGeneral';
|
||||||
ok: true;
|
ok: true;
|
||||||
generalId: number;
|
generalId: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'patchGeneral';
|
type: 'patchGeneral';
|
||||||
ok: false;
|
ok: false;
|
||||||
generalId: number;
|
generalId: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'auctionBid';
|
type: 'auctionBid';
|
||||||
ok: true;
|
ok: true;
|
||||||
auctionId: number;
|
auctionId: number;
|
||||||
closeAt: string;
|
closeAt: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'auctionBid';
|
type: 'auctionBid';
|
||||||
ok: false;
|
ok: false;
|
||||||
auctionId: number;
|
auctionId: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TurnDaemonEvent =
|
export type TurnDaemonEvent =
|
||||||
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
|
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
|
||||||
|
|||||||
@@ -110,6 +110,31 @@ export const resolveStartYear = (world: ActionContextWorldState, scenarioMeta?:
|
|||||||
return world.currentYear;
|
return world.currentYear;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readFiniteInteger = (value: unknown, fallback: number): number => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return Math.floor(value);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return Math.floor(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveInitYearMonth = (
|
||||||
|
world: ActionContextWorldState,
|
||||||
|
scenarioMeta?: ScenarioMeta
|
||||||
|
): { year: number; month: number } => {
|
||||||
|
const meta = asRecord(world.meta);
|
||||||
|
const startYear = resolveStartYear(world, scenarioMeta);
|
||||||
|
return {
|
||||||
|
year: readFiniteInteger(meta.initYear, startYear),
|
||||||
|
month: readFiniteInteger(meta.initMonth, 1),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const resolveTurnTermMinutes = (world: ActionContextWorldState): number =>
|
export const resolveTurnTermMinutes = (world: ActionContextWorldState): number =>
|
||||||
Math.max(1, Math.round(world.tickSeconds / 60));
|
Math.max(1, Math.round(world.tickSeconds / 60));
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { z } from 'zod';
|
|||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
@@ -223,34 +224,18 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveStartYear = (currentYear: number, raw: unknown): number => {
|
|
||||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
||||||
return Math.floor(raw);
|
|
||||||
}
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
const parsed = Number(raw);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
return Math.floor(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return currentYear;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||||
const nationId = base.nation?.id ?? base.general.nationId;
|
const nationId = base.nation?.id ?? base.general.nationId;
|
||||||
const currentYear = options.world.currentYear;
|
const currentYear = options.world.currentYear;
|
||||||
const currentMonth = options.world.currentMonth;
|
const currentMonth = options.world.currentMonth;
|
||||||
|
const init = resolveInitYearMonth(options.world, options.scenarioMeta);
|
||||||
const startYear = resolveStartYear(currentYear, options.scenarioMeta?.startYear);
|
|
||||||
const initYear = startYear;
|
|
||||||
const initMonth = 1;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
allCities: options.worldRef?.listCities() ?? [],
|
allCities: options.worldRef?.listCities() ?? [],
|
||||||
nationGenerals: options.worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [],
|
nationGenerals: options.worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [],
|
||||||
currentYearMonth: currentYear * 12 + currentMonth - 1,
|
currentYearMonth: currentYear * 12 + currentMonth - 1,
|
||||||
initYearMonth: initYear * 12 + initMonth - 1,
|
initYearMonth: init.year * 12 + init.month - 1,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||||
|
import { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
|
|
||||||
@@ -145,33 +146,19 @@ export class ActionDefinition<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveStartYear = (currentYear: number, raw: unknown): number => {
|
|
||||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
||||||
return Math.floor(raw);
|
|
||||||
}
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
const parsed = Number(raw);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
return Math.floor(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return currentYear;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||||
const nationId = base.nation?.id ?? base.general.nationId;
|
const nationId = base.nation?.id ?? base.general.nationId;
|
||||||
const worldRef = options.worldRef;
|
const worldRef = options.worldRef;
|
||||||
const currentYear = options.world.currentYear;
|
const currentYear = options.world.currentYear;
|
||||||
const currentMonth = options.world.currentMonth;
|
const currentMonth = options.world.currentMonth;
|
||||||
const initYear = resolveStartYear(currentYear, options.scenarioMeta?.startYear);
|
const init = resolveInitYearMonth(options.world, options.scenarioMeta);
|
||||||
const initMonth = 1;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
nationGenerals: worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [],
|
nationGenerals: worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [],
|
||||||
nationCities: worldRef?.listCities().filter((city) => city.nationId === nationId) ?? [],
|
nationCities: worldRef?.listCities().filter((city) => city.nationId === nationId) ?? [],
|
||||||
currentYearMonth: currentYear * 12 + currentMonth - 1,
|
currentYearMonth: currentYear * 12 + currentMonth - 1,
|
||||||
initYearMonth: initYear * 12 + initMonth - 1,
|
initYearMonth: init.year * 12 + init.month - 1,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const gatewayPort = process.env.FRONTEND_PARITY_GATEWAY_PORT ?? '15100';
|
||||||
|
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: '.',
|
testDir: '.',
|
||||||
testMatch: ['visual-parity.spec.ts', 'public-gaps.spec.ts', 'instant-diplomacy-message.spec.ts'],
|
testMatch: [
|
||||||
|
'visual-parity.spec.ts',
|
||||||
|
'public-gaps.spec.ts',
|
||||||
|
'instant-diplomacy-message.spec.ts',
|
||||||
|
'tournament-betting.spec.ts',
|
||||||
|
],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
@@ -29,18 +36,16 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
webServer: [
|
webServer: [
|
||||||
{
|
{
|
||||||
command:
|
command: `VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_API_URL_TEMPLATE=/{profile}/api/trpc VITE_GAME_ASSET_URL=/image pnpm --filter @sammo-ts/gateway-frontend dev --host 127.0.0.1 --port ${gatewayPort}`,
|
||||||
'VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_API_URL_TEMPLATE=/{profile}/api/trpc VITE_GAME_ASSET_URL=/image pnpm --filter @sammo-ts/gateway-frontend dev --host 127.0.0.1 --port 15100',
|
|
||||||
cwd: repositoryRoot,
|
cwd: repositoryRoot,
|
||||||
url: 'http://127.0.0.1:15100/gateway/',
|
url: `http://127.0.0.1:${gatewayPort}/gateway/`,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command:
|
command: `VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc VITE_GAME_ASSET_URL=/image VITE_GAME_PROFILE=che VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${gamePort}`,
|
||||||
'VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc VITE_GAME_ASSET_URL=/image VITE_GAME_PROFILE=che VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port 15102',
|
|
||||||
cwd: repositoryRoot,
|
cwd: repositoryRoot,
|
||||||
url: 'http://127.0.0.1:15102/che/',
|
url: `http://127.0.0.1:${gamePort}/che/`,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const gameUrl = process.env.FRONTEND_PARITY_GAME_URL ?? 'http://127.0.0.1:15102';
|
||||||
|
const artifactDir = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||||
|
const errorResponse = (path: string, message: string) => ({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
code: -32000,
|
||||||
|
data: { code: 'BAD_REQUEST', httpStatus: 400, path },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const operationNames = (route: Route): string[] => {
|
||||||
|
const pathname = new URL(route.request().url()).pathname;
|
||||||
|
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const participants = Array.from({ length: 64 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
name: `장수${String(index + 1).padStart(2, '0')}`,
|
||||||
|
leadership: 80 - (index % 20),
|
||||||
|
strength: 70 + (index % 20),
|
||||||
|
intel: 65 + (index % 15),
|
||||||
|
level: 5,
|
||||||
|
groupId: 10 + Math.floor(index / 8),
|
||||||
|
groupNo: index % 8,
|
||||||
|
win: index % 4,
|
||||||
|
draw: index % 2,
|
||||||
|
lose: (index + 1) % 3,
|
||||||
|
gl: 20 - index,
|
||||||
|
finalRank: (index % 8) + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const matches = Array.from({ length: 8 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
stage: 7,
|
||||||
|
roundIndex: index,
|
||||||
|
attackerId: index * 2 + 1,
|
||||||
|
defenderId: index * 2 + 2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
state: {
|
||||||
|
stage: 6,
|
||||||
|
phase: 0,
|
||||||
|
type: 0,
|
||||||
|
auto: true,
|
||||||
|
openYear: 193,
|
||||||
|
openMonth: 1,
|
||||||
|
termSeconds: 60,
|
||||||
|
nextAt: '2099-01-01T00:00:00.000Z',
|
||||||
|
bettingId: 7,
|
||||||
|
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
participants,
|
||||||
|
matches,
|
||||||
|
betCount: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bettingSummary = {
|
||||||
|
state: snapshot.state,
|
||||||
|
totals: { 1: 300, 2: 200, 3: 100 },
|
||||||
|
myTotals: { 1: 50 },
|
||||||
|
totalAmount: 600,
|
||||||
|
myAmount: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
const installFixture = async (page: Page) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem('sammo-game-token', 'ga_tournament_visual');
|
||||||
|
window.localStorage.setItem('sammo-game-profile', 'che:default');
|
||||||
|
});
|
||||||
|
let betCalls = 0;
|
||||||
|
let joinCalls = 0;
|
||||||
|
await page.route('**/image/game/**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
body: Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2Q==', 'base64'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
const results = operationNames(route).map((operation) => {
|
||||||
|
if (operation === 'lobby.info') {
|
||||||
|
return response({ myGeneral: { id: 64, name: '내장수' } });
|
||||||
|
}
|
||||||
|
if (operation === 'join.getConfig') return response({});
|
||||||
|
if (operation === 'general.me') {
|
||||||
|
return response({ general: { id: 64, name: '내장수' }, nation: null, city: null });
|
||||||
|
}
|
||||||
|
if (operation === 'tournament.getSnapshot') return response(snapshot);
|
||||||
|
if (operation === 'tournament.getBettingSummary') return response(bettingSummary);
|
||||||
|
if (operation === 'tournament.getRankings') {
|
||||||
|
return response(
|
||||||
|
[
|
||||||
|
['tt', '전 력 전', '종합'],
|
||||||
|
['tl', '통 솔 전', '통솔'],
|
||||||
|
['ts', '일 기 토', '무력'],
|
||||||
|
['ti', '설 전', '지력'],
|
||||||
|
].map(([prefix, title, statLabel]) => ({
|
||||||
|
prefix,
|
||||||
|
title,
|
||||||
|
statLabel,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
rank: 1,
|
||||||
|
generalId: 1,
|
||||||
|
name: '장수01',
|
||||||
|
npcState: 0,
|
||||||
|
stat: 210,
|
||||||
|
games: 12,
|
||||||
|
win: 8,
|
||||||
|
draw: 2,
|
||||||
|
lose: 2,
|
||||||
|
score: 42,
|
||||||
|
prizes: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (operation === 'tournament.getAdminStatus')
|
||||||
|
return errorResponse(operation, 'Admin permission is required.');
|
||||||
|
if (operation === 'tournament.join') {
|
||||||
|
joinCalls += 1;
|
||||||
|
return joinCalls === 1
|
||||||
|
? errorResponse(operation, '금이 부족합니다.')
|
||||||
|
: response({ ok: true, count: 64 });
|
||||||
|
}
|
||||||
|
if (operation === 'tournament.placeBet') {
|
||||||
|
betCalls += 1;
|
||||||
|
return betCalls === 1
|
||||||
|
? errorResponse(operation, '500금까지만 베팅 가능합니다.')
|
||||||
|
: response({ ok: true });
|
||||||
|
}
|
||||||
|
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await installFixture(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tournament keeps the legacy 2000px bracket and 250px group geometry', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 2200, height: 1000 });
|
||||||
|
await page.goto(`${gameUrl}/che/tournament`);
|
||||||
|
await expect(page.getByText('삼모전 토너먼트')).toBeVisible();
|
||||||
|
await expect(page.locator('.top16 span')).toHaveCount(16);
|
||||||
|
|
||||||
|
const geometry = await page.locator('#tournament-container').evaluate((container) => {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const candidate = container.querySelector<HTMLElement>('.top16 span')!;
|
||||||
|
const groupTable = container.querySelector<HTMLElement>('.group-grid table')!;
|
||||||
|
const title = container.querySelector<HTMLElement>('.legacy-title')!;
|
||||||
|
const refresh = container.querySelector<HTMLElement>('.toolbar button')!;
|
||||||
|
const style = getComputedStyle(container);
|
||||||
|
return {
|
||||||
|
x: rect.x,
|
||||||
|
width: rect.width,
|
||||||
|
candidateWidth: candidate.getBoundingClientRect().width,
|
||||||
|
groupWidth: groupTable.getBoundingClientRect().width,
|
||||||
|
titleHeight: title.getBoundingClientRect().height,
|
||||||
|
refreshHeight: refresh.getBoundingClientRect().height,
|
||||||
|
refreshRadius: getComputedStyle(refresh).borderRadius,
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
backgroundImage: getComputedStyle(container.querySelector<HTMLElement>('.bracket')!).backgroundImage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry).toMatchObject({
|
||||||
|
x: 100,
|
||||||
|
width: 2000,
|
||||||
|
candidateWidth: 125,
|
||||||
|
groupWidth: 250,
|
||||||
|
titleHeight: 55.6875,
|
||||||
|
refreshHeight: 35.5,
|
||||||
|
refreshRadius: '5.25px',
|
||||||
|
fontSize: '14px',
|
||||||
|
});
|
||||||
|
expect(geometry.fontFamily).toContain('Pretendard');
|
||||||
|
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
|
||||||
|
if (artifactDir) await page.screenshot({ path: `${artifactDir}/core-tournament.png`, fullPage: true });
|
||||||
|
|
||||||
|
const refresh = page.getByRole('button', { name: '갱신' });
|
||||||
|
const before = await refresh.evaluate((element) => getComputedStyle(element).filter);
|
||||||
|
await refresh.hover();
|
||||||
|
const hover = await refresh.evaluate((element) => getComputedStyle(element).filter);
|
||||||
|
await refresh.focus();
|
||||||
|
await expect(refresh).toBeFocused();
|
||||||
|
expect(hover).not.toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tournament keeps the fixed legacy canvas at a 1024px viewport', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1024, height: 768 });
|
||||||
|
await page.goto(`${gameUrl}/che/tournament`);
|
||||||
|
await expect(page.locator('#tournament-container')).toHaveCSS('width', '2000px');
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('betting keeps the 1120px and 16 by 70px layout and retains a failed selection', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.goto(`${gameUrl}/che/betting`);
|
||||||
|
await expect(page.getByText('베 팅 장')).toBeVisible();
|
||||||
|
await expect(page.locator('.names span')).toHaveCount(16);
|
||||||
|
|
||||||
|
const geometry = await page.locator('#tournament-betting-container').evaluate((container) => {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const first = container.querySelector<HTMLElement>('.names span')!;
|
||||||
|
const title = container.querySelector<HTMLElement>('.title')!;
|
||||||
|
const refresh = container.querySelector<HTMLElement>('.toolbar button')!;
|
||||||
|
const stateStyle = getComputedStyle(container.querySelector<HTMLElement>('.state')!);
|
||||||
|
const tableStyle = getComputedStyle(container.querySelector<HTMLElement>('.candidate-table')!);
|
||||||
|
return {
|
||||||
|
x: rect.x,
|
||||||
|
width: rect.width,
|
||||||
|
candidateWidth: first.getBoundingClientRect().width,
|
||||||
|
titleHeight: title.getBoundingClientRect().height,
|
||||||
|
refreshHeight: refresh.getBoundingClientRect().height,
|
||||||
|
refreshRadius: getComputedStyle(refresh).borderRadius,
|
||||||
|
stateFontSize: stateStyle.fontSize,
|
||||||
|
tableFontSize: tableStyle.fontSize,
|
||||||
|
tableBorder: tableStyle.borderTopWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry).toEqual({
|
||||||
|
x: 80,
|
||||||
|
width: 1120,
|
||||||
|
candidateWidth: 70,
|
||||||
|
titleHeight: 55.6875,
|
||||||
|
refreshHeight: 35.5,
|
||||||
|
refreshRadius: '5.25px',
|
||||||
|
stateFontSize: '24px',
|
||||||
|
tableFontSize: '10px',
|
||||||
|
tableBorder: '1px',
|
||||||
|
});
|
||||||
|
if (artifactDir) await page.screenshot({ path: `${artifactDir}/core-betting.png`, fullPage: true });
|
||||||
|
|
||||||
|
const select = page.getByLabel('장수01 베팅 금액');
|
||||||
|
await select.selectOption('500');
|
||||||
|
const bet = page.getByRole('button', { name: '베팅!' }).first();
|
||||||
|
await bet.hover();
|
||||||
|
await bet.focus();
|
||||||
|
await expect(bet).toBeFocused();
|
||||||
|
await bet.click();
|
||||||
|
await expect(page.getByRole('status')).toHaveText('500금까지만 베팅 가능합니다.');
|
||||||
|
await expect(select).toHaveValue('500');
|
||||||
|
|
||||||
|
await bet.click();
|
||||||
|
await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.');
|
||||||
|
});
|
||||||
@@ -160,8 +160,11 @@ const createCommandProfile = (request: TurnCommandFixtureRequest): TurnCommandPr
|
|||||||
if (!GENERAL_TURN_COMMAND_KEYS.includes(request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number])) {
|
if (!GENERAL_TURN_COMMAND_KEYS.includes(request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number])) {
|
||||||
throw new Error(`Unknown general command: ${request.action}`);
|
throw new Error(`Unknown general command: ${request.action}`);
|
||||||
}
|
}
|
||||||
|
const generalActions = [request.action, '휴식', 'che_인재탐색', 'che_해산', 'che_이동'] as Array<
|
||||||
|
(typeof GENERAL_TURN_COMMAND_KEYS)[number]
|
||||||
|
>;
|
||||||
return {
|
return {
|
||||||
general: [request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number], '휴식'],
|
general: [...new Set(generalActions)],
|
||||||
nation: ['휴식'],
|
nation: ['휴식'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ const createGameClient = (baseUrl: string, trpcPath: string, accessTokenRef: { v
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildProcessName = (profileName: string, role: 'api' | 'daemon'): string =>
|
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string =>
|
||||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : 'turn-daemon'}`;
|
`sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`;
|
||||||
|
|
||||||
const waitForPm2Online = async (manager: Pm2ProcessManager, names: string[], timeoutMs = 30_000) => {
|
const waitForPm2Online = async (manager: Pm2ProcessManager, names: string[], timeoutMs = 30_000) => {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
@@ -203,10 +203,7 @@ const cleanupPm2 = async (manager: Pm2ProcessManager, names: string[]) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const waitForTurnDaemonStatus = async (
|
const waitForTurnDaemonStatus = async (gameClient: ReturnType<typeof createGameClient>, timeoutMs = 90_000) => {
|
||||||
gameClient: ReturnType<typeof createGameClient>,
|
|
||||||
timeoutMs = 90_000
|
|
||||||
) => {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
let lastError: string | null = null;
|
let lastError: string | null = null;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
@@ -415,7 +412,11 @@ describe('pm2 orchestrator e2e', () => {
|
|||||||
}
|
}
|
||||||
profileName = `${profile}:${scenario}`;
|
profileName = `${profile}:${scenario}`;
|
||||||
apiPort = Number(process.env.GAME_API_PORT ?? '14000');
|
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 resetDatabase(profile);
|
||||||
await resetRedis();
|
await resetRedis();
|
||||||
@@ -439,7 +440,7 @@ describe('pm2 orchestrator e2e', () => {
|
|||||||
}
|
}
|
||||||
}, 30_000);
|
}, 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) {
|
if (!gatewayServer || !pm2Manager) {
|
||||||
throw new Error('test setup failed');
|
throw new Error('test setup failed');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -717,6 +717,117 @@ integration('general sabotage injury boundary matrix', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
integration('general command alternative matrix', () => {
|
||||||
|
const expectAlternativeParity = async (
|
||||||
|
request: TurnCommandFixtureRequest,
|
||||||
|
expectedActionKey: string,
|
||||||
|
expectedLogText: string
|
||||||
|
): Promise<void> => {
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: request.action,
|
||||||
|
actionKey: expectedActionKey,
|
||||||
|
usedFallback: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
reference.after.logs.some((entry) => typeof entry.text === 'string' && entry.text.includes(expectedLogText))
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
core.after.logs.some((entry) => typeof entry.text === 'string' && entry.text.includes(expectedLogText))
|
||||||
|
).toBe(true);
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('che_해산 continues into che_인재탐색 with the legacy RNG and state delta', async () => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_해산',
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
world: { initYear: 190, initMonth: 1 },
|
||||||
|
nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.setup!.world!.hiddenSeed = 'general-alternative-disband-search';
|
||||||
|
await expectAlternativeParity(request, 'che_인재탐색', '다음 턴부터 해산할 수 있습니다.');
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it('che_랜덤임관 continues into che_인재탐색 when no eligible nation exists', async () => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_랜덤임관',
|
||||||
|
undefined,
|
||||||
|
{ nationId: 0, officerLevel: 0 },
|
||||||
|
{
|
||||||
|
generals: {
|
||||||
|
2: { npcState: 5 },
|
||||||
|
3: { npcState: 5 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.setup!.world!.hiddenSeed = 'general-alternative-random-appointment-search';
|
||||||
|
await expectAlternativeParity(request, 'che_인재탐색', '임관 가능한 국가가 없습니다.');
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it('che_무작위건국 continues into che_인재탐색 during the initial month', async () => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_무작위건국',
|
||||||
|
{ nationName: '신국', nationType: 'che_도적', colorType: 1 },
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
world: { startYear: 190, initYear: 190, initMonth: 1 },
|
||||||
|
nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.setup!.world!.hiddenSeed = 'general-alternative-random-founding-search';
|
||||||
|
await expectAlternativeParity(request, 'che_인재탐색', '다음 턴부터 건국할 수 있습니다.');
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it('che_무작위건국 continues into che_해산 when no founding city exists', async () => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_무작위건국',
|
||||||
|
{ nationName: '신국', nationType: 'che_도적', colorType: 1 },
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
world: { initYear: 180, initMonth: 1, year: 181 },
|
||||||
|
nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } },
|
||||||
|
cities: {
|
||||||
|
3: { nationId: 1, level: 4 },
|
||||||
|
70: { nationId: 0, level: 4 },
|
||||||
|
},
|
||||||
|
randomFoundingCandidateCityIds: [3],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.setup!.world!.hiddenSeed = 'general-alternative-random-founding-disband';
|
||||||
|
await expectAlternativeParity(request, 'che_해산', '건국할 수 있는 도시가 없습니다.');
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it('che_출병 continues into che_이동 when the destination belongs to the actor nation', async () => {
|
||||||
|
const request = buildRequest(
|
||||||
|
'che_출병',
|
||||||
|
{ destCityID: 70 },
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
world: { startYear: 180, year: 185 },
|
||||||
|
cities: { 70: { nationId: 1, supplyState: 1, frontState: 0 } },
|
||||||
|
generals: { 2: { nationId: 1 } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.setup!.world!.hiddenSeed = 'general-alternative-sortie-move';
|
||||||
|
await expectAlternativeParity(request, 'che_이동', '본국입니다.');
|
||||||
|
}, 120_000);
|
||||||
|
});
|
||||||
|
|
||||||
type GeneralConstraintCase = {
|
type GeneralConstraintCase = {
|
||||||
name: string;
|
name: string;
|
||||||
action: string;
|
action: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user