/* global module */ /* Copy this entire file into an Apps Script file named ClientCore. * It has no network, spreadsheet, credential, or automatic publication side effects. */ var RivoryModelConnect = (function () { 'use strict' var MAX_ROWS = 100 var MAX_BYTES = 256 * 1024 var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i var KEY = /^[A-Za-z0-9._:~-]{1,100}$/ var HEADERS = [ 'fixture_id', 'pick_format', 'picked_participant_id', 'expected_pick_revision', 'expected_line_revision', 'source_line_home', 'home_score', 'away_score', 'home_name', 'away_name', 'home_participant_id', 'away_participant_id', 'starts_at', 'line_home', 'round_label', ] function check(value, message) { if (!value) throw new Error(message) } function byteLength(value) { // Match UTF-8 encoding, including replacement bytes for lone UTF-16 surrogates. // This works in Node and Apps Script without a TextEncoder dependency. var bytes = 0 for (var i = 0; i < value.length; i++) { var c = value.charCodeAt(i) if (c <= 0x7f) bytes++ else if (c <= 0x7ff) bytes += 2 else if ( c >= 0xd800 && c <= 0xdbff && value.charCodeAt(i + 1) >= 0xdc00 && value.charCodeAt(i + 1) <= 0xdfff ) { bytes += 4 i++ } else bytes += 3 } return bytes } function apiBase(value) { check( typeof value === 'string' && /^https:\/\/[A-Za-z0-9.-]+(?::443)?\/functions\/v1\/model-connect\/v1\/?$/.test(value), 'Use the HTTPS versioned API base copied from Rivory.' ) return value.replace(/\/$/, '') } function credential(value) { check(/^rv_model_[0-9a-f]{64}$/.test(value || ''), 'Set a valid scoped Model credential.') return value } function safeCell(value) { var s = value == null ? '' : String(value) return /^[\s]*[=+@-]/.test(s) && !/^-?\d+(?:\.\d+)?$/.test(s.trim()) ? "'" + s : s } function parseCsv(text) { check(byteLength(text) <= MAX_BYTES, 'CSV exceeds 256 KiB.') var rows = [], row = [], cell = '', quoted = false, closed = false text = text.replace(/^\uFEFF/, '') for (var i = 0; i < text.length; i++) { var c = text[i] if (quoted) { if (c === '"' && text[i + 1] === '"') { cell += '"' i++ } else if (c === '"') { quoted = false closed = true } else cell += c } else if (c === '"') { check(cell === '' && !closed, 'Malformed quoted CSV.') quoted = true } else if (c === ',' || c === '\n' || c === '\r') { row.push(cell) cell = '' closed = false if (c !== ',') { rows.push(row) row = [] if (c === '\r' && text[i + 1] === '\n') i++ } } else { check(!closed, 'Unexpected text after a quoted CSV cell.') cell += c } } check(!quoted, 'Unclosed CSV quote.') if (cell !== '' || row.length) { row.push(cell) rows.push(row) } return rows } function toCsv(rows) { return ( rows .map(function (row) { return row .map(function (v) { return '"' + safeCell(v).replace(/"/g, '""') + '"' }) .join(',') }) .join('\r\n') + '\r\n' ) } function availableRounds(fixtures) { var counts = new Map() fixtures.forEach(function (f) { if (f.locked) return check( typeof f.round_label === 'string' && f.round_label.length > 0, 'Unexpected round label.' ) counts.set(f.round_label, (counts.get(f.round_label) || 0) + 1) }) return Array.from(counts.keys()) .sort() .map(function (label) { return { round_label: label, unlocked_fixtures: counts.get(label) } }) } function template(fixtures, format, round) { check(['straight', 'spread'].indexOf(format) !== -1, 'Choose straight or spread.') var selected = fixtures.filter(function (f) { return !f.locked && (!round || f.round_label === round) }) check(selected.length > 0, 'No unlocked fixtures match that round.') check(selected.length <= MAX_ROWS, 'Choose one round containing at most 100 fixtures.') return [HEADERS].concat( selected.map(function (f) { var pick = f.picks && f.picks[format] return [ f.fixture_id, format, '', pick ? pick.revision : '', format === 'spread' ? f.line_revision || '' : '', '', '', '', f.home_name, f.away_name, f.home_participant_id, f.away_participant_id, f.starts_at, f.line_home == null ? '' : f.line_home, f.round_label, ] }) ) } function number(value, min, max, label) { check( value !== '' && value != null && /^-?\d+(?:\.\d+)?$/.test(String(value)), label + ' must be a plain number.' ) var n = Number(value) check(Number.isFinite(n) && n >= min && n <= max, label + ' is outside the allowed range.') return n } function predictions(rows, fixtures) { check(rows.length > 1, 'The output table has no predictions.') var headers = rows[0].map(function (v) { return String(v).trim() }) check( new Set(headers).size === headers.length && headers.every(function (h) { return HEADERS.indexOf(h) !== -1 }), 'Unknown or duplicate output columns.' ) HEADERS.slice(0, 5).forEach(function (h) { check(headers.indexOf(h) !== -1, 'Missing column: ' + h) }) var seen = {}, catalog = {} fixtures.forEach(function (f) { catalog[f.fixture_id] = f }) var result = [] rows.slice(1).forEach(function (values, index) { check(values.length === headers.length, 'Wrong cell count on output row ' + (index + 2) + '.') var row = {} HEADERS.forEach(function (h) { row[h] = '' }) headers.forEach(function (h, i) { row[h] = values[i] == null ? '' : String(values[i]).trim() }) if (!row.picked_participant_id) return var f = catalog[row.fixture_id] check( UUID.test(row.fixture_id) && f, 'Unknown canonical fixture ID on row ' + (index + 2) + '.' ) check(!f.locked, 'Fixture is locked on row ' + (index + 2) + '.') check(['straight', 'spread'].indexOf(row.pick_format) !== -1, 'Invalid pick format.') check( /^[A-Za-z0-9._-]{1,40}$/.test(row.picked_participant_id) && [f.home_participant_id, f.away_participant_id].indexOf(row.picked_participant_id) !== -1, 'Use the canonical home or away participant ID, not a team name.' ) var key = row.fixture_id + ':' + row.pick_format check(!seen[key], 'Duplicate fixture/format.') seen[key] = true var revision = row.expected_pick_revision === '' ? null : number(row.expected_pick_revision, 1, Number.MAX_SAFE_INTEGER, 'Pick revision') check( revision === null || Number.isSafeInteger(revision), 'Pick revision must be an integer.' ) var line = row.expected_line_revision === '' ? null : row.expected_line_revision check(line === null || /^[0-9a-f]{64}$/.test(line), 'Line revision must be the catalog hash.') check( row.pick_format !== 'spread' || line !== null, 'Spread picks require the current line revision.' ) var p = { fixture_id: row.fixture_id, pick_format: row.pick_format, picked_participant_id: row.picked_participant_id, expected_pick_revision: revision, expected_line_revision: line, } if (row.source_line_home !== '') p.source_line_home = number(row.source_line_home, -60, 60, 'Source line') if (row.home_score !== '' || row.away_score !== '') p.projection = { home_score: number(row.home_score, 0, 200, 'Home projection'), away_score: number(row.away_score, 0, 200, 'Away projection'), } result.push(p) }) check( result.length > 0 && result.length <= MAX_ROWS, 'Select between 1 and 100 prediction rows.' ) return result } function newRun(config, rows, fixtures, generatedAt, runId, idempotencyKey) { check(UUID.test(config.competition_id), 'Competition must be a canonical UUID.') check( typeof config.model_version === 'string' && config.model_version.trim().length > 0 && config.model_version.length <= 100, 'Model version is required (at most 100 characters).' ) check(KEY.test(runId) && KEY.test(idempotencyKey), 'Run ID and idempotency key are required.') check( typeof generatedAt === 'string' && /^\d{4}-\d\d-\d\dT.*Z$/.test(generatedAt) && Number.isFinite(Date.parse(generatedAt)), 'Use a UTC generated_at timestamp.' ) var payload = { competition_id: config.competition_id, game_key: 'nfl-weekly-picks', model_version: config.model_version.trim(), external_run_id: runId, generated_at: generatedAt, predictions: predictions(rows, fixtures), } var body = JSON.stringify(payload) check(byteLength(body) <= MAX_BYTES, 'Batch exceeds 256 KiB.') return { version: 1, api_base: apiBase(config.api_base), idempotency_key: idempotencyKey, payload_text: body, state: 'unpreviewed', preview: null, receipt: null, } } function assertRun(run, base) { check( run && run.version === 1 && run.api_base === apiBase(base) && KEY.test(run.idempotency_key), 'Run file/configuration mismatch.' ) check( typeof run.payload_text === 'string' && byteLength(run.payload_text) <= MAX_BYTES, 'Invalid frozen payload.' ) var p = JSON.parse(run.payload_text) check( UUID.test(p.competition_id) && p.game_key === 'nfl-weekly-picks' && KEY.test(p.external_run_id) && Array.isArray(p.predictions) && p.predictions.length > 0 && p.predictions.length <= MAX_ROWS, 'Invalid frozen batch.' ) return p } function receipt(value, run, phase) { var p = assertRun(run, run.api_base) var statuses = phase === 'preview' ? ['valid', 'unchanged', 'rejected'] : ['accepted', 'unchanged', 'rejected'] check( value && Array.isArray(value.rows) && value.rows.length === p.predictions.length && value.model_version === p.model_version && typeof value.accepted_at === 'string', 'Unexpected receipt envelope.' ) check( phase === 'preview' ? value.batch_id === null : UUID.test(value.batch_id), 'Unexpected batch ID.' ) value.rows.forEach(function (row, i) { check( row.index === i && row.fixture_id === p.predictions[i].fixture_id && statuses.indexOf(row.status) !== -1 && typeof row.message === 'string', 'Unexpected row receipt.' ) }) return value } function previewResult(run, value) { run.preview = receipt(value, run, 'preview') run.state = value.rows.some(function (r) { return r.status === 'rejected' }) ? 'needs_changes' : 'previewed' return run } function publishReady(run, base, confirmed, kickoffConsent) { var p = assertRun(run, base) check(confirmed, 'Publication requires explicit confirmation.') check( ['previewed', 'uncertain', 'published'].indexOf(run.state) !== -1 && run.preview && !run.preview.rows.some(function (r) { return r.status === 'rejected' }), 'Preview every row successfully before publishing.' ) check( !p.predictions.some(function (r) { return r.pick_format === 'spread' }) || kickoffConsent, 'Accept grading against the line captured at kickoff; Preview does not pin that future line.' ) return p } function publishResult(run, value) { run.receipt = receipt(value, run, 'publish') run.state = 'published' return run } function response(status, text) { check( status === 200, 'Rivory returned HTTP ' + status + '. Do not replace this run on an ambiguous publish; retry the saved run with the same key.' ) check( typeof text === 'string' && byteLength(text) <= MAX_BYTES, 'HTTP response exceeds 256 KiB. Preserve the saved run for receipt recovery.' ) try { return JSON.parse(text) } catch { throw new Error('Rivory returned invalid JSON. Keep the saved run unchanged.') } } return { MAX_ROWS: MAX_ROWS, MAX_BYTES: MAX_BYTES, HEADERS: HEADERS, check: check, apiBase: apiBase, credential: credential, safeCell: safeCell, parseCsv: parseCsv, toCsv: toCsv, availableRounds: availableRounds, template: template, newRun: newRun, assertRun: assertRun, receipt: receipt, previewResult: previewResult, publishReady: publishReady, publishResult: publishResult, response: response, } })() if (typeof module !== 'undefined') module.exports = RivoryModelConnect