mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-30 15:53:59 -05:00
Support SE -> Underground bracket format
This commit is contained in:
parent
d0d846956c
commit
c2d3d2e221
|
|
@ -819,3 +819,77 @@ describe("double elimination standings - projected ties", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("single elimination source - underground", () => {
|
||||
// 8-team SE played out fully. The four first-round losers tie for last, so
|
||||
// sourcing [-1] should feed exactly those teams into an underground bracket.
|
||||
const playedSingleEliminationTournament = () => {
|
||||
const storage = new InMemoryDatabase();
|
||||
const manager = new BracketsManager(storage);
|
||||
|
||||
manager.create({
|
||||
name: "SE",
|
||||
tournamentId: 1,
|
||||
type: "single_elimination",
|
||||
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
settings: {},
|
||||
});
|
||||
|
||||
const winnersGroupId = Math.min(
|
||||
...storage.select<any>("group")!.map((g) => g.id),
|
||||
);
|
||||
const firstRoundId = Math.min(
|
||||
...storage
|
||||
.select<any>("round")!
|
||||
.filter((r) => r.group_id === winnersGroupId)
|
||||
.map((r) => r.id),
|
||||
);
|
||||
|
||||
// lower id wins, so the higher id in each first-round match is the loser
|
||||
const firstRoundLoserIds = readyMatches(
|
||||
storage,
|
||||
(m) => m.round_id === firstRoundId,
|
||||
).map((m) => Math.max(m.opponent1.id, m.opponent2.id));
|
||||
|
||||
let ready = readyMatches(storage, (m) => m.group_id === winnersGroupId);
|
||||
while (ready.length) {
|
||||
for (const match of ready) {
|
||||
reportLowerIdWinner(storage, manager, match.id);
|
||||
}
|
||||
ready = readyMatches(storage, (m) => m.group_id === winnersGroupId);
|
||||
}
|
||||
|
||||
const tournament = testTournament({
|
||||
ctx: {
|
||||
settings: {
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "SE",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
sources: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
data: manager.get.tournamentData(1),
|
||||
});
|
||||
|
||||
return { tournament, firstRoundLoserIds };
|
||||
};
|
||||
|
||||
it("sources the first-round losers when placements are [-1]", () => {
|
||||
const { tournament, firstRoundLoserIds } =
|
||||
playedSingleEliminationTournament();
|
||||
|
||||
const { teams, relevantMatchesFinished } = tournament
|
||||
.bracketByIdx(0)!
|
||||
.source({ placements: [-1] });
|
||||
|
||||
expect(relevantMatchesFinished).toBe(true);
|
||||
expect([...teams].sort((a, b) => a - b)).toEqual(
|
||||
[...firstRoundLoserIds].sort((a, b) => a - b),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -161,4 +161,61 @@ export class SingleEliminationBracket extends Bracket {
|
|||
|
||||
return this.standingsWithoutNonParticipants(resultWithThirdPlaceTiebroken);
|
||||
}
|
||||
|
||||
source({ placements }: { placements: number[] }) {
|
||||
invariant(placements.length > 0, "Empty placements not supported");
|
||||
invariant(
|
||||
placements.every((placement) => placement < 0),
|
||||
"Positive placements in SE not implemented",
|
||||
);
|
||||
|
||||
// third place match lives in a separate (higher) group; the winners
|
||||
// group teams get eliminated from is the lowest group id
|
||||
const mainGroupId = Math.min(...this.data.group.map((group) => group.id));
|
||||
|
||||
const orderedRoundsIds = this.data.round
|
||||
.filter((round) => round.group_id === mainGroupId)
|
||||
.map((round) => round.id)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const amountOfRounds = Math.abs(Math.min(...placements));
|
||||
|
||||
const sourceRoundsIds = orderedRoundsIds.slice(0, amountOfRounds).sort(
|
||||
// teams who made it further in the bracket get higher seed
|
||||
(a, b) => b - a,
|
||||
);
|
||||
|
||||
const teams: number[] = [];
|
||||
let relevantMatchesFinished = true;
|
||||
for (const roundId of sourceRoundsIds) {
|
||||
const roundsMatches = this.data.match.filter(
|
||||
(match) => match.round_id === roundId,
|
||||
);
|
||||
|
||||
for (const match of roundsMatches) {
|
||||
// BYE
|
||||
if (!match.opponent1 || !match.opponent2) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
match.opponent1?.result !== "win" &&
|
||||
match.opponent2?.result !== "win"
|
||||
) {
|
||||
relevantMatchesFinished = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const loser =
|
||||
match.opponent1?.result === "win" ? match.opponent2 : match.opponent1;
|
||||
invariant(loser?.id, "Loser id not found");
|
||||
|
||||
teams.push(loser.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
relevantMatchesFinished,
|
||||
teams,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -917,7 +917,7 @@ describe("validatedSources - other rules", () => {
|
|||
expect((error as any).bracketIdx).toEqual(1);
|
||||
});
|
||||
|
||||
it("handles NO_SE_SOURCE", () => {
|
||||
it("handles NO_SE_POSITIVE", () => {
|
||||
const error = getValidatedBrackets([
|
||||
{
|
||||
settings: {},
|
||||
|
|
@ -935,10 +935,31 @@ describe("validatedSources - other rules", () => {
|
|||
},
|
||||
]) as Progression.ValidationError;
|
||||
|
||||
expect(error.type).toBe("NO_SE_SOURCE");
|
||||
expect(error.type).toBe("NO_SE_POSITIVE");
|
||||
expect((error as any).bracketIdx).toEqual(1);
|
||||
});
|
||||
|
||||
it("allows single elimination to source an underground bracket", () => {
|
||||
const result = getValidatedBrackets([
|
||||
{
|
||||
settings: {},
|
||||
type: "single_elimination",
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
type: "single_elimination",
|
||||
sources: [
|
||||
{
|
||||
bracketId: "0",
|
||||
placements: "-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(Progression.isBrackets(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles NO_DE_POSITIVE", () => {
|
||||
const error = getValidatedBrackets([
|
||||
{
|
||||
|
|
@ -1278,6 +1299,21 @@ describe("isUnderground", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("handles SE w/ underground bracket", () => {
|
||||
expect(
|
||||
Progression.isUnderground(
|
||||
0,
|
||||
progressions.singleEliminationWithUnderground,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Progression.isUnderground(
|
||||
1,
|
||||
progressions.singleEliminationWithUnderground,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("throws if given idx is out of bounds", () => {
|
||||
expect(() =>
|
||||
Progression.isUnderground(1, progressions.singleElimination),
|
||||
|
|
@ -1348,6 +1384,14 @@ describe("bracketIdxsForStandings", () => {
|
|||
),
|
||||
).toEqual([0]); // missing 1 because it's underground when DE is the source
|
||||
});
|
||||
|
||||
it("handles SE w/ underground bracket", () => {
|
||||
expect(
|
||||
Progression.bracketIdxsForStandings(
|
||||
progressions.singleEliminationWithUnderground,
|
||||
),
|
||||
).toEqual([0]); // missing 1 because it's underground when SE is the source
|
||||
});
|
||||
});
|
||||
|
||||
describe("startingBrackets", () => {
|
||||
|
|
|
|||
|
|
@ -90,9 +90,9 @@ export type ValidationError =
|
|||
type: "NEGATIVE_PROGRESSION";
|
||||
bracketIdx: number;
|
||||
}
|
||||
// single elimination is not a valid source bracket (might change in the future)
|
||||
// no SE positive placements (single elimination can only source underground brackets)
|
||||
| {
|
||||
type: "NO_SE_SOURCE";
|
||||
type: "NO_SE_POSITIVE";
|
||||
bracketIdx: number;
|
||||
}
|
||||
// no DE positive placements (might change in the future)
|
||||
|
|
@ -287,10 +287,10 @@ export function bracketsToValidationError(
|
|||
};
|
||||
}
|
||||
|
||||
faultyBracketIdx = noSingleEliminationAsSource(brackets);
|
||||
faultyBracketIdx = noSingleEliminationPositive(brackets);
|
||||
if (typeof faultyBracketIdx === "number") {
|
||||
return {
|
||||
type: "NO_SE_SOURCE",
|
||||
type: "NO_SE_POSITIVE",
|
||||
bracketIdx: faultyBracketIdx,
|
||||
};
|
||||
}
|
||||
|
|
@ -671,11 +671,14 @@ function negativeProgression(brackets: ParsedBracket[]) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function noSingleEliminationAsSource(brackets: ParsedBracket[]) {
|
||||
function noSingleEliminationPositive(brackets: ParsedBracket[]) {
|
||||
for (const [bracketIdx, bracket] of brackets.entries()) {
|
||||
for (const source of bracket.sources ?? []) {
|
||||
const sourceBracket = brackets[source.bracketIdx];
|
||||
if (sourceBracket.type === "single_elimination") {
|
||||
if (
|
||||
sourceBracket.type === "single_elimination" &&
|
||||
source.placements.some((placement) => placement > 0)
|
||||
) {
|
||||
return bracketIdx;
|
||||
}
|
||||
}
|
||||
|
|
@ -944,7 +947,8 @@ export function bracketIdxsForStandings(progression: ParsedBracket[]) {
|
|||
|
||||
return !sources.some(
|
||||
(source) =>
|
||||
progression[source.bracketIdx].type === "double_elimination",
|
||||
progression[source.bracketIdx].type === "double_elimination" ||
|
||||
progression[source.bracketIdx].type === "single_elimination",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -256,4 +256,21 @@ export const progressions = {
|
|||
],
|
||||
},
|
||||
],
|
||||
singleEliminationWithUnderground: [
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "single_elimination",
|
||||
},
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "single_elimination",
|
||||
name: "Underground",
|
||||
sources: [
|
||||
{
|
||||
bracketIdx: 0,
|
||||
placements: [-1],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies Record<string, Progression.ParsedBracket[]>;
|
||||
|
|
|
|||
|
|
@ -180,6 +180,15 @@ export default function TournamentBracketsPage() {
|
|||
)} rounds of the losers bracket can play in this bracket`;
|
||||
}
|
||||
|
||||
if (
|
||||
tournament.brackets[0].type === "single_elimination" &&
|
||||
bracket.isUnderground
|
||||
) {
|
||||
return `Teams that get eliminated in the first ${Math.abs(
|
||||
Math.min(...(bracket.sources ?? []).flatMap((s) => s.placements)),
|
||||
)} rounds can play in this bracket`;
|
||||
}
|
||||
|
||||
const advanceThreshold = tournament.brackets[0].settings?.advanceThreshold;
|
||||
if (
|
||||
advanceThreshold &&
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "Duplicate bracket name",
|
||||
"progression.error.NAME_MISSING": "Bracket name missing",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "Negative progression only possible for double elimination",
|
||||
"progression.error.NO_SE_SOURCE": "Single elimination is not a valid source bracket",
|
||||
"progression.error.NO_SE_POSITIVE": "Single elimination is not valid for positive progression",
|
||||
"progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "Swiss bracket with early advance/elimination must lead to another bracket",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "A/B divisions can only be enabled on round robin brackets",
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "Nombre de cuadro duplicado",
|
||||
"progression.error.NAME_MISSING": "Falta el nombre del cuadro",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "La progresión negativa solo es posible en eliminación doble",
|
||||
"progression.error.NO_SE_SOURCE": "La eliminación simple no es un cuadro de origen válido",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "La eliminación doble no es válida para progresión positiva",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "El cuadro suizo con avance/eliminación anticipada debe llevar a otro cuadro",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "Duplicate bracket name",
|
||||
"progression.error.NAME_MISSING": "Bracket name missing",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "Negative progression only possible for double elimination",
|
||||
"progression.error.NO_SE_SOURCE": "Single elimination is not a valid source bracket",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "Nome bracket duplicato",
|
||||
"progression.error.NAME_MISSING": "Nome bracket mancante",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "La progressione negativa è disponibile solo in doppia eliminazione",
|
||||
"progression.error.NO_SE_SOURCE": "Eliminazione singola non è un bracket sorgente valido",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "Doppia eliminazione non è valida per progressione positiva",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "ブラケットの名前が重複しています",
|
||||
"progression.error.NAME_MISSING": "ブラケットの名前がありません",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "逆の進行はダブルエリ三ネーションの時のみ可能です",
|
||||
"progression.error.NO_SE_SOURCE": "シングルエリ三ネーションは妥当なブラケットではないです",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "ダブルエリミネーションは普通の進行(前向き)では妥当ではないです",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "Дубликат имени сетки",
|
||||
"progression.error.NAME_MISSING": "Имя сетки отсутствует",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "Отрицательная прогрессия возможна только в Double Elimination турнирах",
|
||||
"progression.error.NO_SE_SOURCE": "Single elimination не валидная сетка-исток",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "Double elimination не валидно для позитивной прогрессии",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@
|
|||
"progression.error.DUPLICATE_BRACKET_NAME": "对战表名称重复",
|
||||
"progression.error.NAME_MISSING": "缺少对战表名称",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "负序晋级仅在双败淘汰赛中可行",
|
||||
"progression.error.NO_SE_SOURCE": "单败淘汰赛不是有效的来源对战表",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "双败淘汰赛不适用于正序晋级",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "包含提前晋级/淘汰的瑞士轮对战表必须导向另一个对战表",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "A/B 分组只能在循环赛对战表中启用",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user