/* ---- 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: drive.file (files this app creates — covers the generated matrix copies and everything written to them afterward) plus drive.readonly (needed for exactly one thing: reading the pre-existing template to copy it — drive.file deliberately can't see a file the app didn't create, which is why the first version of this only requesting drive.file 404'd on the template with no way to even see it existed — Drive's API returns 404, not 403, for a file outside the token's grant, so it looks identical to a bad file ID) 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/drive.readonly 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 || ""; }