354 lines
14 KiB
JavaScript
354 lines
14 KiB
JavaScript
/* ---- Generate Matrix: duplicate the team's Google Sheets matchup matrix
|
|||
|
|
template, once per opposing team, pre-filled from this page's own roster
|
||
|
|
data plus cleaned army lists (via the vendored 40k-compactor). ----
|
||
|
|
|
||
|
|
Auth: Google Identity Services (GIS) token client — the viewer signs in
|
||
|
|
with their own Google account (a real OAuth popup), no backend, no
|
||
|
|
client secret. Scopes are drive.file (only files this app creates, not
|
||
|
|
full Drive access) and spreadsheets. */
|
||
|
|
|
||
|
|
import { detectFormat, parseGwAppV11, parseWarOrganV11, parseV11List, generateDiscordText, buildAbbreviationIndex } from "./vendor/40k-compactor/index.js";
|
||
|
|
import skippableWargear from "./vendor/40k-compactor/skippable_wargear.json";
|
||
|
|
|
||
|
|
const GOOGLE_CLIENT_ID = "698705216189-rqbg3f7vlosi3077c3n410a7lpmsn16r.apps.googleusercontent.com";
|
||
|
|
const TEMPLATE_FILE_ID = "1tSG1BxqLwmMtwFnaHozaLqVsKlmk_QsCXRorIxQBcMo";
|
||
|
|
const SCOPES = "https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/spreadsheets";
|
||
|
|
|
||
|
|
const DRIVE_API = "https://www.googleapis.com/drive/v3";
|
||
|
|
const SHEETS_API = "https://sheets.googleapis.com/v4/spreadsheets";
|
||
|
|
|
||
|
|
/* ---- List cleaning (vendored 40k-compactor — see vendor/40k-compactor/NOTICE.md) ---- */
|
||
|
|
|
||
|
|
// detectFormat() only distinguishes the 11th-edition dialects this parser
|
||
|
|
// set understands; NR_TOURNAMENT isn't separately parseable from the
|
||
|
|
// functions this tool exports (no dedicated parser in the vendored code),
|
||
|
|
// so it falls back to the generic V11 parser same as an unrecognized format.
|
||
|
|
const PARSERS = {
|
||
|
|
GW_APP_V11: parseGwAppV11,
|
||
|
|
WAR_ORGAN_V11: parseWarOrganV11,
|
||
|
|
V11_GENERIC: parseV11List,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Returns cleaned, sheet-friendly (plain, not Discord-markdown) list text,
|
||
|
|
// or null if the list couldn't be parsed — callers should fall back to the
|
||
|
|
// raw text rather than dropping the list entirely.
|
||
|
|
function cleanListText(rawText) {
|
||
|
|
if (!rawText) return null;
|
||
|
|
try {
|
||
|
|
const lines = String(rawText).split("\n");
|
||
|
|
const format = detectFormat(lines);
|
||
|
|
const parser = PARSERS[format] || parseV11List;
|
||
|
|
const parsed = parser(lines, skippableWargear);
|
||
|
|
if (!parsed) return null;
|
||
|
|
const abbrIndex = buildAbbreviationIndex(parsed);
|
||
|
|
return generateDiscordText(
|
||
|
|
parsed,
|
||
|
|
true, // plain text — this is going in a spreadsheet cell, not Discord
|
||
|
|
true, // use abbreviations
|
||
|
|
abbrIndex,
|
||
|
|
false, // hide subunits?
|
||
|
|
skippableWargear,
|
||
|
|
true, // combine identical units?
|
||
|
|
{ multilineHeader: true, colorMode: "none" }
|
||
|
|
);
|
||
|
|
} catch (err) {
|
||
|
|
console.warn("[matrix] list cleaning failed, falling back to raw text:", err);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Google auth ---- */
|
||
|
|
|
||
|
|
let tokenClient = null;
|
||
|
|
let cachedToken = null; // { access_token, expires_at }
|
||
|
|
|
||
|
|
function ensureTokenClient() {
|
||
|
|
if (tokenClient) return tokenClient;
|
||
|
|
if (!window.google?.accounts?.oauth2) {
|
||
|
|
throw new Error("Google Identity Services hasn't loaded yet — try again in a moment.");
|
||
|
|
}
|
||
|
|
tokenClient = window.google.accounts.oauth2.initTokenClient({
|
||
|
|
client_id: GOOGLE_CLIENT_ID,
|
||
|
|
scope: SCOPES,
|
||
|
|
callback: () => {}, // overridden per-request in getAccessToken()
|
||
|
|
});
|
||
|
|
return tokenClient;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getAccessToken() {
|
||
|
|
if (cachedToken && cachedToken.expires_at > Date.now() + 30_000) {
|
||
|
|
return Promise.resolve(cachedToken.access_token);
|
||
|
|
}
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const client = ensureTokenClient();
|
||
|
|
client.callback = resp => {
|
||
|
|
if (resp.error) { reject(new Error(`Google sign-in failed: ${resp.error}`)); return; }
|
||
|
|
cachedToken = { access_token: resp.access_token, expires_at: Date.now() + (resp.expires_in || 3600) * 1000 };
|
||
|
|
resolve(resp.access_token);
|
||
|
|
};
|
||
|
|
client.requestAccessToken({ prompt: cachedToken ? "" : "consent" });
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Google API helpers ---- */
|
||
|
|
|
||
|
|
async function googleFetch(url, token, options = {}) {
|
||
|
|
const r = await fetch(url, {
|
||
|
|
...options,
|
||
|
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...options.headers },
|
||
|
|
});
|
||
|
|
if (!r.ok) {
|
||
|
|
const body = await r.text().catch(() => "");
|
||
|
|
throw new Error(`Google API ${r.status}: ${body.slice(0, 300)}`);
|
||
|
|
}
|
||
|
|
return r.json();
|
||
|
|
}
|
||
|
|
|
||
|
|
const copyTemplate = (token, name) =>
|
||
|
|
googleFetch(`${DRIVE_API}/files/${TEMPLATE_FILE_ID}/copy?fields=id,webViewLink`, token, {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({ name }),
|
||
|
|
});
|
||
|
|
|
||
|
|
const getSpreadsheet = (token, fileId) =>
|
||
|
|
googleFetch(`${SHEETS_API}/${fileId}?includeGridData=true`, token);
|
||
|
|
|
||
|
|
const batchUpdate = (token, fileId, requests) =>
|
||
|
|
googleFetch(`${SHEETS_API}/${fileId}:batchUpdate`, token, {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({ requests }),
|
||
|
|
});
|
||
|
|
|
||
|
|
const batchUpdateValues = (token, fileId, data) =>
|
||
|
|
googleFetch(`${SHEETS_API}/${fileId}/values:batchUpdate`, token, {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({ valueInputOption: "USER_ENTERED", data }),
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---- Template layout ----
|
||
|
|
Deliberately not hardcoding A1 ranges: the implementation searches each
|
||
|
|
template tab for its own placeholder text ("Player N - Faction", the
|
||
|
|
"Opponent N" row) and works relative to what it finds, so this survives
|
||
|
|
the template being tweaked later (rows/columns inserted, etc.) without
|
||
|
|
needing a code change. See the plan file for how this was mapped out. */
|
||
|
|
|
||
|
|
const PLAYER_ROW_RE = /^Player (\d+) - Faction$/;
|
||
|
|
const OPPONENT_COL_RE = /^Opponent (\d+)$/;
|
||
|
|
|
||
|
|
// The template repeats "Player N - Faction" / "Opponent N" in more than one
|
||
|
|
// place — a primary input area, plus a "mobile matrix" mirror and a couple
|
||
|
|
// of reference/lookup sections the sheet's own instructions say to ignore
|
||
|
|
// ("Ignore the mobile matrix... I have no clue what the purpose is"). CSV
|
||
|
|
// export can't tell these apart (it only shows computed values), but the
|
||
|
|
// real API response can: only treat a cell as a genuine placeholder if it's
|
||
|
|
// literal typed text (userEnteredValue.stringValue), not a formula result
|
||
|
|
// (e.g. a mirror cell like `=B6`) that merely displays the same text.
|
||
|
|
function isLiteralText(cell, expected) {
|
||
|
|
return cell.userEnteredValue?.stringValue === expected && (cell.formattedValue || "") === expected;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Scans a tab's grid for the template's placeholder cells. Returns null if
|
||
|
|
// this isn't a matrix template tab (e.g. the instructions tab, or a tab a
|
||
|
|
// team has already filled in and renamed).
|
||
|
|
function locateTemplate(sheet) {
|
||
|
|
const rows = sheet.data?.[0]?.rowData || [];
|
||
|
|
|
||
|
|
// { col -> [{ row, size }] } — group by column so mirrored/reference
|
||
|
|
// occurrences (which land in different columns) don't get mixed together.
|
||
|
|
const playerHitsByCol = new Map();
|
||
|
|
const opponentHitsByRow = new Map();
|
||
|
|
|
||
|
|
rows.forEach((rowData, rIdx) => {
|
||
|
|
(rowData.values || []).forEach((cell, cIdx) => {
|
||
|
|
const text = cell.formattedValue || "";
|
||
|
|
const pm = text.match(PLAYER_ROW_RE);
|
||
|
|
if (pm && isLiteralText(cell, text)) {
|
||
|
|
if (!playerHitsByCol.has(cIdx)) playerHitsByCol.set(cIdx, []);
|
||
|
|
playerHitsByCol.get(cIdx).push({ row: rIdx, size: Number(pm[1]) });
|
||
|
|
}
|
||
|
|
const om = text.match(OPPONENT_COL_RE);
|
||
|
|
if (om && isLiteralText(cell, text)) {
|
||
|
|
if (!opponentHitsByRow.has(rIdx)) opponentHitsByRow.set(rIdx, []);
|
||
|
|
opponentHitsByRow.get(rIdx).push({ col: cIdx, size: Number(om[1]) });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// The real input area is the single column/row with the longest run of
|
||
|
|
// *consecutive* placeholder numbers (1, 2, 3, …) — mirrors elsewhere in
|
||
|
|
// the sheet may repeat the same set, but this is how we'd tell a genuine
|
||
|
|
// partial/edited copy apart from a stray one-off match either way.
|
||
|
|
const longestRun = hits => {
|
||
|
|
const sorted = [...hits].sort((a, b) => a.size - b.size);
|
||
|
|
let best = [], current = [];
|
||
|
|
for (const h of sorted) {
|
||
|
|
if (current.length && h.size !== current[current.length - 1].size + 1) { current = []; }
|
||
|
|
current.push(h);
|
||
|
|
if (current.length > best.length) best = current;
|
||
|
|
}
|
||
|
|
return best;
|
||
|
|
};
|
||
|
|
|
||
|
|
let playerCol = null, playerRows = [];
|
||
|
|
for (const [col, hits] of playerHitsByCol) {
|
||
|
|
const run = longestRun(hits);
|
||
|
|
if (run.length > playerRows.length) { playerCol = col; playerRows = run; }
|
||
|
|
}
|
||
|
|
|
||
|
|
let opponentRowIdx = null, opponentHits = [];
|
||
|
|
for (const [row, hits] of opponentHitsByRow) {
|
||
|
|
const run = longestRun(hits);
|
||
|
|
if (run.length > opponentHits.length) { opponentRowIdx = row; opponentHits = run; }
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!playerRows.length || !opponentHits.length) return null;
|
||
|
|
|
||
|
|
return {
|
||
|
|
sheetId: sheet.properties.sheetId,
|
||
|
|
title: sheet.properties.title,
|
||
|
|
teamSize: Math.max(...opponentHits.map(c => c.size)),
|
||
|
|
playerCol,
|
||
|
|
playerRows: playerRows.map(p => p.row).sort((a, b) => a - b),
|
||
|
|
opponentRow: opponentRowIdx, // "Name and Win Rate" row
|
||
|
|
factionRow: opponentRowIdx - 1, // one row up, per the template's own layout
|
||
|
|
listRow: opponentRowIdx - 2, // two rows up — "top of each appropriate column"
|
||
|
|
opponentCols: opponentHits.sort((a, b) => a.size - b.size).map(c => c.col),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Finds every matrix-template tab in the copied file (there's one per
|
||
|
|
// supported team size, e.g. 5-man and 8-man) and returns them keyed by size.
|
||
|
|
function findTemplates(spreadsheet) {
|
||
|
|
const templates = new Map(); // teamSize -> layout
|
||
|
|
for (const sheet of spreadsheet.sheets) {
|
||
|
|
const layout = locateTemplate(sheet);
|
||
|
|
if (layout) templates.set(layout.teamSize, layout);
|
||
|
|
}
|
||
|
|
return templates;
|
||
|
|
}
|
||
|
|
|
||
|
|
function colLetter(idx) {
|
||
|
|
let s = "", n = idx;
|
||
|
|
do { s = String.fromCharCode(65 + (n % 26)) + s; n = Math.floor(n / 26) - 1; } while (n >= 0);
|
||
|
|
return s;
|
||
|
|
}
|
||
|
|
|
||
|
|
const a1 = (sheetTitle, col, row) => `'${sheetTitle.replace(/'/g, "''")}'!${colLetter(col)}${row + 1}`;
|
||
|
|
|
||
|
|
/* ---- Row/column value builders ---- */
|
||
|
|
|
||
|
|
function playerLabel(member) {
|
||
|
|
const name = fullNameOf(member);
|
||
|
|
const faction = member.faction?.name || "Unknown";
|
||
|
|
return `${name} - ${faction}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// fullName/fmtWpct live in main.js today; passed in rather than duplicated.
|
||
|
|
let fullNameOf = () => "";
|
||
|
|
let fmtWpctOf = () => "—";
|
||
|
|
export function configureMatrixHelpers({ fullName, fmtWpct }) {
|
||
|
|
fullNameOf = fullName;
|
||
|
|
fmtWpctOf = fmtWpct;
|
||
|
|
}
|
||
|
|
|
||
|
|
function opponentNameAndWinRate(member, careerRecords) {
|
||
|
|
const name = fullNameOf(member);
|
||
|
|
const uid = member.userId || member.user?.id;
|
||
|
|
const rec = careerRecords.get(uid);
|
||
|
|
const wpct = rec ? fmtWpctOf(rec.w, rec.l, rec.t) : "—";
|
||
|
|
return `${name} (${wpct})`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Orchestration ---- */
|
||
|
|
|
||
|
|
// team: one entry from computeTeamRollups(). onProgress(msg) for UI status.
|
||
|
|
export async function generateMatrix({ event, ourTeam, opposingTeams, careerRecords, bcpToken, onProgress }) {
|
||
|
|
onProgress?.("Signing in to Google…");
|
||
|
|
const token = await getAccessToken();
|
||
|
|
|
||
|
|
onProgress?.("Copying the matrix template…");
|
||
|
|
const copy = await copyTemplate(token, `${event.name || "Event"} — Matrix`);
|
||
|
|
|
||
|
|
onProgress?.("Reading the template…");
|
||
|
|
const spreadsheet = await getSpreadsheet(token, copy.id);
|
||
|
|
const templates = findTemplates(spreadsheet);
|
||
|
|
if (!templates.size) {
|
||
|
|
throw new Error("Couldn't find a recognizable template tab (looking for 'Player 1 - Faction' / 'Opponent 1' placeholders) in the copied spreadsheet.");
|
||
|
|
}
|
||
|
|
|
||
|
|
const skipped = [];
|
||
|
|
let done = 0;
|
||
|
|
|
||
|
|
for (const oppTeam of opposingTeams) {
|
||
|
|
onProgress?.(`Building tab ${++done}/${opposingTeams.length}: ${oppTeam.name}…`);
|
||
|
|
|
||
|
|
// Nearest template size at or above this team's actual size (an
|
||
|
|
// undersized opponent just leaves trailing columns blank).
|
||
|
|
const sizes = [...templates.keys()].sort((a, b) => a - b);
|
||
|
|
const size = sizes.find(s => s >= Math.max(oppTeam.members.length, ourTeam.members.length)) || sizes[sizes.length - 1];
|
||
|
|
const template = templates.get(size);
|
||
|
|
|
||
|
|
let title;
|
||
|
|
try {
|
||
|
|
const dup = await batchUpdate(token, copy.id, [{
|
||
|
|
duplicateSheet: {
|
||
|
|
sourceSheetId: template.sheetId,
|
||
|
|
newSheetName: oppTeam.name.slice(0, 100),
|
||
|
|
},
|
||
|
|
}]);
|
||
|
|
title = dup.replies[0].duplicateSheet.properties.title;
|
||
|
|
} catch (err) {
|
||
|
|
skipped.push({ team: oppTeam.name, reason: `Couldn't duplicate tab: ${err.message}` });
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = [];
|
||
|
|
ourTeam.members.forEach((member, i) => {
|
||
|
|
const row = template.playerRows[i];
|
||
|
|
if (row == null) return; // template has fewer rows than our roster
|
||
|
|
data.push({ range: a1(title, template.playerCol, row), values: [[playerLabel(member)]] });
|
||
|
|
});
|
||
|
|
|
||
|
|
let listSkips = 0;
|
||
|
|
for (let i = 0; i < oppTeam.members.length; i++) {
|
||
|
|
const col = template.opponentCols[i];
|
||
|
|
if (col == null) break; // opposing team is larger than this template supports
|
||
|
|
const member = oppTeam.members[i];
|
||
|
|
data.push({ range: a1(title, col, template.opponentRow), values: [[opponentNameAndWinRate(member, careerRecords)]] });
|
||
|
|
data.push({ range: a1(title, col, template.factionRow), values: [[member.faction?.name || "Unknown"]] });
|
||
|
|
|
||
|
|
if (member.listId && bcpToken) {
|
||
|
|
try {
|
||
|
|
const list = await fetchArmyListText(member.listId, bcpToken);
|
||
|
|
const cleaned = cleanListText(list) || list;
|
||
|
|
data.push({ range: a1(title, col, template.listRow), values: [[cleaned]] });
|
||
|
|
} catch {
|
||
|
|
listSkips++;
|
||
|
|
}
|
||
|
|
} else if (member.listId) {
|
||
|
|
listSkips++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (listSkips) skipped.push({ team: oppTeam.name, reason: `${listSkips} list(s) not fetched — add a BCP auth token above to include them.` });
|
||
|
|
|
||
|
|
try {
|
||
|
|
await batchUpdateValues(token, copy.id, data);
|
||
|
|
} catch (err) {
|
||
|
|
skipped.push({ team: oppTeam.name, reason: `Tab created but couldn't write data: ${err.message}` });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { url: copy.webViewLink || `https://docs.google.com/spreadsheets/d/${copy.id}/edit`, skipped };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Same BCP endpoint/shape as fetchArmyList() in main.js (kept local rather
|
||
|
|
// than imported to keep this module's dependency surface self-contained).
|
||
|
|
async function fetchArmyListText(listId, token) {
|
||
|
|
const r = await fetch(`https://newprod-api.bestcoastpairings.com/v1/armylists/${listId}`, {
|
||
|
|
headers: { "client-id": "web-app", "env": "bcp", "accept": "application/json", authorization: `Bearer ${token}` },
|
||
|
|
});
|
||
|
|
if (!r.ok) throw new Error(`armylists/${r.status}`);
|
||
|
|
const list = await r.json();
|
||
|
|
return list.armyListText || "";
|
||
|
|
}
|