mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 15:46:30 +03:00
/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>
223 lines
7.9 KiB
JavaScript
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',
|
|
};
|