brackt/app/services/simulations/snooker-simulator.ts
Claude c58cfb2d2f
Refactor snooker simulator to use DB world rankings, add snooker-elo admin page
- Remove hardcoded SEEDINGS_2026 name→seed map from snooker-simulator.ts
- Load worldRanking from participantExpectedValues (same column darts uses)
- Top 16 by worldRanking get seeded; fallback to Elo order if none stored
- Add /admin/sports-seasons/:id/snooker-elo route with bulk import (name, Elo,
  world ranking format) and per-player Elo + Rank # form, matching darts-elo UX
- Update snooker simulator tests: remove name-based ranking fixtures, add tests
  for DB-driven rankings and Elo fallback

https://claude.ai/code/session_01Fz54vdsLjDVintFguH32To
2026-04-01 21:27:15 +00:00

586 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Snooker World Championship Simulator
*
* Monte Carlo simulation of the World Snooker Championship (Crucible, Sheffield).
* The tournament is a 32-player single-elimination bracket with 5 rounds, using
* best-of frame formats that increase in length each round.
*
* Algorithm:
* 1. Load the bracket scoring event and all playoff matches from DB.
* 2. Load Elo ratings and world rankings from participantExpectedValues.
* 3. Compute per-match win probability using the Bernoulli frame model:
* p_frame = 1 / (1 + e^(-(Elo1 - Elo2) / ELO_DIVISOR))
* P(win match) = sum_{w2=0}^{F-1} C(F-1+w2, w2) * p^F * (1-p)^w2
* where F = frames to win, which varies by round.
* 4. Two simulation paths:
* a. Bracket populated: simulate from actual draw, respecting completed matches.
* b. No bracket: sort players by worldRanking (fallback: Elo order), place top 16
* as seeds, randomly draw the rest into seed slots 17-32 each simulation.
* 5. Track integer placement counts per tier across 50,000 simulations.
* 6. Convert to probability distributions using exact denominators (column sums = 1.0).
*
* Round format (Crucible main draw):
* First Round (R32): best-of-19, first to 10
* Second Round (R16): best-of-25, first to 13
* Quarter-Finals (QF): best-of-25, first to 13
* Semi-Finals (SF): best-of-33, first to 17
* Final: best-of-35, first to 18
*
* Placement bucketing (8-slot probability model):
* probFirst → Champion
* probSecond → Finalist
* probThird/Fourth → SF losers (2/sim)
* probFifthEighth → QF losers (4/sim)
* R16 losers → all 0
* R32 losers → all 0
*
* World rankings (for pre-bracket path): loaded from participantExpectedValues.worldRanking.
* Set via the snooker-elo admin page. Falls back to Elo order if no rankings are stored.
*/
import { database } from "~/database/context";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50000;
/**
* Controls how much Elo gaps affect per-frame win probability.
* Higher = softer probabilities (more randomness, less Elo dominance).
* Lower = sharper probabilities (Elo differences matter more).
*
* Standard chess Elo uses 400. Snooker frames are more random than chess
* games, so a higher value is appropriate. Tune this until tournament win
* probabilities for the top seed feel realistic (~15% for a clear favourite
* in a 32-player field).
*
* Reference calibration points at ELO_DIVISOR=700:
* 100-pt gap → 53.5% per frame
* 300-pt gap → 60.6% per frame
* At ELO_DIVISOR=400 (standard chess):
* 100-pt gap → 56.2% per frame
* 300-pt gap → 67.9% per frame
*/
const ELO_DIVISOR = 700;
/**
* Frames needed to win per round, indexed by round order (most matches first).
* Index 0 = R32 (16 matches, best-of-19, need 10)
* Index 1 = R16 (8 matches, best-of-25, need 13)
* Index 2 = QF (4 matches, best-of-25, need 13)
* Index 3 = SF (2 matches, best-of-33, need 17)
* Index 4 = Final (1 match, best-of-35, need 18)
*/
const FRAMES_TO_WIN = [10, 13, 13, 17, 18] as const;
/**
* Standard seeded R32 bracket for 32-player single-elimination.
* Each entry is [seedA, seedB] for one R32 match, in bracket order.
* Consecutive pairs of R32 winners form R16 matches (bracket structure preserved).
* Ensures seed 1 and seed 2 can only meet in the Final.
*
* Structure (seeds 1-16 are fixed; seeds 17-32 are randomly drawn qualifiers):
* Top half: 1v32, 16v17, 9v24, 8v25, 5v28, 12v21, 13v20, 4v29
* Bottom half: 3v30, 14v19, 11v22, 6v27, 7v26, 10v23, 15v18, 2v31
*/
const R32_BRACKET: Array<[number, number]> = [
[1, 32], [16, 17],
[9, 24], [8, 25],
[5, 28], [12, 21],
[13, 20], [4, 29],
[3, 30], [14, 19],
[11, 22], [6, 27],
[7, 26], [10, 23],
[15, 18], [2, 31],
];
// ─── Math helpers ──────────────────────────────────────────────────────────────
/**
* Per-frame win probability for player 1 vs player 2 based on their Elo ratings.
*
* Uses the logistic sigmoid: p = 1 / (1 + e^(-(R1 - R2) / ELO_DIVISOR))
*
* ELO_DIVISOR is set higher than standard chess (400) to reflect the greater
* randomness of snooker frames vs chess games. At ELO_DIVISOR=700:
* 100-pt gap → ~53.5% per frame
* 300-pt gap → ~60.6% per frame
*
* Exported for unit testing.
*/
export function frameWinProb(elo1: number, elo2: number): number {
return 1 / (1 + Math.exp(-(elo1 - elo2) / ELO_DIVISOR));
}
/**
* Match win probability for player 1 using the Bernoulli frame model.
*
* For a best-of-(2F-1) match (first to F frames wins):
* P(win) = sum_{w2=0}^{F-1} C(F-1+w2, w2) * p^F * (1-p)^w2
*
* where p = per-frame win probability and F = framesToWin.
* This sums over all winning score combinations (F-0, F-1, ..., F-(F-1)).
* Exported for unit testing.
*/
export function matchWinProb(p: number, framesToWin: number): number {
const F = framesToWin;
let prob = 0;
for (let w2 = 0; w2 < F; w2++) {
prob += binomialCoeff(F - 1 + w2, w2) * Math.pow(p, F) * Math.pow(1 - p, w2);
}
return prob;
}
/** Binomial coefficient C(n, k) using iterative multiplication to avoid overflow. */
function binomialCoeff(n: number, k: number): number {
if (k === 0) return 1;
if (k > n - k) k = n - k; // Symmetry: C(n,k) = C(n, n-k)
let result = 1;
for (let i = 0; i < k; i++) {
result = (result * (n - i)) / (i + 1);
}
return result;
}
/** Fisher-Yates shuffle (in-place). */
function shuffle<T>(arr: T[]): T[] {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class SnookerSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Find the bracket scoring event for this sports season.
const bracketEvent = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.eventType, "playoff_game")
),
});
// 2. Load playoff matches (may be empty if bracket hasn't been drawn yet).
const allMatches = bracketEvent
? await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
orderBy: (m, { asc }) => [asc(m.matchNumber)],
})
: [];
// 3. Load Elo ratings and world rankings from participantExpectedValues.
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceElo: schema.participantExpectedValues.sourceElo,
worldRanking: schema.participantExpectedValues.worldRanking,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
// Build Elo and ranking maps; fall back to 1500 for any participant with no stored rating.
const eloMap = new Map<string, number>();
const rankingMap = new Map<string, number>();
for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined) {
eloMap.set(r.participantId, r.sourceElo);
} else if (!eloMap.has(r.participantId)) {
eloMap.set(r.participantId, 1500);
}
if (r.worldRanking !== null && r.worldRanking !== undefined) {
rankingMap.set(r.participantId, r.worldRanking);
}
}
// Determine which simulation path to take.
const bracketPopulated =
allMatches.some((m) => m.participant1Id && m.participant2Id);
if (bracketPopulated) {
return this.simulateBracket(allMatches, eloMap);
} else {
return this.simulatePreBracket(sportsSeasonId, eloMap, rankingMap, db);
}
}
// ─── Path A: Full bracket simulation ────────────────────────────────────────
private async simulateBracket(
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
eloMap: Map<string, number>
): Promise<SimulationResult[]> {
// Group matches by round, sorted by match count descending (R32 first).
const byRound = new Map<string, typeof allMatches>();
for (const m of allMatches) {
if (!byRound.has(m.round)) byRound.set(m.round, []);
byRound.get(m.round)?.push(m);
}
const sortedRounds = [...byRound.values()]
.toSorted((a, b) => b.length - a.length)
.map((matches) => matches.sort((a, b) => a.matchNumber - b.matchNumber));
if (sortedRounds.length !== 5) {
throw new Error(
`Expected 5 rounds for World Snooker Championship, found ${sortedRounds.length}. ` +
`Rounds: ${[...byRound.keys()].join(", ")}`
);
}
const [r32Matches, r16Matches, qfMatches, sfMatches, finalMatches] = sortedRounds;
if (r32Matches.length !== 16) {
throw new Error(
`Expected 16 First Round matches, found ${r32Matches.length}.`
);
}
// Collect all 32 participant IDs from R32.
const participantIds: string[] = [];
for (const m of r32Matches) {
if (!m.participant1Id || !m.participant2Id) {
throw new Error(
`First Round match ${m.matchNumber} is missing participants. ` +
`Assign all 32 players to the bracket before running simulation.`
);
}
participantIds.push(m.participant1Id, m.participant2Id);
}
// Build lookup maps by matchNumber for O(1) access in the hot loop.
const r32ByNum = new Map(r32Matches.map((m) => [m.matchNumber, m]));
const r16ByNum = new Map(r16Matches.map((m) => [m.matchNumber, m]));
const qfByNum = new Map(qfMatches.map((m) => [m.matchNumber, m]));
const sfByNum = new Map(sfMatches.map((m) => [m.matchNumber, m]));
const finalMatch = finalMatches[0];
const fallbackElo = 1500;
// Cache matchWinProb results — the Elo values and framesToWin are fixed across
// all simulations, so the same (elo1, elo2, framesToWin) triple always yields
// the same probability. Avoids recomputing the Bernoulli sum ~2M times per run.
const matchProbCache = new Map<string, number>();
const simMatch = (p1: string, p2: string, framesToWin: number): { winner: string; loser: string } => {
const elo1 = eloMap.get(p1) ?? fallbackElo;
const elo2 = eloMap.get(p2) ?? fallbackElo;
const cacheKey = `${elo1},${elo2},${framesToWin}`;
let winProb = matchProbCache.get(cacheKey);
if (winProb === undefined) {
winProb = matchWinProb(frameWinProb(elo1, elo2), framesToWin);
matchProbCache.set(cacheKey, winProb);
}
const winner = Math.random() < winProb ? p1 : p2;
return { winner, loser: winner === p1 ? p2 : p1 };
};
// Integer placement counts.
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// ── First Round (R32) ──────────────────────────────────────────────────
const r32Winners: string[] = [];
for (let i = 1; i <= 16; i++) {
const m = r32ByNum.get(i);
if (!m) continue;
if (m.isComplete && m.winnerId) {
r32Winners.push(m.winnerId);
} else {
const { winner } = simMatch(m.participant1Id ?? "", m.participant2Id ?? "", FRAMES_TO_WIN[0]);
r32Winners.push(winner);
}
}
// ── Second Round (R16) ────────────────────────────────────────────────
// R16 losers score 0 — only the winner is tracked.
const r16Winners: string[] = [];
for (let i = 1; i <= 8; i++) {
const dbMatch = r16ByNum.get(i);
let winner: string;
if (dbMatch?.isComplete && dbMatch.winnerId) {
winner = dbMatch.winnerId;
} else {
const p1 = r32Winners[(i - 1) * 2];
const p2 = r32Winners[(i - 1) * 2 + 1];
({ winner } = simMatch(p1, p2, FRAMES_TO_WIN[1]));
}
r16Winners.push(winner);
}
// ── Quarter-Finals ────────────────────────────────────────────────────
const qfWinners: string[] = [];
for (let i = 1; i <= 4; i++) {
const dbMatch = qfByNum.get(i);
let winner: string;
let loser: string;
if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) {
winner = dbMatch.winnerId;
loser = dbMatch.loserId;
} else {
const p1 = r16Winners[(i - 1) * 2];
const p2 = r16Winners[(i - 1) * 2 + 1];
({ winner, loser } = simMatch(p1, p2, FRAMES_TO_WIN[2]));
}
qfWinners.push(winner);
qfLoserCounts.set(loser, (qfLoserCounts.get(loser) ?? 0) + 1);
}
// ── Semi-Finals ───────────────────────────────────────────────────────
const sfWinners: string[] = [];
for (let i = 1; i <= 2; i++) {
const dbMatch = sfByNum.get(i);
let winner: string;
let loser: string;
if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) {
winner = dbMatch.winnerId;
loser = dbMatch.loserId;
} else {
const p1 = qfWinners[(i - 1) * 2];
const p2 = qfWinners[(i - 1) * 2 + 1];
({ winner, loser } = simMatch(p1, p2, FRAMES_TO_WIN[3]));
}
sfWinners.push(winner);
sfLoserCounts.set(loser, (sfLoserCounts.get(loser) ?? 0) + 1);
}
// ── Final ─────────────────────────────────────────────────────────────
let champion: string;
let finalist: string;
if (finalMatch?.isComplete && finalMatch.winnerId && finalMatch.loserId) {
champion = finalMatch.winnerId;
finalist = finalMatch.loserId;
} else {
({ winner: champion, loser: finalist } = simMatch(sfWinners[0], sfWinners[1], FRAMES_TO_WIN[4]));
}
championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1);
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(participantIds, NUM_SIMULATIONS, {
championCounts,
finalistCounts,
sfLoserCounts,
qfLoserCounts,
});
}
// ─── Path B: Pre-bracket simulation ─────────────────────────────────────────
// Players beyond the top 16 seeds haven't been drawn yet. Each simulation:
// 1. Randomly pairs qualifiers (seeds 17+) and simulates best-of-19 matches if >16.
// 2. The 16 remaining qualifiers are shuffled and drawn into seed slots 17-32.
// 3. The full 32-player bracket is then simulated using the proper seeded structure.
//
// Seeding is determined by worldRanking from participantExpectedValues (ascending).
// Falls back to Elo order (descending) for players with no ranking stored.
private async simulatePreBracket(
sportsSeasonId: string,
eloMap: Map<string, number>,
rankingMap: Map<string, number>,
db: ReturnType<typeof database>
): Promise<SimulationResult[]> {
const allParticipants = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
if (allParticipants.length < 17) {
throw new Error(
`Pre-bracket simulation requires at least 17 participants (got ${allParticipants.length}). ` +
`Add players to this sports season first.`
);
}
// Sort by world ranking (ascending). Unranked players fall back to Elo order (descending).
const fallbackElo = 1500;
const ranked = allParticipants.toSorted((a, b) => {
const rankA = rankingMap.get(a.id);
const rankB = rankingMap.get(b.id);
if (rankA !== undefined && rankB !== undefined) return rankA - rankB;
if (rankA !== undefined) return -1;
if (rankB !== undefined) return 1;
return (eloMap.get(b.id) ?? fallbackElo) - (eloMap.get(a.id) ?? fallbackElo);
});
const topSeeds = ranked.slice(0, 16); // Fixed seed positions 1-16
const qualifiers = ranked.slice(16); // Randomly drawn into positions 17-32
const matchProbCache = new Map<string, number>();
const simMatch = (p1Id: string, p2Id: string, framesToWin: number): string => {
const elo1 = eloMap.get(p1Id) ?? fallbackElo;
const elo2 = eloMap.get(p2Id) ?? fallbackElo;
const cacheKey = `${elo1},${elo2},${framesToWin}`;
let winProb = matchProbCache.get(cacheKey);
if (winProb === undefined) {
winProb = matchWinProb(frameWinProb(elo1, elo2), framesToWin);
matchProbCache.set(cacheKey, winProb);
}
return Math.random() < winProb ? p1Id : p2Id;
};
// Pre-extract qualifier IDs once — reused (with a fresh shuffle) each iteration.
const qualifierIds = qualifiers.map((p) => p.id);
const allParticipantIds = allParticipants.map((p) => p.id);
const championCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const sfLoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const qfLoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
// Whether qualifying needs to be simulated (>16 players competing for 16 bracket slots).
// If exactly 16 qualifiers are already loaded (the common case when only 32 total players
// are in the DB), skip the qualifying simulation and use them directly as seeds 17-32.
const needsQualifying = qualifiers.length > 16;
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// ── Qualifying: run elimination rounds until exactly 16 qualifiers remain ──
let drawnQualifiers: string[];
if (!needsQualifying) {
// All 16 qualifiers go straight through — just shuffle the draw order.
drawnQualifiers = shuffle([...qualifierIds]);
} else {
// Multiple qualifying rounds (best-of-19 each) to whittle down to 16.
let pool = shuffle([...qualifierIds]);
while (pool.length > 16) {
const winners: string[] = [];
for (let i = 0; i + 1 < pool.length; i += 2) {
winners.push(simMatch(pool[i], pool[i + 1], FRAMES_TO_WIN[0]));
}
// Odd player out gets a bye.
if (pool.length % 2 !== 0) {
winners.push(pool[pool.length - 1]);
}
pool = shuffle(winners);
}
drawnQualifiers = pool;
}
// Seeds 5-16: randomly swap adjacent pairs to reflect uncertainty in
// mid-tier seedings (top 4 are locked in).
const midSeeds = topSeeds.slice(4).map((p) => p.id);
for (let i = 0; i < midSeeds.length - 1; i++) {
if (Math.random() < 0.5) {
[midSeeds[i], midSeeds[i + 1]] = [midSeeds[i + 1], midSeeds[i]];
}
}
// Map seed number (1-indexed) → participant ID.
const seedToId = new Map<number, string>();
topSeeds.slice(0, 4).forEach((p, i) => seedToId.set(i + 1, p.id));
midSeeds.forEach((id, i) => seedToId.set(i + 5, id));
drawnQualifiers.forEach((id, i) => seedToId.set(i + 17, id));
// ── First Round (R32) — proper seeded bracket ─────────────────────────
// R32_BRACKET defines which seeds meet in each match, in bracket order so
// consecutive R32 winner pairs form the correct R16 matchups.
const r32Winners: string[] = [];
for (const [s1, s2] of R32_BRACKET) {
const p1 = seedToId.get(s1) ?? "";
const p2 = seedToId.get(s2) ?? "";
r32Winners.push(simMatch(p1, p2, FRAMES_TO_WIN[0]));
}
// ── Second Round (R16) ────────────────────────────────────────────────
const r16Winners: string[] = [];
for (let i = 0; i < r32Winners.length; i += 2) {
r16Winners.push(simMatch(r32Winners[i], r32Winners[i + 1], FRAMES_TO_WIN[1]));
}
// ── Quarter-Finals ────────────────────────────────────────────────────
const qfWinners: string[] = [];
for (let i = 0; i < r16Winners.length; i += 2) {
const p1 = r16Winners[i], p2 = r16Winners[i + 1];
const winner = simMatch(p1, p2, FRAMES_TO_WIN[2]);
qfWinners.push(winner);
qfLoserCounts.set(winner === p1 ? p2 : p1, (qfLoserCounts.get(winner === p1 ? p2 : p1) ?? 0) + 1);
}
// ── Semi-Finals ───────────────────────────────────────────────────────
const sfWinners: string[] = [];
for (let i = 0; i < qfWinners.length; i += 2) {
const p1 = qfWinners[i], p2 = qfWinners[i + 1];
const winner = simMatch(p1, p2, FRAMES_TO_WIN[3]);
sfWinners.push(winner);
sfLoserCounts.set(winner === p1 ? p2 : p1, (sfLoserCounts.get(winner === p1 ? p2 : p1) ?? 0) + 1);
}
// ── Final ─────────────────────────────────────────────────────────────
const champion = simMatch(sfWinners[0], sfWinners[1], FRAMES_TO_WIN[4]);
const finalist = champion === sfWinners[0] ? sfWinners[1] : sfWinners[0];
championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1);
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
}
return buildResults(allParticipantIds, NUM_SIMULATIONS, {
championCounts,
finalistCounts,
sfLoserCounts,
qfLoserCounts,
});
}
}
// ─── Shared result builder ─────────────────────────────────────────────────────
function buildResults(
participantIds: string[],
N: number,
counts: {
championCounts: Map<string, number>;
finalistCounts: Map<string, number>;
sfLoserCounts: Map<string, number>;
qfLoserCounts: Map<string, number>;
}
): SimulationResult[] {
const { championCounts, finalistCounts, sfLoserCounts, qfLoserCounts } = counts;
const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0;
const sf = sfLoserCounts.get(participantId) ?? 0;
const qf = qfLoserCounts.get(participantId) ?? 0;
return {
participantId,
probabilities: {
probFirst: c / N,
probSecond: f / N,
probThird: sf / (2 * N),
probFourth: sf / (2 * N),
probFifth: qf / (4 * N),
probSixth: qf / (4 * N),
probSeventh: qf / (4 * N),
probEighth: qf / (4 * N),
},
source: "snooker_world_championship_monte_carlo",
};
});
// Per-position column normalization — ensures sums are exactly 1.0.
const positionKeys: Array<keyof typeof results[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
];
for (const key of positionKeys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
const residual = 1.0 - colSum;
if (residual !== 0) {
const maxResult = results.reduce((best, r) =>
r.probabilities[key] > best.probabilities[key] ? r : best
);
maxResult.probabilities[key] += residual;
}
}
return results;
}