Files
pbakaus_impeccable/functions/api/_worldroll.js
T
Paul BakausandClaude Fable 5 b5ec969c07 Add world roll API and seed telemetry client
/api/roll deals deterministic challenger rolls server-side (same salts and
sha256 ranking as the local seed, verified bit-for-bit); the request log is
the impression record. /api/chosen takes the anonymous choice ping. Events
land in Workers Analytics Engine.

concept-seed.mjs resolves data in order: local catalog dir, roll API,
degraded promotion-only seed. --chosen sends the choice ping; DO_NOT_TRACK
and IMPECCABLE_NO_TELEMETRY disable it. API-dealt seeds carry the telemetry
instruction inline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:17:09 -07:00

223 lines
7.9 KiB
JavaScript

// Shared world-roll logic for the /api/roll and /api/chosen endpoints.
//
// This mirrors the selection mechanics in skill/scripts/concept-seed.mjs
// exactly (same salts, same sha256 ranking, same rating weights), computed
// with Web Crypto because Workers have no sync hash. Same key + same pool
// revision therefore reproduces a roll bit-for-bit.
//
// Catalog data is bundled at deploy time from functions/api/_data/, refreshed
// by scripts/sync-api-data.mjs. The catalog never ships to clients in full:
// a roll exposes exactly the entries it deals.
import conceptCatalog from './_data/concept-ingredients.json';
import conceptReviews from './_data/concept-reviews.json';
import compositionCatalog from './_data/composition-ingredients.json';
import compositionReviews from './_data/composition-reviews.json';
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']);
const encoder = new TextEncoder();
async function sha256Hex(input) {
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(input));
return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('');
}
async function deterministicRank(items, input, idFor = item => item.id) {
const scored = await Promise.all(items.map(async item => ({
item,
id: idFor(item),
score: await sha256Hex(`${input}:${idFor(item)}`),
})));
scored.sort((a, b) => b.score.localeCompare(a.score) || a.id.localeCompare(b.id));
return scored.map(entry => entry.item);
}
function mergeConcepts() {
const reviews = conceptReviews.reviews || {};
const wellsById = new Map((conceptCatalog.wells || []).map(well => [well.id, well]));
const concepts = [];
for (const family of conceptCatalog.families || []) {
for (const concept of family.concepts || []) {
concepts.push({
...concept,
familyId: family.id,
wellTier: wellsById.get(family.well)?.tier || null,
status: reviews[concept.id]?.status || 'pending',
review: reviews[concept.id] || null,
});
}
}
return concepts;
}
function mergeCompositions() {
const reviews = compositionReviews.reviews || {};
return (compositionCatalog.compositions || []).map(composition => ({
...composition,
status: reviews[composition.id]?.status || 'pending',
}));
}
export async function approvedPoolRevision(concepts) {
const payload = concepts
.filter(concept => concept.status === 'approved')
.map(concept => `${concept.familyId}:${concept.id}:${concept.strength}:${concept.form}:${concept.spark}:${JSON.stringify(concept.system)}:${concept.webLeverage}`)
.sort()
.join('\n');
return (await sha256Hex(payload)).slice(0, 12);
}
export async function selectApprovedChallengers({ scope, key, reroll = 0, concepts }) {
const approved = concepts.filter(concept => concept.status === 'approved');
const wanted = scope === 'direction'
? new Set(['world', 'dual'])
: new Set(['composition', 'dual']);
const approvedByTier = new Map();
for (const concept of approved) {
const tier = approvedByTier.get(concept.wellTier) || [];
tier.push(concept);
approvedByTier.set(concept.wellTier, tier);
}
if (WELL_TIERS.some(tier => !(approvedByTier.get(tier) || []).length)) {
throw new Error('every challenger tier needs at least one approved concept');
}
for (const [tier, pool] of approvedByTier) {
const matching = pool.filter(concept => wanted.has(concept.strength));
if (matching.length > 0) approvedByTier.set(tier, matching);
}
const ticketsFor = pool => pool.flatMap(concept => {
const rating = concept.review?.rating;
if (rating === 1) return [];
return rating === 3
? [{ concept, ticket: 0 }, { concept, ticket: 1 }]
: [{ concept, ticket: 0 }];
});
const pickRound = async (round, excluded) => {
const salt = round === 0 ? '' : `:reroll-${round}`;
const tierOrder = (await deterministicRank(
WELL_TIERS.map(id => ({ id })),
`${scope}:${key}:tiers${salt}`
)).map(item => item.id);
const picks = [];
for (const [index, tier] of tierOrder.entries()) {
let pool = approvedByTier.get(tier).filter(concept => !excluded.has(concept.id));
if (pool.length === 0) pool = approvedByTier.get(tier);
let tickets = ticketsFor(pool);
if (tickets.length === 0) tickets = pool.map(concept => ({ concept, ticket: 0 }));
const ranked = await deterministicRank(
tickets,
`${scope}:${key}:challenger-${index}${salt}`,
entry => `${entry.concept.id}#${entry.ticket}`
);
const order = [];
const seen = new Set();
for (const entry of ranked) {
if (seen.has(entry.concept.id)) continue;
seen.add(entry.concept.id);
order.push(entry.concept);
}
const first = order[0];
const second = order.find(concept => concept.familyId !== first.familyId)
|| order.find(concept => concept.id !== first.id);
picks.push(...(second ? [first, second] : [first]));
}
return picks;
};
const excluded = new Set();
let picks = await pickRound(0, excluded);
for (let round = 1; round <= reroll; round += 1) {
for (const pick of picks) excluded.add(pick.id);
picks = await pickRound(round, excluded);
}
return { approved, picks };
}
export async function selectApprovedStaging({ scope, key, reroll = 0, mode = null, compositions }) {
let approved = compositions.filter(composition => composition.status === 'approved');
if (approved.length === 0) return null;
if (mode) {
const matching = approved.filter(composition => composition.surface === mode);
if (matching.length === 0) return null;
approved = matching;
}
const prior = new Set();
let pick = (await deterministicRank(approved, `${scope}:${key}:staging`))[0];
for (let round = 1; round <= reroll; round += 1) {
prior.add(pick.id);
const pool = approved.filter(composition => !prior.has(composition.id));
pick = (await deterministicRank(
pool.length > 0 ? pool : approved,
`${scope}:${key}:staging:reroll-${round}`
))[0];
}
return pick;
}
const publicConcept = concept => ({
id: concept.id,
form: concept.form,
spark: concept.spark,
system: concept.system,
webLeverage: concept.webLeverage,
wellTier: concept.wellTier,
});
const publicComposition = composition => ({
id: composition.id,
form: composition.form,
spark: composition.spark,
grammar: composition.grammar,
webLeverage: composition.webLeverage,
surface: composition.surface,
});
export async function rollSeed({ scope, key, mode, reroll }) {
const concepts = mergeConcepts();
const compositions = mergeCompositions();
const [poolRevision, { approved, picks }, staging] = await Promise.all([
approvedPoolRevision(concepts),
selectApprovedChallengers({ scope, key, reroll, concepts }),
selectApprovedStaging({ scope, key, reroll, mode, compositions }),
]);
return {
key,
scope,
mode: mode || null,
reroll,
poolRevision,
approvedCount: approved.length,
catalogCount: concepts.length,
challengers: picks.map(publicConcept),
staging: staging ? publicComposition(staging) : null,
};
}
// Impressions and choices land in Workers Analytics Engine when the binding
// exists; without it, logging is a silent no-op so the roll never fails.
export function logEvent(env, event, fields) {
try {
env.ROLL_ANALYTICS?.writeDataPoint({
blobs: [
event,
fields.scope || '',
fields.mode || '',
fields.poolRevision || '',
fields.chosenId || '',
...(fields.dealtIds || []),
],
doubles: [fields.reroll || 0],
indexes: [event],
});
} catch {
// Telemetry must never break a roll.
}
}
export const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};