Add Generate Matrix: auto-build the team scouting matchup matrix

Duplicates the team's Google Sheets matchup-matrix template once per
opposing team at a loaded event, pre-filled with names, factions,
career win% (all already computed by this page), and cleaned army
lists. New "Which team is yours?" selector + Generate Matrix button,
team-events only.

- Auth: Google Identity Services token client (drive.file +
  spreadsheets scopes) — the viewer signs in with their own Google
  account via a real OAuth popup, no backend, no client secret.
- Template layout is located at runtime by searching each tab for its
  own placeholder text ("Player N - Faction", "Opponent N") rather
  than hardcoded A1 ranges, so it survives the template changing later.
  Distinguishes the real input area from the template's own "mobile
  matrix" mirror and reference sections (which repeat the same
  placeholder text) by checking for literal typed values vs formulas —
  CSV export can't tell these apart, only the real Sheets API response
  can, which is how this was actually verified before writing this.
  Auto-picks between the template's 5-man/8-man tab variants by team
  size.
- List cleaning via a vendored copy of desjani's 40k-compactor
  (src/vendor/40k-compactor/, see NOTICE.md) — it isn't actually
  published to npm despite documenting `npm install 40k-compactor`
  (verified: 404 against the registry under every plausible name), so
  it can't be a normal dependency; vendored instead, MIT per direct
  confirmation from the maintainer. Verified against 40k-compactor's
  own sample list fixtures with real Node before committing.
- Generating a matrix needs the same BCP auth token already used for
  faction/disposition backfill (list text is a subscriber-gated
  endpoint) — reuses the existing token UI/storage as-is; missing
  lists are reported, not a hard failure.
- nginx CSP updated (script/connect/frame-src) for Google's identity
  script and the Drive/Sheets REST APIs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBzcNkW7JcYipnAX6HfgmK
This commit is contained in:
2026-09-04 18:09:48 -05:00
co-authored by Claude Sonnet 5
parent 345f952970
commit 06b0cf4418
18 changed files with 6974 additions and 1 deletions
+353
View File
@@ -0,0 +1,353 @@
/* ---- 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 || "";
}