Commit 1b55307c authored by nanahira's avatar nanahira

rework swiss

parent 5f832c5a
Pipeline #43364 failed with stages
in 2 minutes and 41 seconds
......@@ -12,6 +12,7 @@ lerna-debug.log*
# OS
.DS_Store
._*
# Tests
/coverage
......
......@@ -12,6 +12,7 @@ lerna-debug.log*
# OS
.DS_Store
._*
# Tests
/coverage
......@@ -35,4 +36,4 @@ lerna-debug.log*
/data
/output
/config.yaml
\ No newline at end of file
/config.yaml
......@@ -33,14 +33,28 @@ export class Swiss extends TournamentRuleBase {
}
if (r === 1) {
const participants = _.sortBy(
this.tournament.participants,
this.tournament.participants.filter((p) => !p.quit),
(p) => -p.seq,
(p) => -p.id,
);
for (const match of matches) {
if (participants.length % 2 === 1) {
// Lowest seed gets a bye in round 1.
participants.shift();
}
const seeded = participants.slice().reverse();
const half = Math.floor(seeded.length / 2);
const top = seeded.slice(0, half);
const bottom = seeded.slice(half);
for (let i = 0; i < matches.length; ++i) {
const match = matches[i];
match.status = MatchStatus.Running;
match.player1Id = participants.pop().id;
match.player2Id = participants.pop().id;
match.player1Id = top[i]?.id;
match.player2Id = bottom[i]?.id;
if (!match.player1Id || !match.player2Id) {
match.status = MatchStatus.Abandoned;
match.player1Id = null;
match.player2Id = null;
}
}
}
allMatches.push(...matches);
......@@ -50,44 +64,37 @@ export class Swiss extends TournamentRuleBase {
nextRound(): Partial<Match[]> {
this.tournament.calculateScore();
// score asc
const participants = this.tournament.participants
.filter((p) => !p.quit)
.reverse()
.map((p) => {
const opponentIds =
this.getOpponentMap().get(p.id) ?? new Map<number, number>();
return {
p,
opponentIds,
};
});
const nextRoundCount = this.nextRoundCount();
const matches = this.tournament.matches.filter(
(m) => m.round === nextRoundCount,
const matches = _.sortBy(
this.tournament.matches.filter((m) => m.round === nextRoundCount),
(m) => m.id,
);
for (const match of matches) {
match.status = MatchStatus.Running;
const player1 = participants.pop();
if (player1 && participants.length) {
match.player1Id = player1.p.id;
let player2Index = -1;
for (let i = 0; i < 10 && player2Index === -1; ++i) {
player2Index = participants.findLastIndex((p) => {
const metCount = player1.opponentIds.get(p.p.id);
return !metCount || metCount <= i;
});
}
if (player2Index === -1) {
// No suitable player found, so ignore condition of non-met
player2Index = participants.length - 1;
}
const player2 = participants.splice(player2Index, 1)[0];
if (player2) {
match.player2Id = player2.p.id;
}
}
if (!match.player1Id || !match.player2Id) {
const sortedParticipants = _.sortBy(
this.tournament.participants.filter((p) => !p.quit),
(p) => -(p.score?.score ?? 0),
(p) => -(p.score?.tieBreaker ?? 0),
(p) => -p.seq,
(p) => -p.id,
);
const active = sortedParticipants.map((p) => ({
p,
opponentIds: this.getOpponentMap().get(p.id) ?? new Map<number, number>(),
}));
const byePlayer = this.pickByePlayer(active);
const pool = byePlayer
? active.filter((entry) => entry.p.id !== byePlayer.p.id)
: active;
const pairs = this.pairSwiss(pool);
for (let i = 0; i < matches.length; ++i) {
const match = matches[i];
const pair = pairs[i];
if (pair) {
match.status = MatchStatus.Running;
match.player1Id = pair[0].p.id;
match.player2Id = pair[1].p.id;
} else {
match.status = MatchStatus.Abandoned;
match.player1Id = null;
match.player2Id = null;
......@@ -96,6 +103,225 @@ export class Swiss extends TournamentRuleBase {
return matches;
}
private pickByePlayer(
participants: Array<{
p: Participant;
opponentIds: Map<number, number>;
}>,
) {
if (participants.length % 2 === 0) return null;
const roundsMap = this.getParticipantRoundsMap();
const currentRounds = this.currentRoundCount();
const byeCount = (participantId: number) => {
let count = 0;
for (let i = 1; i <= currentRounds; ++i) {
const roundSet = roundsMap.get(i);
if (!roundSet?.has(participantId)) ++count;
}
return count;
};
// Prefer lowest-ranked players with the fewest byes.
const candidates = participants.slice().sort((a, b) => {
const byeDiff = byeCount(a.p.id) - byeCount(b.p.id);
if (byeDiff !== 0) return byeDiff;
const scoreDiff = (a.p.score.score ?? 0) - (b.p.score.score ?? 0);
if (scoreDiff !== 0) return scoreDiff;
const rankDiff = (b.p.score.rank ?? 0) - (a.p.score.rank ?? 0);
if (rankDiff !== 0) return rankDiff;
const seqDiff = (b.p.seq ?? 0) - (a.p.seq ?? 0);
if (seqDiff !== 0) return seqDiff;
return b.p.id - a.p.id;
});
return candidates[0] ?? null;
}
private pairSwiss(
participants: Array<{
p: Participant;
opponentIds: Map<number, number>;
}>,
): Array<
[
{ p: Participant; opponentIds: Map<number, number> },
{ p: Participant; opponentIds: Map<number, number> },
]
> {
if (!participants.length) return [];
const byScore = _.groupBy(participants, (entry) => entry.p.score.score ?? 0);
const scoreKeys = Object.keys(byScore)
.map(Number)
.sort((a, b) => b - a);
const pairs: Array<
[
{ p: Participant; opponentIds: Map<number, number> },
{ p: Participant; opponentIds: Map<number, number> },
]
> = [];
let floater:
| { p: Participant; opponentIds: Map<number, number> }
| null = null;
for (const score of scoreKeys) {
const bracket = _.sortBy(
(byScore[score] ?? []).concat(floater ? [floater] : []),
(entry) => entry.p.score.rank ?? Number.MAX_SAFE_INTEGER,
(entry) => -(entry.p.seq ?? 0),
(entry) => -(entry.p.id ?? 0),
);
floater = null;
let working = bracket.slice();
if (working.length % 2 === 1) {
// Try to float the lowest-ranked player that keeps the bracket pairable.
let floatIndex = working.length - 1;
for (let i = working.length - 1; i >= 0; --i) {
const candidate = working[i];
const rest = working.slice(0, i).concat(working.slice(i + 1));
const preview = this.pairEvenBracket(rest, true);
if (preview.length * 2 === rest.length) {
floatIndex = i;
break;
}
if ((candidate.p.score.rank ?? 0) > (working[floatIndex].p.score.rank ?? 0)) {
floatIndex = i;
}
}
floater = working.splice(floatIndex, 1)[0];
}
const bracketPairs = this.pairEvenBracket(working, false);
pairs.push(...bracketPairs);
}
if (floater) {
// Should rarely happen when player count is even after bye allocation.
// Leave the leftover as an implicit bye by not creating a match.
}
return pairs;
}
private pairEvenBracket(
players: Array<{
p: Participant;
opponentIds: Map<number, number>;
}>,
strictOnly: boolean,
): Array<
[
{ p: Participant; opponentIds: Map<number, number> },
{ p: Participant; opponentIds: Map<number, number> },
]
> {
if (!players.length) return [];
if (players.length % 2 === 1) return [];
const maxMet = _.max(
players.flatMap((a) =>
players.map((b) => (a.p.id === b.p.id ? 0 : this.metCount(a, b))),
),
);
const tolerances = strictOnly ? [0] : _.range(0, (maxMet ?? 0) + 1);
const half = players.length / 2;
const top = players.slice(0, half);
const bottom = players.slice(half);
for (const tolerance of tolerances) {
for (let offset = 0; offset < half; ++offset) {
const candidate: Array<
[
{ p: Participant; opponentIds: Map<number, number> },
{ p: Participant; opponentIds: Map<number, number> },
]
> = [];
let ok = true;
for (let i = 0; i < half; ++i) {
const a = top[i];
const b = bottom[(i + offset) % half];
if (this.metCount(a, b) > tolerance) {
ok = false;
break;
}
candidate.push([a, b]);
}
if (ok) return candidate;
}
}
// Fallback greedy pairing with minimum rematch count.
const remaining = players.slice();
const greedy: Array<
[
{ p: Participant; opponentIds: Map<number, number> },
{ p: Participant; opponentIds: Map<number, number> },
]
> = [];
while (remaining.length >= 2) {
const a = remaining.shift();
let bestIndex = 0;
for (let i = 1; i < remaining.length; ++i) {
const current = remaining[i];
const best = remaining[bestIndex];
const metDiff = this.metCount(a, current) - this.metCount(a, best);
if (metDiff < 0) {
bestIndex = i;
continue;
}
if (metDiff > 0) continue;
const rankDiffCurrent = Math.abs(
(a.p.score.rank ?? Number.MAX_SAFE_INTEGER) -
(current.p.score.rank ?? Number.MAX_SAFE_INTEGER),
);
const rankDiffBest = Math.abs(
(a.p.score.rank ?? Number.MAX_SAFE_INTEGER) -
(best.p.score.rank ?? Number.MAX_SAFE_INTEGER),
);
if (rankDiffCurrent < rankDiffBest) bestIndex = i;
}
const b = remaining.splice(bestIndex, 1)[0];
greedy.push([a, b]);
}
return greedy;
}
private metCount(
a: { p: Participant; opponentIds: Map<number, number> },
b: { p: Participant; opponentIds: Map<number, number> },
) {
return a.opponentIds.get(b.p.id) ?? 0;
}
private asNonNegativeInt(value: number, fallback = 0) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(0, Math.floor(parsed));
}
private bitsForMax(maxValue: number) {
const normalized = this.asNonNegativeInt(maxValue, 0);
return Math.max(1, Math.ceil(Math.log2(normalized + 1)));
}
private compressValueForBits(value: number, fullBits: number, useBits: number) {
if (useBits <= 0) return 0;
const normalized = this.asNonNegativeInt(value, 0);
if (useBits >= fullBits) return normalized;
return normalized >> (fullBits - useBits);
}
private pushBits(base: bigint, value: number, bits: number) {
if (bits <= 0) return base;
const b = BigInt(bits);
const mask = (1n << b) - 1n;
return (base << b) | (BigInt(value) & mask);
}
private participantRoundsMap: Map<number, Set<number>> | null = null;
private getParticipantRoundsMap(): Map<number, Set<number>> {
......@@ -175,11 +401,82 @@ export class Swiss extends TournamentRuleBase {
participantScoreAfter(participant: Participant): Partial<ParticipantScore> {
const opponentIds = this.getOpponentMap().get(participant.id) ?? new Map();
const opponents = Array.from(opponentIds.keys()).map((id) =>
this.participantMap.get(id),
const opponentScores: number[] = [];
for (const [opponentId, metCount] of opponentIds.entries()) {
const opponent = this.participantMap.get(opponentId);
if (!opponent) continue;
for (let i = 0; i < metCount; ++i) {
opponentScores.push(this.asNonNegativeInt(opponent.score?.score ?? 0));
}
}
const sorted = opponentScores.slice().sort((a, b) => a - b);
const buchholz = this.asNonNegativeInt(_.sum(sorted));
const medianBuchholz = this.asNonNegativeInt(
sorted.length >= 3 ? _.sum(sorted.slice(1, -1)) : buchholz,
);
const rounds = this.asNonNegativeInt(
this.settings.rounds ?? this.currentRoundCount(),
1,
);
const maxRoundScore = this.asNonNegativeInt(
Math.max(
this.settings.winScore ?? 0,
this.settings.drawScore ?? 0,
this.settings.byeScore ?? 0,
),
0,
);
// Worst-case opponent score sum upper bound across configured rounds.
const maxPlayerScore = rounds * maxRoundScore;
const maxBuchholz = rounds * maxPlayerScore;
const fullBuchholzBits = this.bitsForMax(maxBuchholz);
const winsBits = this.bitsForMax(rounds);
const drawsBits = this.bitsForMax(rounds);
let medianBits = fullBuchholzBits;
let buchholzBits = fullBuchholzBits;
let overflow = medianBits + buchholzBits + winsBits + drawsBits - 32;
if (overflow > 0) {
const reduceSecondary = Math.min(overflow, buchholzBits);
buchholzBits -= reduceSecondary;
overflow -= reduceSecondary;
}
if (overflow > 0) {
medianBits = Math.max(1, medianBits - overflow);
}
const packedMedian = this.compressValueForBits(
medianBuchholz,
fullBuchholzBits,
medianBits,
);
const packedBuchholz = this.compressValueForBits(
buchholz,
fullBuchholzBits,
buchholzBits,
);
const wins = Math.min(
this.asNonNegativeInt(participant.score?.win ?? 0),
rounds,
);
const draws = Math.min(
this.asNonNegativeInt(participant.score?.draw ?? 0),
rounds,
);
let tieBreaker = 0n;
tieBreaker = this.pushBits(tieBreaker, packedMedian, medianBits);
tieBreaker = this.pushBits(tieBreaker, packedBuchholz, buchholzBits);
tieBreaker = this.pushBits(tieBreaker, wins, winsBits);
tieBreaker = this.pushBits(tieBreaker, draws, drawsBits);
return {
tieBreaker: _.sumBy(opponents, (p) => p.score.score),
// u32 packed priority:
// median Buchholz > Buchholz > wins > draws.
tieBreaker: Number(tieBreaker & 0xffffffffn),
...(participant.quit ? { score: -1 } : {}),
};
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment