0; i--) { var j = Math.floor(Math.random() * (i + 1)); var t = a[i]; a[i] = a[j]; a[j] = t; }
return a;
}
/* Groups of 3–5 (about 4 each); the playoff takes 4, 8 or 16 languages, group winners first. */
function plan(n) {
var g = n <= 5 ? 1 : Math.round(n / 4);
var q = Math.max(4, Math.min(16, Math.pow(2, Math.floor(Math.log2(2 * g)))));
if (q > n) q = n;
var sizes = [];
for (var i = 0; i < g; i++) sizes.push(Math.floor(n / g) + (i < n % g ? 1 : 0));
var gm = sizes.reduce(function (t, s) { return t + s * (s - 1) / 2; }, 0);
var pm = 2 * q - 2; /* upper q-1, lower q-2, grand final 1 */
return { n: n, g: g, q: q, sizes: sizes, groupMatches: gm, playoffMatches: pm };
}
function describePlan(n) {
var p = plan(n), per = p.q / p.g, txt;
if (p.g === 1) txt = 'one group of ' + n + ' (everyone plays everyone), then the top ' + p.q + ' play a double-elimination bracket';
else {
var sz = p.sizes[0] === p.sizes[p.sizes.length - 1] ? p.sizes[0] : p.sizes[p.sizes.length - 1] + '–' + p.sizes[0];
txt = p.g + ' groups of ' + sz + ', then ';
if (per === Math.floor(per)) txt += 'the top ' + per + ' of each group (' + p.q + ' languages)';
else txt += 'the ' + p.g + ' group winners and the ' + (p.q - p.g * Math.floor(per)) + ' best ' + (Math.floor(per) === 1 ? 'runners-up' : 'third places') + ' (' + p.q + ' languages)';
txt += ' play a double-elimination bracket';
}
return txt + '. ' + (p.groupMatches + p.playoffMatches) + ' matches in total.';
}
function letter(i) { return String.fromCharCode(65 + i); }
/* Circle method: every pair once, spread over matchdays. */
function roundRobin(ids) {
var a = ids.slice(), days = [];
if (a.length % 2) a.push(null);
var n = a.length;
for (var r = 0; r < n - 1; r++) {
var day = [];
for (var i = 0; i < n / 2; i++) {
var x = a[i], y = a[n - 1 - i];
if (x && y) day.push(r % 2 ? [y, x] : [x, y]);
}
days.push(day);
a.splice(1, 0, a.pop());
}
return days;
}
function seedOrder(q) {
var o = [1];
while (o.length < q) {
var size = o.length * 2, next = [];
o.forEach(function (s) { next.push(s, size + 1 - s); });
o = next;
}
return o;
}
function W(m) { return { m: m, r: 'W' }; }
function L(m) { return { m: m, r: 'L' }; }
function newState(langs) {
var p = plan(langs.length), ids = shuffle(langs), groups = {}, gorder = [], matches = {}, order = 0, at = 0;
p.sizes.forEach(function (s, i) { var g = letter(i); gorder.push(g); groups[g] = ids.slice(at, at + s); at += s; });
var sched = gorder.map(function (g) { return roundRobin(groups[g]); });
var maxDays = Math.max.apply(null, sched.map(function (d) { return d.length; }));
for (var d = 0; d < maxDays; d++) {
gorder.forEach(function (g, gi) {
(sched[gi][d] || []).forEach(function (pair, k) {
var id = 'G' + g + (d + 1) + letter(k).toLowerCase();
matches[id] = { id: id, stage: 'group', group: g, short: 'Group ' + g, label: 'Group ' + g + ' · Matchday ' + (d + 1),
a: pair[0], b: pair[1], bo: 3, order: order++, games: [], flips: [], picks: [], done: false };
});
});
}
/* double elimination for q = 2^k */
var q = p.q, k = Math.log2(q), so = seedOrder(q), ub = [], lb = [];
function add(id, stage, short, label, a, b, bo) {
matches[id] = { id: id, stage: stage, short: short, label: label, a: a, b: b, bo: bo || 3, order: order++, games: [], flips: [], picks: [], done: false };
return id;
}
function ubName(r) { var left = k - r; return left === 0 ? 'Upper Final' : left === 1 ? 'Upper Semifinals' : left === 2 ? 'Upper Quarterfinals' : 'Upper Round ' + r; }
var lbRounds = 2 * (k - 1), lbNo = 0;
function lbName(r) { return r === lbRounds ? 'Lower Final' : r === lbRounds - 1 && lbRounds > 2 ? 'Lower Semifinal' : 'Lower Round ' + r; }
function lbShort(r) { return r === lbRounds ? 'LB Final' : 'LB R' + r; }
function ubShort(r) { var left = k - r; return left === 0 ? 'UB Final' : left === 1 ? 'UB SF' : left === 2 ? 'UB QF' : 'UB R' + r; }
function ubRound(r) {
var ids = [], cnt = q / Math.pow(2, r);
for (var i = 0; i < cnt; i++) {
var a, b;
if (r === 1) { a = { seed: so[2 * i] }; b = { seed: so[2 * i + 1] }; }
else { a = W(ub[r - 2][2 * i]); b = W(ub[r - 2][2 * i + 1]); }
var sh = ubShort(r) + (cnt > 1 ? ' ' + (i + 1) : '');
ids.push(add('U' + r + '-' + (i + 1), 'ub', sh, ubName(r) + (cnt > 1 ? ' · Match ' + (i + 1) : ''), a, b));
}
ub.push(ids);
}
function lbRound(pairs) {
lbNo++;
var ids = [];
pairs.forEach(function (pr, i) {
var sh = lbShort(lbNo) + (pairs.length > 1 ? ' ' + (i + 1) : '');
ids.push(add('L' + lbNo + '-' + (i + 1), 'lb', sh, lbName(lbNo) + (pairs.length > 1 ? ' · Match ' + (i + 1) : ''), pr[0], pr[1]));
});
lb.push(ids);
}
ubRound(1);
var first = ub[0], pairs = [];
for (var i = 0; i < first.length; i += 2) pairs.push([L(first[i]), L(first[i + 1])]);
lbRound(pairs);
for (var r = 2; r <= k; r++) {
ubRound(r);
var prev = lb[lb.length - 1], drop = ub[r - 1];
lbRound(prev.map(function (id, j) { return [W(id), L(drop[drop.length - 1 - j])]; }));
if (r < k) {
var cur = lb[lb.length - 1], ps = [];
for (var j = 0; j < cur.length; j += 2) ps.push([W(cur[j]), W(cur[j + 1])]);
lbRound(ps);
}
}
var ubF = ub[ub.length - 1][0], lbF = lb[lb.length - 1][0];
add('GF1', 'gf', 'Grand Final', 'Grand Final', W(ubF), W(lbF), 5);
return { v: VERSION, created: Date.now(), langs: langs.slice(), q: q, gorder: gorder, groups: groups, matches: matches,
ub: ub, lb: lb, ubNames: ub.map(function (x, i) { return ubName(i + 1); }), lbNames: lb.map(function (x, i) { return lbName(i + 1); }) };
}
/* ---------- state ---------- */
var S = null, HIST = [], SEEN = {}, KEY = '', HKEY = '', SKEY = '', PKEY = '';
/* Both pages (Arena and Plain) pass the same prefix, so they share one tournament. `legacy` is the page's
old prefix: its saved tournament moves over the first time the shared one is empty. */
function init(prefix, legacy) {
KEY = prefix + ':state:v2'; HKEY = prefix + ':history:v2'; SKEY = prefix + ':seen:v2'; PKEY = prefix + ':picked:v2';
if (legacy && !store.get(KEY) && store.get(legacy + ':state:v2')) {
['state', 'history', 'seen', 'picked'].forEach(function (k) {
var v = store.get(legacy + ':' + k + ':v2');
if (v != null) store.set(prefix + ':' + k + ':v2', v);
});
}
S = store.get(KEY);
if (!S || S.v !== VERSION || !S.matches) S = null;
if (S && S.matches.GF2) { delete S.matches.GF2; save(); } /* the grand final is a single match now */
HIST = store.get(HKEY) || [];
SEEN = store.get(SKEY) || {};
}
function save() { return store.set(KEY, S); }
function saveHist() { while (!store.set(HKEY, HIST) && HIST.length > 1) HIST = HIST.slice(Math.ceil(HIST.length / 2)); }
function saveSeen() { store.set(SKEY, SEEN); }
function lastPicked() { var p = store.get(PKEY); return Array.isArray(p) ? p.filter(function (id) { return LMAP[id]; }) : null; }
function start(langs) {
langs = langs.filter(function (id) { return LMAP[id]; });
if (langs.length < 4) return false;
store.set(PKEY, langs);
S = newState(langs); HIST = []; save(); saveHist();
report('Cup started', { size: String(langs.length) });
return true;
}
function clear() { S = null; HIST = []; store.del(KEY); saveHist(); }
/* ---------- rules ---------- */
function name(id) { return LMAP[id] ? LMAP[id].name : '?'; }
function values(o) { return Object.keys(o).map(function (k) { return o[k]; }); }
function byOrder(x, y) { return x.order - y.order; }
function groupMatches(g) { return values(S.matches).filter(function (m) { return m.stage === 'group' && m.group === g; }).sort(byOrder); }
function groupComplete(g) { return groupMatches(g).every(function (m) { return m.done; }); }
function groupsComplete() { return S.gorder.every(groupComplete); }
function score(m, a, b) {
var sa = 0, sb = 0;
m.picks.forEach(function (p) { if (p === a) sa++; else if (p === b) sb++; });
return [sa, sb];
}
/* Matches are first to a majority: best of 3 ends at 2 wins, the best-of-5 final at 3. */
function need(m) { return Math.floor(m.bo / 2) + 1; }
function finished(m) {
if (!m.picks.length) return false;
var p = participants(m), s = score(m, p[0], p[1]);
return Math.max(s[0], s[1]) >= need(m);
}
var stCache = {};
function standings(g) {
var ms = groupMatches(g), R = {};
var rows = S.groups[g].map(function (id) { return (R[id] = { id: id, mw: 0, ml: 0, gw: 0, gl: 0, p: 0 }); });
ms.forEach(function (m) {
if (!m.done) return;
var sc = score(m, m.a, m.b), A = R[m.a], B = R[m.b];
A.p++; B.p++; A.gw += sc[0]; A.gl += sc[1]; B.gw += sc[1]; B.gl += sc[0];
if (m.winner === m.a) { A.mw++; B.ml++; } else { B.mw++; A.ml++; }
});
function h2h(x, y) {
var m = ms.filter(function (m) { return m.done && ((m.a === x && m.b === y) || (m.a === y && m.b === x)); })[0];
return m ? (m.winner === x ? -1 : 1) : 0;
}
return rows.sort(function (x, y) {
return (y.mw - x.mw) || ((y.gw - y.gl) - (x.gw - x.gl)) || (y.gw - x.gw) || h2h(x.id, y.id) ||
(S.groups[g].indexOf(x.id) - S.groups[g].indexOf(y.id));
});
}
/* Everyone ranked across groups: by group place, then win rate, duel difference per match, duels won per match. */
function overall() {
var all = [];
S.gorder.forEach(function (g, gi) {
standings(g).forEach(function (r, i) {
var p = r.p || 1;
all.push({ id: r.id, group: g, place: i + 1, wr: r.mw / p, dr: (r.gw - r.gl) / p, gr: r.gw / p, gi: gi });
});
});
return all.sort(function (x, y) { return (x.place - y.place) || (y.wr - x.wr) || (y.dr - x.dr) || (y.gr - x.gr) || (x.gi - y.gi); });
}
function seeds() { return groupsComplete() ? overall().slice(0, S.q).map(function (r) { return r.id; }) : null; }
function resolve(src) {
if (typeof src === 'string') return src;
if (src.seed) { var s = seeds(); return s ? s[src.seed - 1] : null; }
var m = S.matches[src.m];
if (!m || !m.done) return null;
return src.r === 'W' ? m.winner : m.loser;
}
function participants(m) { return [resolve(m.a), resolve(m.b)]; }
function isActive(m) { return !!m; }
function isReady(m) { if (m.done || !isActive(m)) return false; var p = participants(m); return !!(p[0] && p[1]); }
function activeMatches() { return values(S.matches).filter(isActive).sort(byOrder); }
function nextMatch() {
var list = activeMatches().filter(isReady);
return list.filter(function (m) { return m.picks.length > 0; })[0] || list[0] || null;
}
function champion() {
var g1 = S.matches.GF1;
return g1.done ? { first: g1.winner, second: g1.loser } : null;
}
function podium() {
var ch = champion();
if (!ch) return null;
var lf = S.matches[S.lb[S.lb.length - 1][0]];
return { first: ch.first, second: ch.second, third: lf.done ? lf.loser : null };
}
function progress() {
var act = activeMatches();
return { done: act.filter(function (m) { return m.done; }).length, total: act.length,
phase: champion() ? 'Finished' : groupsComplete() ? 'Playoffs' : 'Group stage' };
}
/* Group row fate: 'q' qualified, 'out', or '' (not decided yet). */
function fate(g, place) {
if (!groupComplete(g)) return '';
var per = S.q / S.gorder.length;
if (place <= Math.floor(per)) return 'q';
if (place > Math.ceil(per)) return 'out';
if (!groupsComplete()) return '';
var id = standings(g)[place - 1].id;
return seeds().indexOf(id) >= 0 ? 'q' : 'out';
}
function srcLabel(src) {
if (src.seed) return 'Seed ' + src.seed;
return (src.r === 'W' ? 'W ' : 'L ') + S.matches[src.m].short;
}
function langStatus(id) {
var ch = champion();
if (ch && ch.first === id) return 'champ';
if (groupsComplete() && seeds().indexOf(id) < 0) return 'out';
var out = values(S.matches).some(function (m) {
if (!m.done || m.loser !== id) return false;
return m.stage === 'lb' || m.stage === 'gf';
});
return out ? 'out' : 'alive';
}
/* What winning or losing this match means, for banners and result lines. */
function stakes(m) {
if (m.stage === 'group') return { win: 'takes the points', lose: 'can still recover in the group' };
if (m.stage === 'ub') return { win: 'advances in the upper bracket', lose: 'drops to the lower bracket' };
if (m.stage === 'lb') return { win: 'survives', lose: 'is eliminated' };
return { win: 'wins the cup', lose: 'finishes second' };
}
function anyStarted() { return !!S && values(S.matches).some(function (m) { return m.picks.length > 0 || m.done; }); }
/* ---------- challenge choice: same task for both fighters, never one they already had ----------
First rule: within a cup, a language never gets the same task twice (when the pair has any task left
that neither of them had in this cup). Then, across cups, each language remembers its last 29 tasks in
order and the duel takes the task either fighter saw longest ago, so a returning task is always the
oldest one. */
var WINDOW = 29;
function seenList(l) { return SEEN[l] || (SEEN[l] = []); }
/* How many duels ago this language last had task c: 1 = its previous duel, Infinity = not within the window. */
function age(l, c) { var h = seenList(l), i = h.lastIndexOf(c); return i < 0 ? Infinity : h.length - i; }
/* Tasks this language already had in the current cup (kept in the saved tournament, so undo rewinds it). */
function cupUsed(l) { if (!S.used) S.used = {}; return S.used[l] || (S.used[l] = []); }
function chooseChallenge(m, a, b) {
var ua = cupUsed(a), ub = cupUsed(b);
var usedCats = m.games.map(function (id) { return CHMAP[id].cat; });
var others = S.langs.filter(function (l) { return l !== a && l !== b; }), best = null;
CH.forEach(function (c) {
if (m.games.indexOf(c.id) >= 0 || !has(c.id, a) || !has(c.id, b)) return;
var ea = age(a, c.id), eb = age(b, c.id), fresh = Math.min(ea, eb);
/* tasks the rest of the field already had keep everyone moving through the catalogue in step */
var done = others.length ? others.filter(function (l) { return age(l, c.id) !== Infinity; }).length / others.length : 0;
var cupFresh = (ua.indexOf(c.id) < 0 ? 1 : 0) + (ub.indexOf(c.id) < 0 ? 1 : 0);
var score = [cupFresh, fresh === Infinity ? 1e6 : fresh, Math.min(ea, 999) + Math.min(eb, 999), usedCats.indexOf(c.cat) < 0 ? 1 : 0, done + Math.random() * 0.5];
if (!best || cmp(score, best.score) > 0) best = { id: c.id, score: score };
});
if (!best) best = { id: CH.filter(function (c) { return m.games.indexOf(c.id) < 0; })[0].id };
[a, b].forEach(function (l) {
var h = seenList(l);
cupUsed(l).push(best.id);
h.push(best.id);
if (h.length > WINDOW) h.splice(0, h.length - WINDOW);
});
saveSeen();
return best.id;
}
function cmp(x, y) { for (var i = 0; i < x.length; i++) if (x[i] !== y[i]) return x[i] > y[i] ? 1 : -1; return 0; }
function seenTotal() { return Object.keys(SEEN).reduce(function (t, l) { return t + (LMAP[l] ? SEEN[l].length : 0); }, 0); }
function forgetSeen() { SEEN = {}; saveSeen(); }
/* ---------- actions ---------- */
/* Returns {ok:true, m, a, b} when the match can be played or viewed, else {ok:false, reason}. */
function prepare(id) {
var m = S.matches[id];
if (!m) return { ok: false, reason: 'No such match' };
var p = participants(m);
if (!p[0] || !p[1]) return { ok: false, reason: 'Waiting for earlier results' };
var n = m.picks.length;
if (!m.done && !finished(m) && !m.games[n]) {
m.games[n] = chooseChallenge(m, p[0], p[1]);
m.flips[n] = Math.random() < 0.5;
save();
}
return { ok: true, m: m, a: p[0], b: p[1] };
}
function pick(m, lang) {
if (m.done || finished(m)) return false;
m.picks.push(lang); save(); return true;
}
function undoPick(m) { if (m.done || !m.picks.length) return false; m.picks.pop(); save(); return true; }
/* Locks in a finished match. Returns what happened so the page can announce it. */
function confirmMatch(m) {
var p = participants(m), a = p[0], b = p[1], sc = score(m, a, b);
if (m.done || !finished(m)) return null;
HIST.push({ id: m.id, label: name(a) + ' vs ' + name(b), state: JSON.stringify(S) });
if (HIST.length > 30) HIST.shift();
saveHist();
m.done = true;
m.winner = sc[0] > sc[1] ? a : b;
m.loser = m.winner === a ? b : a;
save();
m.picks.forEach(function (w, i) {
var c = CHMAP[m.games[i]];
report('Duel', { winner: name(w), loser: name(w === a ? b : a), task: c.title, category: CATMAP[c.cat].name, stage: STAGE_NAME[m.stage] || m.stage });
});
var pd = podium();
if (pd) report('Cup finished', { champion: name(pd.first), runner_up: name(pd.second), third: pd.third ? name(pd.third) : '', size: String(S.langs.length) });
var hi = Math.max(sc[0], sc[1]), lo = Math.min(sc[0], sc[1]);
var text = name(m.winner) + ' beat ' + name(m.loser) + ' ' + hi + '–' + lo + '.';
if (m.stage === 'group' && groupsComplete()) {
text += ' The group stage is over: ' + S.q + ' languages go to the playoffs.';
} else if (m.stage === 'group' && groupComplete(m.group)) text += ' Group ' + m.group + ' is done.';
else if (m.stage === 'ub') text += ' ' + name(m.loser) + ' drops to the lower bracket.';
else if (m.stage === 'lb') text += ' ' + name(m.loser) + ' is eliminated.';
if (champion()) text = name(m.winner) + ' wins the Programming Language Cup!';
return text;
}
/* Anonymous results for the site's own Plausible stats: one Duel event per duel once its match is
confirmed, plus Cup started and Cup finished. Does nothing where Plausible isn't loaded. */
var STAGE_NAME = { group: 'Group', ub: 'Upper bracket', lb: 'Lower bracket', gf: 'Grand final' };
function report(event, props) {
try {
if (typeof window === 'undefined' || typeof window.plausible !== 'function') return;
props.mode = /\/plain\//.test(location.pathname) ? 'Plain' : '3D';
window.plausible(event, { props: props });
} catch (e) { /* stats must never break the game */ }
}
function undoResult() {
var h = HIST.pop();
if (!h) return null;
S = JSON.parse(h.state); save(); saveHist();
return h;
}
function stats() {
var st = {}, total = 0, cats = {};
S.langs.forEach(function (l) { st[l] = { id: l, w: 0, p: 0 }; });
CATS.forEach(function (c) { cats[c.id] = { id: c.id, n: 0, wins: {} }; });
values(S.matches).forEach(function (m) {
if (!isActive(m)) return;
var p = participants(m), a = p[0], b = p[1];
if (!a || !b) return;
m.picks.forEach(function (x, i) {
st[x].w++; st[a].p++; st[b].p++; total++;
var c = cats[CHMAP[m.games[i]].cat];
c.n++; c.wins[x] = (c.wins[x] || 0) + 1;
});
});
var rows = values(st).sort(function (x, y) {
var rx = x.p ? x.w / x.p : -1, ry = y.p ? y.w / y.p : -1;
return (ry - rx) || (y.w - x.w) || (name(x.id) < name(y.id) ? -1 : 1);
});
rows.forEach(function (r) { r.pct = r.p ? Math.round(100 * r.w / r.p) : null; r.status = langStatus(r.id); });
var crow = CATS.map(function (c) {
var w = cats[c.id].wins, top = Object.keys(w).sort(function (x, y) { return w[y] - w[x]; })[0];
return { id: c.id, n: cats[c.id].n, top: top || null, topWins: top ? w[top] : 0 };
});
return { total: total, rows: rows, cats: crow };
}
return {
LANGS: LANGS, LMAP: LMAP, POPULAR: POPULAR, CH: CH, CHMAP: CHMAP, CATS: CATS, CATMAP: CATMAP, STORAGE_OK: STORAGE_OK,
esc: esc, hl: hl, loadSnippets: loadSnippets, snippet: snippet, snippetCount: snippetCount, init: init, start: start, clear: clear,
lastPicked: lastPicked, plan: plan, describePlan: describePlan, shuffle: shuffle,
get S() { return S; }, get HIST() { return HIST; },
name: name, groupMatches: groupMatches, groupComplete: groupComplete, groupsComplete: groupsComplete, score: score,
standings: standings, fate: fate, resolve: resolve, participants: participants, isActive: isActive, isReady: isReady,
nextMatch: nextMatch, champion: champion, podium: podium, progress: progress, srcLabel: srcLabel, langStatus: langStatus,
need: need, finished: finished, prepare: prepare, pick: pick, undoPick: undoPick, confirmMatch: confirmMatch,
undoResult: undoResult, stats: stats, seenTotal: seenTotal, forgetSeen: forgetSeen, stakes: stakes, anyStarted: anyStarted, store: store
};
})();