fix: 토너먼트 참가 즉시 조편성과 화면 여백을 수정
수동 참가자를 비포화 예선 조에 즉시 배치하고 자동 참가 설정을 건드리던 잘못된 부작용을 제거한다. 자동 참가 장수와 NPC의 8x8 조 편입을 회귀 테스트로 고정한다. 장수 아이콘 아래 배당이 잘리지 않도록 대진 높이를 늘리고 갱신·참가·닫기 버튼의 클릭 영역을 확대한다.
This commit is contained in:
@@ -7,6 +7,7 @@ import type { TournamentState } from '../../tournament/types.js';
|
||||
|
||||
import { TournamentStore } from '../../tournament/store.js';
|
||||
import { buildTournamentKeys } from '../../tournament/keys.js';
|
||||
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
|
||||
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
@@ -412,52 +413,31 @@ export const tournamentRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
const settingResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'setMySetting',
|
||||
generalId: general.id,
|
||||
settings: { tnmt: 1 },
|
||||
const meta = asRecord(general.meta);
|
||||
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
|
||||
const applicant = assignManualApplicantGroup({
|
||||
state,
|
||||
baseSeed: String(asRecord(worldState?.meta).hiddenSeed ?? 'tournament'),
|
||||
current: participants,
|
||||
applicant: {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intel: general.intel,
|
||||
level,
|
||||
},
|
||||
});
|
||||
if (!settingResult || settingResult.type !== 'setMySetting' || !settingResult.ok) {
|
||||
const next = participants.concat(applicant);
|
||||
|
||||
try {
|
||||
await store.setParticipants(next);
|
||||
} catch (error) {
|
||||
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 };
|
||||
|
||||
@@ -155,6 +155,60 @@ export const assignGroupSlots = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Ref assigns a manual applicant to one uniformly selected non-full preliminary
|
||||
* group as part of the join request. Keeping that assignment in the persisted
|
||||
* participant projection lets the applicant see the group immediately while
|
||||
* the later participant-fill pass can still balance automatic applicants.
|
||||
*/
|
||||
export const assignManualApplicantGroup = (options: {
|
||||
state: TournamentState;
|
||||
baseSeed: string;
|
||||
current: TournamentParticipantEntry[];
|
||||
applicant: TournamentParticipantEntry;
|
||||
groupCount?: number;
|
||||
groupSize?: number;
|
||||
}): TournamentParticipantEntry => {
|
||||
const groupCount = options.groupCount ?? 8;
|
||||
const groupSize = options.groupSize ?? 8;
|
||||
const groupCounts = Array.from({ length: groupCount }, () => 0);
|
||||
|
||||
for (const participant of options.current) {
|
||||
const groupId = participant.groupId;
|
||||
if (groupId !== undefined && groupId >= 0 && groupId < groupCount) {
|
||||
groupCounts[groupId] = (groupCounts[groupId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const openGroupIds = groupCounts.flatMap((count, groupId) => (count < groupSize ? [groupId] : []));
|
||||
if (openGroupIds.length === 0) {
|
||||
throw new Error('참가 인원이 가득 찼습니다.');
|
||||
}
|
||||
|
||||
const rng = createTournamentRng(options.baseSeed, {
|
||||
openYear: options.state.openYear,
|
||||
openMonth: options.state.openMonth,
|
||||
stage: 1,
|
||||
phase: options.state.phase,
|
||||
matchIndex: options.applicant.id,
|
||||
participantIndex: options.current.length,
|
||||
extraSeed: `manual-group:${options.current.map((entry) => entry.id).join('-')}:${openGroupIds.join('-')}`,
|
||||
});
|
||||
const groupId = rng.choice(openGroupIds);
|
||||
|
||||
return {
|
||||
...options.applicant,
|
||||
groupId,
|
||||
groupNo: groupCounts[groupId] ?? 0,
|
||||
win: 0,
|
||||
draw: 0,
|
||||
lose: 0,
|
||||
gl: 0,
|
||||
seedRank: 0,
|
||||
finalRank: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const selectWeighted = <T>(rng: ReturnType<typeof createTournamentRng>, pool: Array<{ item: T; weight: number }>): T =>
|
||||
rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight]));
|
||||
|
||||
|
||||
@@ -204,6 +204,20 @@ describe('tournament router permissions and mutations', () => {
|
||||
|
||||
expect(transport.gold.get(general.id)).toBe(1_800);
|
||||
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
|
||||
expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0);
|
||||
const snapshot = await caller.tournament.getSnapshot();
|
||||
expect(snapshot.participants).toHaveLength(1);
|
||||
expect(snapshot.participants[0]).toMatchObject({
|
||||
id: general.id,
|
||||
groupId: expect.any(Number),
|
||||
groupNo: 0,
|
||||
win: 0,
|
||||
draw: 0,
|
||||
lose: 0,
|
||||
gl: 0,
|
||||
});
|
||||
expect(snapshot.participants[0]!.groupId).toBeGreaterThanOrEqual(0);
|
||||
expect(snapshot.participants[0]!.groupId).toBeLessThan(8);
|
||||
});
|
||||
|
||||
it('serializes concurrent bets and enforces the legacy per-user 1000 limit', async () => {
|
||||
|
||||
@@ -12,7 +12,12 @@ import type {
|
||||
TournamentState,
|
||||
} from '../src/tournament/types.js';
|
||||
import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js';
|
||||
import { buildBettingPayouts, resolveBettingCloseAt, resolveNextAt } from '../src/tournament/workerHelpers.js';
|
||||
import {
|
||||
assignManualApplicantGroup,
|
||||
buildBettingPayouts,
|
||||
resolveBettingCloseAt,
|
||||
resolveNextAt,
|
||||
} from '../src/tournament/workerHelpers.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
|
||||
class MemoryRedis {
|
||||
@@ -226,6 +231,45 @@ const runTournamentToCompletion = async (options: {
|
||||
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe('tournament worker schedule compatibility', () => {
|
||||
it('수동 참가자를 즉시 남은 예선 조의 다음 슬롯에 배치한다', () => {
|
||||
const current = Array.from({ length: 63 }, (_, index): TournamentParticipantEntry => {
|
||||
const groupId = index < 47 ? index % 8 : (index + 1) % 8;
|
||||
const groupNo = Math.floor(index / 8);
|
||||
return {
|
||||
id: index + 1,
|
||||
name: `참가자${index + 1}`,
|
||||
leadership: 70,
|
||||
strength: 70,
|
||||
intel: 70,
|
||||
level: 10,
|
||||
groupId,
|
||||
groupNo,
|
||||
};
|
||||
});
|
||||
const groupCounts = Array.from({ length: 8 }, (_, groupId) =>
|
||||
current.filter((entry) => entry.groupId === groupId).length
|
||||
);
|
||||
const openGroupId = groupCounts.findIndex((count) => count === 7);
|
||||
expect(openGroupId).toBeGreaterThanOrEqual(0);
|
||||
expect(groupCounts.filter((count) => count === 7)).toHaveLength(1);
|
||||
|
||||
const applicant = assignManualApplicantGroup({
|
||||
state: createTournamentState(),
|
||||
baseSeed: 'manual-join-seed',
|
||||
current,
|
||||
applicant: {
|
||||
id: 100,
|
||||
name: '즉시배치',
|
||||
leadership: 80,
|
||||
strength: 81,
|
||||
intel: 82,
|
||||
level: 20,
|
||||
},
|
||||
});
|
||||
|
||||
expect(applicant).toMatchObject({ groupId: openGroupId, groupNo: 7, win: 0, draw: 0, lose: 0, gl: 0 });
|
||||
});
|
||||
|
||||
it('catches up from the stored schedule instead of discarding elapsed legacy phases', () => {
|
||||
const state = createTournamentState({
|
||||
termSeconds: 600,
|
||||
@@ -600,6 +644,14 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(participants.some((entry) => entry.id === 99)).toBe(false);
|
||||
expect(participants.some((entry) => entry.id === 1001)).toBe(true);
|
||||
expect(participants.some((entry) => entry.id < 0)).toBe(true);
|
||||
expect(participants.every((entry) => entry.groupId !== undefined && entry.groupNo !== undefined)).toBe(true);
|
||||
expect(participants.find((entry) => entry.id === 1)).toMatchObject({ groupId: expect.any(Number) });
|
||||
expect(participants.find((entry) => entry.id === 1001)).toMatchObject({ groupId: expect.any(Number) });
|
||||
expect(
|
||||
Array.from({ length: 8 }, (_, groupId) =>
|
||||
participants.filter((entry) => entry.groupId === groupId).length
|
||||
)
|
||||
).toEqual(Array.from({ length: 8 }, () => 8));
|
||||
|
||||
await store.setState(afterJoin);
|
||||
const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' });
|
||||
|
||||
Reference in New Issue
Block a user