Files
gateway-gamers.net/sites/scouting/src/matrix.js
T

360 lines
14 KiB
JavaScript
Raw Normal View History

/* ---- 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 || "";
}