Dice World — Phase transitions, landing dispatch, render loop

// dc-state.js — Dice World: Phase transitions, spawn, landing dispatch, render loop // ca-015 | load_order 130 | world: dice

(function() { if (!window.DC) { console.warn('dc-state: DC not initialized'); return; } var DC = window.DC;

// ── Local aliases for immutable scene objects ── var scene = DC.scene, camera = DC.camera, renderer = DC.renderer; var controls = DC.controls; var dice = DC.dice; // alias: never reassigned, only mutated var slab = DC.slab, ring = DC.ring; var ambient = DC.ambient, dirLight = DC.dirLight, pointLight = DC.pointLight; var container = DC.container; var bandLabel = DC.bandLabel;

// ── Constant aliases ── var SCALE_BANDS = DC.SCALE_BANDS, BAND_COLORS = DC.BAND_COLORS; var SUBU_COLORS = DC.SUBU_COLORS; var HC_LEVEL_COLORS = DC.HC_LEVEL_COLORS; var HC_ELEMENTS = DC.HC_ELEMENTS, HC_ELEM_COLORS = DC.HC_ELEM_COLORS; var ALIGN_AXIS = DC.ALIGN_AXIS; var DICE_SETS = DC.DICE_SETS, DICE_SET_LABELS = DC.DICE_SET_LABELS; var HOME_DECK_TYPES = DC.HOME_DECK_TYPES; var DECK_TYPE_LABELS = DC.DECK_TYPE_LABELS, DECK_TYPE_COLORS = DC.DECK_TYPE_COLORS; var GEM_NAMES = DC.GEM_NAMES, GEM_LABELS = DC.GEM_LABELS, GEM_COLORS = DC.GEM_COLORS; var PHASE_HOME = DC.PHASE_HOME, PHASE_BAND = DC.PHASE_BAND; var PHASE_SUBU = DC.PHASE_SUBU, PHASE_HEXCUBE = DC.PHASE_HEXCUBE; var PHASE_GEM = DC.PHASE_GEM, PHASE_DECK = DC.PHASE_DECK;

// ── Local mutable state ── var allCubes = [], allDecks = []; var scaleCubeData = null, scaleCubeSolid = null; var subuData = null, hexcubeData = null, grimoireData = null; var fidData = null, pulseData = null; var currentDeck = null, gemThingData = null; var hcWallResult = -1, hcDirResult = -1; var animId = 0, clock = new THREE.Clock();

// ============================================================ // Phase Transitions // ============================================================

function enterHomePhase() { DC.phase = PHASE_HOME; DC.currentBand = null; DC.subuStack = []; DC.hexcubeStack = []; DC.clearDice(); bandLabel.style.display = 'none';

var hasScale = scaleCubeData && scaleCubeSolid; var hasSubu = subuData && subuData.months; var hasHC = !!hexcubeData; var hasGrim = grimoireData && grimoireData.categories && grimoireData.categories.length > 0; var hasFid = fidData && fidData.fids && fidData.fids.length > 0; var hasPulse = pulseData && pulseData.cycles && pulseData.cycles.length > 0;

if (!hasScale && !hasSubu && !hasHC && !hasGrim && !hasFid && !hasPulse) { DC.hud.textContent = 'No data loaded'; return; }

// Layout: back row = gnosis + grimoire + fid + pulse(2) + hexcube; subu floats above at y=8 var backCount = (hasScale ? 1 : 0) + (hasGrim ? 1 : 0) + (hasFid ? 1 : 0) + (hasPulse ? 2 : 0) + (hasHC ? 1 : 0); var backSpacing = backCount > 1 ? 6 / (backCount - 1) : 0; var backStart = -((backCount - 1) * backSpacing) / 2; var backIdx = 0; var backZ = 0;

if (hasScale) { var fColors = {}; for (var fi = 0; fi < 12; fi++) fColors[fi] = BAND_COLORS[SCALE_BANDS[fi]] || 0x888888; scaleCubeData.dieSet = 'gnosis'; DC.createDie(scaleCubeSolid, backStart + backIdx * backSpacing, backZ, scaleCubeData, fColors); backIdx++; }

// Grimoire die (back center) if (hasGrim) { var grimFaces = grimoireData.categories.map(function(cat, i) { return { onyx: cat.sapphire, title: cat.sapphire + ' (' + cat.count + ')', face_number: i + 1, thing_count: cat.count, subu_type: 'grim_cat', grim_sapphire: cat.sapphire }; }); spawnColoredDie(backStart + backIdx * backSpacing, backZ, 'grimoire', grimFaces, 0x8B8B8B, 'Grimoire', 'grimoire'); backIdx++; }

// FID work die if (hasFid) { var activeFids = fidData.fids.filter(function(f) { return f.active > 0; }); if (activeFids.length === 0) activeFids = fidData.fids.slice(0, 20); var fidFaces = activeFids.map(function(f, i) { var pct = f.gaps > 0 ? Math.round(f.done / f.gaps * 100) : 0; return { onyx: f.onyx, title: f.onyx + ' (' + pct + '%)', face_number: i + 1, subu_type: 'fid_entry', fid_id: f.id, fid_title: f.title || f.onyx, gap_total: f.gaps, gap_done: f.done, gap_active: f.active }; }); spawnColoredDie(backStart + backIdx * backSpacing, backZ, 'fid', fidFaces, 0x8B8B8B, 'FIDs', 'fid'); backIdx++; }

// Pulse pair — current cycle progress + completed cycle shelf if (hasPulse) { var pulseX = backStart + backIdx * backSpacing; var shelfX = backStart + (backIdx + 1) * backSpacing; var pulseFaces = pulseData.cycles.map(function(cyc, i) { var pct = Math.round(cyc.progress * 100); return { onyx: cyc.symbol + ' ' + cyc.name, title: cyc.name + ' ' + pct + '% (cycle ' + cyc.current_cycle + ')', face_number: i + 1, subu_type: 'pulse_cycle', cycle: cyc }; }); var shelfFaces = pulseData.cycles.map(function(cyc, i) { return { onyx: cyc.symbol + ' ' + cyc.name, title: cyc.name + ' \u00d7 ' + cyc.completed + ' completed', face_number: i + 1, subu_type: 'shelf_cycle', cycle: cyc }; }); DC.fetchSolid(3, 4).then(function(sd) { if (sd.error) return; var tf = sd.face_indices.length; var pulseFC = {}, shelfFC = {}; for (var fi = 0; fi < tf; fi++) { if (fi < pulseData.cycles.length) { var hex = pulseData.cycles[fi].color.replace('#',''); var c = parseInt(hex, 16); pulseFC[fi] = c; shelfFC[fi] = DC.dimColor(c, 0.5); } else { pulseFC[fi] = 0x272727; shelfFC[fi] = 0x121212; } } var pPad = [], sPad = []; for (var fi2 = 0; fi2 < tf; fi2++) { pPad.push(fi2 < pulseFaces.length ? pulseFaces[fi2] : { onyx: 'empty', title: '', face_number: fi2 + 1, subu_type: 'empty' }); sPad.push(fi2 < shelfFaces.length ? shelfFaces[fi2] : { onyx: 'empty', title: '', face_number: fi2 + 1, subu_type: 'empty' }); } DC.createDie(sd, pulseX, backZ, { onyx: 'Pulse', title: 'Current Cycles', faces: pPad, subuRole: 'pulse', subuLevel: 'pulse_cycle', dieSet: 'pulse' }, pulseFC); DC.createDie(sd, shelfX, backZ, { onyx: 'Shelf', title: 'Completed Cycles', faces: sPad, subuRole: 'shelf', subuLevel: 'shelf_cycle', dieSet: 'pulse' }, shelfFC, 0.85); }); backIdx += 2; }

// HexCube entry die (back right) — each face colored by realm if (hasHC) { var hcFaces = []; for (var ei = 0; ei < 3; ei++) { hcFaces.push({ onyx: HC_ELEMENTS[ei] + ' ' + ['Earth','Air','Fire'][ei], title: ['Earth','Air','Fire'][ei] + ' Realm', face_number: ei + 1, subu_type: 'hc_realm', hc_element: ei }); } var hcPQ = DC.solidForCount(3); DC.fetchSolid(hcPQ[0], hcPQ[1]).then(function(sd) { if (sd.error) return; var tf = sd.face_indices.length; var hcFC = {}; for (var fi = 0; fi < tf; fi++) { hcFC[fi] = fi < HC_ELEM_COLORS.length ? HC_ELEM_COLORS[fi] : 0x333333; } var hcPadded = []; for (var fi2 = 0; fi2 < tf; fi2++) { hcPadded.push(fi2 < hcFaces.length ? hcFaces[fi2] : { onyx: 'empty', title: '', face_number: fi2 + 1, subu_type: 'empty' }); } DC.createDie(sd, backStart + backIdx * backSpacing, backZ, { onyx: 'HexCube', title: 'HexCube', faces: hcPadded, subuRole: 'hexcube', subuLevel: 'hc_realm', realmDie: true, dieSet: 'hexcube' }, hcFC); }); backIdx++; }

// Subu triptych (floating above — temporal observers) var SUBU_ELEVATION = 12; if (hasSubu) { var currentDay = subuData.current_day; var currentMonth = Math.floor(currentDay / 30); console.log('Subu: day=' + currentDay + ' month=' + currentMonth + ' months=' + (subuData.months||[]).length + ' today=' + ((subuData.today||{}).things||[]).length + ' future_days=' + ((subuData.future||{}).days_remaining||0));

var pastMonths = subuData.months.filter(function(m) { return m.month < currentMonth; }); console.log('Subu Past: ' + pastMonths.length + ' months, spawning at elevation=' + SUBU_ELEVATION); if (pastMonths.length > 0) { var pastFaces = pastMonths.map(function(m, i) { return { onyx: 'Month '+(m.month+1), title: 'Month '+(m.month+1)+' ('+m.thing_count+')', face_number: i+1, thing_count: m.thing_count, active_days: m.active_days, subu_type: 'month', month_data: m }; }); spawnColoredDie(-4, 0, 'past', pastFaces, SUBU_COLORS.past.base, 'Past', 'subu', SUBU_ELEVATION); }

var todayThings = subuData.today.things || []; if (todayThings.length > 0) { spawnPackedDice(0, 0, 'present', todayThings.map(function(t, i) { return { onyx: t.onyx, title: t.title || t.onyx, face_number: i+1, sapphire: t.sapphire, subu_type: 'thing', id: t.id }; }), SUBU_COLORS.present.base, 'Today', 'subu', SUBU_ELEVATION); } else { var emptyFaces = []; for (var ei2 = 0; ei2 < 4; ei2++) emptyFaces.push({ onyx: 'Day '+currentDay, title: 'Day '+currentDay+' (empty)', face_number: ei2+1, subu_type: 'empty' }); spawnColoredDie(0, 0, 'present', emptyFaces, SUBU_COLORS.present.base, 'Today', 'subu', SUBU_ELEVATION); }

var futureCount = Math.ceil(subuData.future.days_remaining / 30); var futureFaces = []; for (var fm = 0; fm < Math.min(futureCount, 12); fm++) { futureFaces.push({ onyx: 'Month '+(currentMonth+2+fm), title: 'Month '+(currentMonth+2+fm)+' (future)', face_number: fm+1, thing_count: 0, subu_type: 'future_month' }); } if (futureFaces.length > 0) spawnColoredDie(4, 0, 'future', futureFaces, SUBU_COLORS.future.base, 'Future', 'subu', SUBU_ELEVATION); }

// Deck dice (far back row) — one die per deck_type, only game types var homeDecks = allDecks.filter(function(d) { return d.ruby && HOME_DECK_TYPES[d.ruby.deck_type]; }); var deckTypeGroups = {}, deckTypeOrder = []; for (var dti = 0; dti < homeDecks.length; dti++) { var dt = homeDecks[dti].ruby.deck_type; if (!deckTypeGroups[dt]) { deckTypeGroups[dt] = []; deckTypeOrder.push(dt); } deckTypeGroups[dt].push(homeDecks[dti]); } var hasDecks = deckTypeOrder.length > 0; if (hasDecks) { var deckCount = deckTypeOrder.length; var deckSpacing = deckCount > 1 ? Math.min(4, 6) : 0; var deckStart = -((deckCount - 1) * deckSpacing) / 2; var deckZ = backZ + 4; for (var dki = 0; dki < deckTypeOrder.length; dki++) { var dType = deckTypeOrder[dki]; var dLabel = DECK_TYPE_LABELS[dType] || dType; var dColor = DECK_TYPE_COLORS[dType] || 0x707070; var dProjs = deckTypeGroups[dType]; var subNames = [], subMap = {}; for (var dpi = 0; dpi < dProjs.length; dpi++) { var sn = DC.deckBaseName(dProjs[dpi].onyx); if (!subMap[sn]) { subMap[sn] = { cards: 0 }; subNames.push(sn); } subMap[sn].cards += (dProjs[dpi].faces || []).length; } var totalCards = 0; var deckFaces = subNames.map(function(sn, idx) { totalCards += subMap[sn].cards; return { onyx: sn, title: sn + ' (' + subMap[sn].cards + ')', face_number: idx + 1, subu_type: 'deck_type_entry', deck_type: dType, deck_label: dLabel, deck_total: 0 }; }); for (var dfi = 0; dfi < deckFaces.length; dfi++) deckFaces[dfi].deck_total = totalCards; (function(type, label, color, x, z) { spawnColoredDie(x, z, 'deck', deckFaces, color, label, 'decks'); })(dType, dLabel, dColor, deckStart + dki * deckSpacing, deckZ); } }

// ── Alignment Dice (2 tetrahedra — Order axis + Moral axis) ── DC.alignResult = [-1, -1]; var alignZ = -3; DC.fetchSolid(3, 3).then(function(sd) { if (sd.error) return; var tf = sd.face_indices.length; for (var ai = 0; ai < 2; ai++) { var axis = ALIGN_AXIS[ai]; var aFaces = [], aFC = {}; for (var fi = 0; fi < tf; fi++) { if (fi < axis.values.length) { aFaces.push({ onyx: axis.values[fi], title: axis.values[fi], face_number: fi + 1, subu_type: 'align_value', align_axis: ai, align_value: fi }); aFC[fi] = axis.colors[fi]; } else { aFaces.push({ onyx: 'empty', title: '', face_number: fi + 1, subu_type: 'empty' }); aFC[fi] = 0x222222; } } DC.createDie(sd, ai === 0 ? -2 : 2, alignZ, { onyx: axis.label, title: axis.label + ' Axis', faces: aFaces, subuRole: 'alignment', subuLevel: 'align_value', dieSet: 'alignment' }, aFC, 0.8); } });

var parts = []; if (hasScale) parts.push('Gnosis'); if (hasGrim) parts.push('Grimoire'); if (hasFid) parts.push('FIDs'); if (hasPulse) parts.push('Pulse'); if (hasHC) parts.push('HexCube'); if (hasSubu) parts.push('Subu'); if (hasDecks) parts.push(deckTypeOrder.map(function(t) { return DECK_TYPE_LABELS[t] || t; }).join('+')); parts.push('Alignment'); DC.hud.textContent = parts.join(' + ') + ' \u00b7 Space=roll \u00b7 T=toggle sets \u00b7 Enter=forward \u00b7 E=back';

// Auto-roll on load — wait for async die spawns, then gentle roll setTimeout(function() { for (var d = 0; d < dice.length; d++) { if (dice[d].cube && dice[d].cube.realmDie) continue; if (!dice[d].group.visible) continue; var p = DC.cc('physics'); dice[d].rolling = true; dice[d].settled = false; dice[d].landedFace = -1; dice[d].velocity.set( (Math.random()-0.5) * 2, p.launch_y[0] * 0.4 + Math.random() * p.launch_y[1] * 0.3, (Math.random()-0.5) * 2 ); dice[d].angularVel.set( (Math.random()-0.5) * p.spin * 0.5, (Math.random()-0.5) * p.spin * 0.5, (Math.random()-0.5) * p.spin * 0.5 ); } }, 800); }

// ── Spawn colored die (subu/hexcube) ── function spawnColoredDie(x, z, role, faceData, baseColor, label, dieSet, elevation) { var count = faceData.length || 4; var pq = DC.solidForCount(count); DC.fetchSolid(pq[0], pq[1]).then(function(sd) { if (sd.error) return; var tf = sd.face_indices.length; var fColors = {}; for (var fi = 0; fi < tf; fi++) { var t = tf > 1 ? fi / (tf-1) : 0.5; fColors[fi] = DC.dimColor(baseColor, 0.6 + t * 0.4); } var padded = []; for (var fi2 = 0; fi2 < tf; fi2++) { padded.push(fi2 < faceData.length ? faceData[fi2] : { onyx: 'empty', title: '', face_number: fi2+1, subu_type: 'empty' }); } var ci = { onyx: label, title: label, faces: padded, subuRole: role, subuLevel: faceData.length > 0 ? (faceData[0].subu_type || 'unknown') : 'empty' }; if (dieSet) ci.dieSet = dieSet; if (elevation != null) ci.elevation = elevation; DC.createDie(sd, x, z, ci, fColors); }) .catch(function(e) { console.warn('Die fetch failed:', label, e); }); }

// ── Spawn packed dice (shape derived from content count) ── function spawnPackedDice(cx, cz, role, faceData, baseColor, label, dieSet, elevation) { var n = faceData.length; if (n === 0) return; fetch('/api/platonic/pack/' + n) .then(function(r) { return r.json(); }) .then(function(pack) { if (pack.error || !pack.dice || pack.dice.length === 0) { spawnColoredDie(cx, cz, role, faceData, baseColor, label, dieSet, elevation); return; } var solids = pack.dice; var spacing = Math.min(3, 12 / solids.length); var startX = cx - ((solids.length - 1) * spacing) / 2; var off = 0; solids.forEach(function(s, si) { var used = Math.min(s.faces, n - off); var chunk = faceData.slice(off, off + used); off += used; DC.fetchSolid(s.schlafli.p, s.schlafli.q).then(function(sd) { if (sd.error) return; var tf = sd.face_indices.length; var fC = {}; for (var fi = 0; fi < tf; fi++) { var t = tf > 1 ? fi / (tf - 1) : 0.5; fC[fi] = DC.dimColor(baseColor, 0.6 + t * 0.4); } var padded = []; for (var fi2 = 0; fi2 < tf; fi2++) { padded.push(fi2 < chunk.length ? chunk[fi2] : { onyx: 'empty', title: '', face_number: fi2 + 1, subu_type: 'empty' }); } var dLabel = solids.length > 1 ? label + ' (' + (si + 1) + '/' + solids.length + ')' : label; var ci2 = { onyx: dLabel, title: dLabel, faces: padded, subuRole: role, subuLevel: 'thing' }; if (dieSet) ci2.dieSet = dieSet; if (elevation != null) ci2.elevation = elevation; DC.createDie(sd, startX + si * spacing, cz, ci2, fC); }); }); DC.hud.textContent = label + ' \u00b7 ' + n + ' things \u00b7 ' + solids.length + ' ' + (solids.length === 1 ? 'die' : 'dice'); }) .catch(function(e) { console.warn('Pack failed, falling back:', e); spawnColoredDie(cx, cz, role, faceData, baseColor, label); }); }

// ── Deck Phase (by deck_type) ── function enterDeckPhase(deckType) { DC.phase = PHASE_DECK; currentDeck = deckType; DC.clearDice(); var dLabel = DECK_TYPE_LABELS[deckType] || deckType; var dColor = DECK_TYPE_COLORS[deckType] || 0x707070; bandLabel.style.color = '#' + ('000000' + dColor.toString(16)).slice(-6); bandLabel.textContent = dLabel; bandLabel.style.display = 'block'; bandLabel.style.opacity = '1'; var items = allDecks.filter(function(d) { return d.ruby && d.ruby.deck_type === deckType; }); if (items.length === 0) { DC.hud.textContent = dLabel + ' \u2014 no cards \u00b7 E = back'; return; } var spacing = Math.min(4, 20 / items.length), startX = -((items.length - 1) * spacing) / 2; var loaded = 0; items.forEach(function(item, idx) { DC.fetchSolid(item.schlafli[0], item.schlafli[1]).then(function(sd) { if (sd.error) return; var fC = {}; for (var fi = 0; fi < sd.face_indices.length; fi++) fC[fi] = DC.dimColor(dColor, 0.35 + (fi / sd.face_indices.length) * 0.3); DC.createDie(sd, startX + idx * spacing, 0, { onyx: item.onyx, title: item.title || item.onyx, faces: item.faces || [], ruby: item.ruby, is_deck: true, card_count: item.card_count || 0, subuRole: 'deck_card' }, fC); loaded++; DC.hud.textContent = dLabel + ' \u00b7 ' + loaded + '/' + items.length + ' dice \u00b7 E = back'; }); }); DC.hud.textContent = dLabel + ' \u00b7 loading... \u00b7 E = back'; }

// ── Band Phase (gnosis) ── function enterBandPhase(bandName) { DC.phase = PHASE_BAND; DC.currentBand = bandName; DC.clearDice(); var bColor = BAND_COLORS[bandName] || 0xffffff; bandLabel.style.color = '#'+('000000'+bColor.toString(16)).slice(-6); bandLabel.textContent = bandName; bandLabel.style.display = 'block'; bandLabel.style.opacity = '1'; var items = allCubes.filter(function(c) { return c.ruby && c.ruby.scale_band === bandName; }) .concat(allDecks.filter(function(d) { return d.ruby && d.ruby.scale_band === bandName; })); if (items.length === 0) { DC.hud.textContent = bandName + ' \u2014 no content \u00b7 E = back'; return; } var spacing = Math.min(4, 20/items.length), startX = -((items.length-1)*spacing)/2; var loaded = 0; items.forEach(function(item, idx) { DC.fetchSolid(item.schlafli[0], item.schlafli[1]).then(function(sd) { if (sd.error) return; var fC = null; if (item.is_deck) { fC = {}; for (var fi = 0; fi < sd.face_indices.length; fi++) fC[fi] = DC.dimColor(bColor, 0.35+(fi/sd.face_indices.length)*0.3); } DC.createDie(sd, startX+idx*spacing, 0, { onyx: item.onyx, title: item.title||item.onyx, faces: item.faces||[], ruby: item.ruby, is_deck: item.is_deck||false, card_count: item.card_count||0 }, fC); loaded++; DC.hud.textContent = bandName + ' \u00b7 ' + loaded + '/' + items.length + ' dice \u00b7 E = back'; }); }); DC.hud.textContent = bandName + ' \u00b7 loading... \u00b7 E = back'; }

// ── Subu Descent ── function enterSubuLevel(level, items, label, baseColor) { DC.phase = PHASE_SUBU; DC.clearDice(); DC.subuStack.push({ level: level, items: items, label: label, color: baseColor }); bandLabel.style.color = '#'+('000000'+baseColor.toString(16)).slice(-6); bandLabel.textContent = label; bandLabel.style.display = 'block'; bandLabel.style.opacity = '1'; if (items.length === 0) { DC.hud.textContent = label + ' \u2014 empty \u00b7 E = back'; return; }

// Pack large sets into multiple dice if (level === 'thing' && items.length > 20) { var sols = [[20,3,5],[12,5,3],[8,3,4],[6,4,3],[4,3,3]]; var rem = items.length, packs = []; for (var si = 0; si < sols.length; si++) while (rem >= sols[si][0]) { packs.push(sols[si]); rem -= sols[si][0]; } if (rem > 0) { for (var si2 = sols.length-1; si2 >= 0; si2--) if (sols[si2][0] >= rem) { packs.push(sols[si2]); break; } } var sp = Math.min(4, 20/packs.length), sx = -((packs.length-1)*sp)/2, off = 0; packs.forEach(function(pk, pi) { var used = Math.min(pk[0], items.length-off), pf = items.slice(off, off+used); off += used; DC.fetchSolid(pk[1], pk[2]).then(function(sd) { if (sd.error) return; var tf = sd.face_indices.length, fC = {}; for (var fi = 0; fi < tf; fi++) fC[fi] = DC.dimColor(baseColor, 0.5+fi/tf*0.5); var pad = []; for (var fi2 = 0; fi2 < tf; fi2++) pad.push(fi2 < pf.length ? pf[fi2] : { onyx:'empty', face_number:fi2+1, subu_type:'empty' }); var dl = packs.length > 1 ? label+' ('+(pi+1)+'/'+packs.length+')' : label; DC.createDie(sd, sx+pi*sp, 0, { onyx:dl, title:dl, faces:pad, subuRole:'descent', subuLevel:'thing' }, fC); DC.hud.textContent = label+' \u00b7 '+items.length+' things \u00b7 E = back'; }); }); return; } spawnColoredDie(0, 0, 'descent', items, baseColor, label); DC.hud.textContent = label + ' \u00b7 ' + items.length + ' faces \u00b7 E = back'; }

// ── HexCube Descent — dual dice ── function enterHexcubeLevel(levelIdx, label, address) { DC.phase = PHASE_HEXCUBE; DC.clearDice(); var levels = ['array','hc','cube','cluster','set','node']; var levelName = levels[levelIdx] || 'node'; var color = HC_LEVEL_COLORS[levelName] || 0x8B8B8B; DC.hexcubeStack.push({ levelIdx: levelIdx, label: label, address: address }); hcWallResult = -1; hcDirResult = -1;

bandLabel.style.color = '#'+('000000'+color.toString(16)).slice(-6); bandLabel.textContent = label + ' \u00b7 ' + levelName; bandLabel.style.display = 'block'; bandLabel.style.opacity = '1';

var wallFaces = [], dirFaces = []; for (var ei = 0; ei < 3; ei++) { var eName = ['Earth','Air','Fire'][ei]; wallFaces.push({ onyx: HC_ELEMENTS[ei]+' '+eName, title: eName, face_number: ei+1, subu_type: 'hc_wall', hc_element: ei }); dirFaces.push({ onyx: HC_ELEMENTS[ei]+' '+eName, title: eName, face_number: ei+1, subu_type: 'hc_dir', hc_element: ei }); } var hcPQ2 = DC.solidForCount(3); DC.fetchSolid(hcPQ2[0], hcPQ2[1]).then(function(sd) { if (sd.error) return; var tf = sd.face_indices.length; var wFC = {}, dFC = {}; for (var fi = 0; fi < tf; fi++) { wFC[fi] = fi < HC_ELEM_COLORS.length ? HC_ELEM_COLORS[fi] : 0x333333; dFC[fi] = fi < HC_ELEM_COLORS.length ? DC.dimColor(HC_ELEM_COLORS[fi], 0.7) : 0x222222; } var wPad = [], dPad = []; for (var fi2 = 0; fi2 < tf; fi2++) { wPad.push(fi2 < wallFaces.length ? wallFaces[fi2] : { onyx:'empty', face_number:fi2+1, subu_type:'empty' }); dPad.push(fi2 < dirFaces.length ? dirFaces[fi2] : { onyx:'empty', face_number:fi2+1, subu_type:'empty' }); } DC.createDie(sd, -2, 0, { onyx:levelName+' Wall', title:levelName+' Wall', faces:wPad, subuRole:'hc_wall', subuLevel:'hc_wall', realmDie:true }, wFC); DC.createDie(sd, 2, 0, { onyx:levelName+' Dir', title:levelName+' Dir', faces:dPad, subuRole:'hc_dir', subuLevel:'hc_dir' }, dFC); }); DC.hud.textContent = label + ' \u00b7 ' + levelName + ' \u00b7 dual dice \u00b7 E = back'; }

// Check if both hexcube dice have settled — resolve the cell function checkHexcubeDualDice() { if (hcWallResult < 0 || hcDirResult < 0) return; var w = hcWallResult, d2 = hcDirResult; var elemPair = HC_ELEMENTS[w] + HC_ELEMENTS[d2]; var top = DC.hexcubeStack[DC.hexcubeStack.length - 1]; var addr = top.address ? top.address + ',' + (w-1) + ',' + (d2-1) : (w-1) + ',' + (d2-1); var levels = ['array','hc','cube','cluster','set','node']; var nextLevel = top.levelIdx + 1; var cellTitle = elemPair + ' ' + (levels[top.levelIdx] || 'node');

var wallDie = null; for (var di = 0; di < dice.length; di++) { if (dice[di].cube && dice[di].cube.subuRole === 'hc_wall') { wallDie = dice[di]; break; } }

if (nextLevel < levels.length) { var nextName = levels[nextLevel]; if (wallDie) { DC.handleLanding(wallDie, 0, cellTitle, nextName + ' level \u2192 dual dice', DC.actionsEnter(function() { enterHexcubeLevel(nextLevel, cellTitle, addr); })); } DC.hud.textContent = HC_ELEMENTS[w] + '\u00d7' + HC_ELEMENTS[d2] + ' = ' + cellTitle + ' \u00b7 Enter to descend \u00b7 E = back'; } else { if (wallDie) { DC.handleLanding(wallDie, 0, cellTitle, 'node \u00b7 ' + addr, false, null); } DC.hud.textContent = cellTitle + ' \u00b7 node \u00b7 ' + addr; } }

// ── Check alignment dice — resolve the 3×3 grid cell ── function checkAlignmentDice() { if (DC.alignResult[0] < 0 || DC.alignResult[1] < 0) return; var orderNames = ALIGN_AXIS[0].values; var moralNames = ALIGN_AXIS[1].values; var cellName = orderNames[DC.alignResult[0]] + ' ' + moralNames[DC.alignResult[1]]; var hcRow = DC.alignResult[0]; var hcCol = DC.alignResult[1]; var orderDie = null; for (var di = 0; di < dice.length; di++) { if (dice[di].cube && dice[di].cube.subuRole === 'alignment' && dice[di].cube.onyx === 'Order') { orderDie = dice[di]; break; } } if (orderDie) { DC.handleLanding(orderDie, 0, cellName, 'HexCube [' + hcRow + ',' + hcCol + ']', false, null); } DC.hud.textContent = 'Alignment: ' + cellName + ' \u00b7 [' + hcRow + ',' + hcCol + ']'; }

// ── Dice Set Toggle ── function toggleDiceSet(setName) { DC.diceSetVisible[setName] = !DC.diceSetVisible[setName]; var vis = DC.diceSetVisible[setName]; for (var d = 0; d < dice.length; d++) { if (dice[d].dieSet === setName) dice[d].group.visible = vis; } } function buildSetToggleUI() { var existing = document.getElementById('dice-set-panel'); if (existing) existing.parentNode.removeChild(existing); var panel = document.createElement('div'); panel.id = 'dice-set-panel'; panel.style.cssText = 'position:fixed;top:60px;right:20px;background:rgba(18,18,18,0.85);' + 'border:1px solid #334;border-radius:8px;padding:8px 12px;z-index:200;font:12px "hanken-grotesk",sans-serif;color:#aab;'; panel.innerHTML = '<div style="color:#667;font-size:10px;margin-bottom:4px">DICE SETS</div>'; var activeSets = {}; for (var d = 0; d < dice.length; d++) { if (dice[d].dieSet) activeSets[dice[d].dieSet] = true; } for (var si = 0; si < DICE_SETS.length; si++) { var s = DICE_SETS[si]; if (!activeSets[s]) continue; var row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:6px;padding:2px 0;cursor:pointer;'; var vis = DC.diceSetVisible[s]; row.innerHTML = '<span style="color:' + (vis ? '#8B8B8B' : '#444') + '">' + (vis ? '\u25C9' : '\u25CB') + '</span>' + '<span style="color:' + (vis ? '#ccd' : '#556') + '">' + (DICE_SET_LABELS[s] || s) + '</span>'; (function(setName) { row.onclick = function() { toggleDiceSet(setName); buildSetToggleUI(); }; })(s); panel.appendChild(row); } document.body.appendChild(panel); }

// ── Gem Inspection — content-derived gem die for any Thing ── function enterGemPhase(thingId, thingName, returnPhase) { DC.gemReturnPhase = returnPhase || DC.phase; DC.hud.textContent = 'Loading ' + thingName + '...'; fetch('/api/things/doc/' + thingId) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.sacred || !data.gem_values) { DC.hud.textContent = 'Could not load ' + thingName; return; } gemThingData = { id: thingId, onyx: data.gem_values.onyx || thingName, gem_values: data.gem_values }; DC.phase = PHASE_GEM; DC.clearDice(); bandLabel.style.color = '#E1E1E1'; bandLabel.textContent = gemThingData.onyx; bandLabel.style.display = 'block'; bandLabel.style.opacity = '1';

var gemFaces = [], gemCount = 0; for (var gi = 0; gi < GEM_NAMES.length; gi++) { var gName = GEM_NAMES[gi]; var gVal = gemThingData.gem_values[gName]; var hasData = gVal != null && gVal !== ''; if (hasData) gemCount++; gemFaces.push({ onyx: gName, title: gName, face_number: gi + 1, subu_type: 'gem_face', gem_name: gName, gem_label: GEM_LABELS[gName], gem_data: gVal, has_data: hasData }); } var pq = DC.solidForCount(Math.max(gemCount, 4)); DC.fetchSolid(pq[0], pq[1]).then(function(sd) { if (sd.error) { DC.hud.textContent = 'Solid fetch failed'; return; } var tf = sd.face_indices.length; var fColors = {}; for (var fi = 0; fi < tf; fi++) { if (fi < GEM_NAMES.length) { var gn = GEM_NAMES[fi]; var hd = gemThingData.gem_values[gn] != null && gemThingData.gem_values[gn] !== ''; fColors[fi] = hd ? GEM_COLORS[gn] : DC.dimColor(GEM_COLORS[gn], 0.3); } else { fColors[fi] = 0x111111; } } while (gemFaces.length < tf) { gemFaces.push({ onyx: 'empty', title: '', face_number: gemFaces.length + 1, subu_type: 'empty' }); } DC.createDie(sd, 0, 0, { onyx: gemThingData.onyx + ' Gems', title: 'Gem Die', faces: gemFaces, subuRole: 'gem', subuLevel: 'gem_face' }, fColors); DC.hud.textContent = gemThingData.onyx + ' \u00b7 ' + gemCount + ' gems \u00b7 Space=roll \u00b7 E = back'; }); }) .catch(function(e) { console.warn('Gem fetch failed:', e); DC.hud.textContent = 'Failed to load gems'; }); }

// UUID regex for linkification var UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi;

function formatGemValue(val) { if (val == null) return '(empty)'; if (typeof val === 'string') { var s = val.length > 200 ? val.substring(0, 200) + '...' : val; return linkifyUUIDs(s); } if (typeof val === 'number' || typeof val === 'boolean') return String(val); if (Array.isArray(val)) return val.map(function(v) { var s = typeof v === 'string' ? v : JSON.stringify(v); return linkifyUUIDs(s); }).join(', '); if (typeof val === 'object') { var lines = []; var keys = Object.keys(val); for (var ki = 0; ki < keys.length; ki++) { var k = keys[ki], v = val[k]; var vStr = typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v).substring(0, 80) : String(v)); if (k === 'body') vStr = (typeof v === 'string' ? v.substring(0, 100) + '...' : vStr); lines.push(k + ': ' + linkifyUUIDs(vStr)); } return lines.join('\n'); } return linkifyUUIDs(JSON.stringify(val).substring(0, 200)); }

function linkifyUUIDs(text) { return text.replace(UUID_RE, '<span class="uuid-link" data-uuid="$1" style="color:#8B8B8B;cursor:pointer;text-decoration:underline;text-decoration-style:dotted">$1</span>'); }

// Build 3x3 grid faces for hexcube level (legacy — kept for reference) function buildHexcubeGrid(levelIdx, parentAddress) { var levels = ['array','hc','cube','cluster','set','node']; var levelName = levels[levelIdx] || 'unknown'; var faces = []; for (var w = 0; w < 3; w++) { for (var d2 = 0; d2 < 3; d2++) { var elemPair = HC_ELEMENTS[w] + HC_ELEMENTS[d2]; var addr = parentAddress ? parentAddress + ',' + (w-1) + ',' + (d2-1) : (w-1) + ',' + (d2-1); faces.push({ onyx: elemPair, title: elemPair + ' ' + levelName, face_number: w*3 + d2 + 1, subu_type: 'hc_cell', hc_level: levelIdx, hc_address: addr, hc_wall: w, hc_dir: d2 }); } } return faces; }

// ============================================================ // Landing Handler — Card Doors // ============================================================

window.addEventListener('dice-landed', function(e) { var d = e.detail, die = d.die; var faceInfo = DC.getFaceInfo(die, d.face); var sacredInfo = DC.getFaceInfo(die, d.sacredFace);

// ── Scale Cube → door to band ── if (d.isScaleCube && DC.phase === PHASE_HOME) { var bandIdx = d.face; if (bandIdx >= 0 && bandIdx < SCALE_BANDS.length) { var bandName = SCALE_BANDS[bandIdx]; var bandItems = allCubes.filter(function(c) { return c.ruby && c.ruby.scale_band === bandName; }) .concat(allDecks.filter(function(dd) { return dd.ruby && dd.ruby.scale_band === bandName; })); var hasContent = bandItems.length > 0; if (hasContent) { DC.handleLanding(die, d.face, bandName, bandItems.length + ' dice at this scale', DC.actionsThing(null, bandName, function() { enterBandPhase(bandName); }, function() { DC.smashDie(die, bandItems.map(function(b) { return { onyx: b.onyx, title: b.onyx, id: b.id, sapphire: b.sapphire }; }), bandName, BAND_COLORS[bandName] || 0x888888); } )); } else { DC.handleLanding(die, d.face, bandName, 'no content yet', false, null); } DC.hud.textContent = 'Rolled: ' + bandName; } return; }

// ── Subu die → doors ── var subuRole = die.cube && die.cube.subuRole; if (subuRole && (DC.phase === PHASE_HOME || DC.phase === PHASE_SUBU)) { if (!faceInfo || faceInfo.subu_type === 'empty') { DC.hud.textContent = (die.cube.onyx||'Subu') + ' \u25B2 face '+(d.face+1)+' (empty)'; return; }

// Past month → door to weeks (Enter) or smash all days (Smash) if (subuRole === 'past' && faceInfo.subu_type === 'month') { var monthEnterFn = function() { var md = faceInfo.month_data, days = md.days || [], ms = md.day_start; var weeks = {}; for (var di = 0; di < days.length; di++) { var wk = Math.floor((days[di].day - ms) / 7); if (!weeks[wk]) weeks[wk] = []; weeks[wk].push(days[di]); } var wKeys = Object.keys(weeks).sort(function(a,b){return a-b;}); var wFaces = wKeys.map(function(wk,i) { var wd = weeks[wk], wt = 0; for (var j=0;j<wd.length;j++) wt+=wd[j].count; var ws = ms+parseInt(wk)*7, we = Math.min(ws+6, md.day_end); return { onyx:'Week '+(parseInt(wk)+1), title:'Days '+ws+'-'+we+' ('+wt+')', face_number:i+1, thing_count:wt, subu_type:'week', week_days:wd, week_start:ws, week_end:we }; }); enterSubuLevel('week', wFaces, 'Month '+(md.month+1), SUBU_COLORS.past.light); }; var monthSmashFn = function() { var md = faceInfo.month_data, days = md.days || []; var dayNums = []; for (var di = 0; di < days.length; di++) { if (days[di].count > 0) dayNums.push(days[di].day); } if (dayNums.length === 0) { DC.hud.textContent = 'Month ' + (md.month+1) + ' \u2014 empty'; return; } DC.hud.textContent = 'Smashing Month ' + (md.month+1) + '...'; var allThings = [], loaded = 0; dayNums.forEach(function(dayNum) { fetch('/api/worlds/dice/subu/' + dayNum).then(function(r){return r.json();}).then(function(data) { if (data.things) for (var ti = 0; ti < data.things.length; ti++) allThings.push(data.things[ti]); loaded++; if (loaded >= dayNums.length && allThings.length > 0) { DC.smashGrouped(die, allThings, 'Month ' + (md.month+1), SUBU_COLORS.past.light); } else if (loaded >= dayNums.length) { DC.hud.textContent = 'Month ' + (md.month+1) + ' \u2014 empty'; } }); }); }; DC.handleLanding(die, d.face, faceInfo.title, faceInfo.active_days + ' active days', DC.actionsThing(null, faceInfo.title, monthEnterFn, monthSmashFn, die)); DC.hud.textContent = 'Rolled: ' + faceInfo.title; return; }

// Week → door to days (Enter) or smash all days (Smash) if (faceInfo.subu_type === 'week') { var weekEnterFn = function() { var wd = faceInfo.week_days || []; var dFaces = wd.map(function(d2,i) { return { onyx:'Day '+d2.day, title:'Day '+d2.day+' ('+d2.count+')', face_number:i+1, thing_count:d2.count, subu_type:'day', day_number:d2.day }; }); enterSubuLevel('day', dFaces, faceInfo.title, SUBU_COLORS.past.accent); }; var weekSmashFn = function() { var wd = faceInfo.week_days || []; var dayNums = []; for (var wdi = 0; wdi < wd.length; wdi++) { if (wd[wdi].count > 0) dayNums.push(wd[wdi].day); } if (dayNums.length === 0) { DC.hud.textContent = faceInfo.title + ' \u2014 empty'; return; } DC.hud.textContent = 'Smashing ' + faceInfo.title + '...'; var allThings = [], loaded = 0; dayNums.forEach(function(dayNum) { fetch('/api/worlds/dice/subu/' + dayNum).then(function(r){return r.json();}).then(function(data) { if (data.things) for (var ti = 0; ti < data.things.length; ti++) allThings.push(data.things[ti]); loaded++; if (loaded >= dayNums.length && allThings.length > 0) { DC.smashGrouped(die, allThings, faceInfo.title, SUBU_COLORS.past.accent); } else if (loaded >= dayNums.length) { DC.hud.textContent = faceInfo.title + ' \u2014 empty'; } }); }); }; DC.handleLanding(die, d.face, faceInfo.title, faceInfo.thing_count + ' things', DC.actionsThing(null, faceInfo.title, weekEnterFn, weekSmashFn, die)); DC.hud.textContent = 'Rolled: ' + faceInfo.title; return; }

// Day → door to things (fetch) if (faceInfo.subu_type === 'day') { var dayEnterFn = function() { DC.hud.textContent = 'Loading Day ' + faceInfo.day_number + '...'; fetch('/api/worlds/dice/subu/' + faceInfo.day_number).then(function(r){return r.json();}).then(function(data) { if (!data.things || data.things.length === 0) { DC.hud.textContent = 'Day '+faceInfo.day_number+' \u2014 empty'; return; } var tFaces = data.things.map(function(t,i) { return { onyx:t.onyx, title:t.title||t.onyx, face_number:i+1, sapphire:t.sapphire, preview:t.preview||'', subu_type:'thing', id:t.id }; }); enterSubuLevel('thing', tFaces, 'Day '+faceInfo.day_number, SUBU_COLORS.past.accent); }); }; var daySmashFn = function() { DC.hud.textContent = 'Smashing Day ' + faceInfo.day_number + '...'; fetch('/api/worlds/dice/subu/' + faceInfo.day_number).then(function(r){return r.json();}).then(function(data) { if (!data.things || data.things.length === 0) { DC.hud.textContent = 'Day '+faceInfo.day_number+' \u2014 empty'; return; } DC.smashDie(die, data.things, 'Day '+faceInfo.day_number, SUBU_COLORS.past.accent); }); }; DC.handleLanding(die, d.face, 'Day ' + faceInfo.day_number, faceInfo.thing_count + ' things', DC.actionsThing(null, 'Day '+faceInfo.day_number, dayEnterFn, daySmashFn)); DC.hud.textContent = 'Rolled: Day ' + faceInfo.day_number; return; }

// Present (terminal — gaze inspects gems) if (subuRole === 'present' && faceInfo.subu_type === 'thing') { DC.handleLanding(die, d.face, faceInfo.title, faceInfo.sapphire || '', DC.actionsThing(faceInfo.id, faceInfo.title, null, null, die)); DC.hud.textContent = 'Today \u00b7 ' + faceInfo.title; return; }

// Future (sealed) if (subuRole === 'future' || faceInfo.subu_type === 'future_month') { DC.handleLanding(die, d.face, faceInfo.title, 'not yet written', false, null); DC.hud.textContent = faceInfo.title + ' \u2014 sealed'; return; }

// Terminal thing — gaze inspects gems if (faceInfo.subu_type === 'thing') { DC.handleLanding(die, d.face, faceInfo.title, faceInfo.preview || faceInfo.sapphire || '', DC.actionsThing(faceInfo.id, faceInfo.title, null, null, die)); var sacred = sacredInfo ? (sacredInfo.title || 'face '+(d.sacredFace+1)) : 'face '+(d.sacredFace+1); DC.hud.textContent = faceInfo.title + ' \u00b7 \u25BC ' + sacred; return; } }

// ── Grimoire die → doors ── if (die.cube && die.cube.subuRole === 'grimoire' && DC.phase === PHASE_HOME) { if (!faceInfo || faceInfo.subu_type === 'empty') return; if (faceInfo.subu_type === 'grim_cat') { var grimEnter = function() { DC.hud.textContent = 'Loading ' + faceInfo.grim_sapphire + '...'; fetch('/api/worlds/dice/grimoire/' + encodeURIComponent(faceInfo.grim_sapphire)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.things || data.things.length === 0) { DC.hud.textContent = faceInfo.grim_sapphire + ' \u2014 empty'; return; } var tFaces = data.things.map(function(t, i) { return { onyx: t.onyx, title: t.title || t.onyx, face_number: i + 1, sapphire: t.sapphire, preview: t.preview || '', subu_type: 'thing', id: t.id }; }); enterSubuLevel('thing', tFaces, faceInfo.grim_sapphire, 0x8B8B8B); }); }; var grimSmash = function() { DC.hud.textContent = 'Smashing ' + faceInfo.grim_sapphire + '...'; fetch('/api/worlds/dice/grimoire/' + encodeURIComponent(faceInfo.grim_sapphire)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.things || data.things.length === 0) { DC.hud.textContent = faceInfo.grim_sapphire + ' \u2014 empty'; return; } DC.smashDie(die, data.things, faceInfo.grim_sapphire, 0x8B8B8B); }); }; var grimDieEnter = function() { var allCats = grimoireData.categories || []; var allFaces = allCats.map(function(cat, i) { return { onyx: cat.sapphire, title: cat.sapphire + ' (' + cat.count + ')', face_number: i + 1, thing_count: cat.count, subu_type: 'grim_cat', grim_sapphire: cat.sapphire }; }); enterSubuLevel('grim_cat', allFaces, 'Grimoire', 0x8B8B8B); }; var grimDieSmash = function() { var allCats = grimoireData.categories || []; var catItems = allCats.map(function(cat) { return { onyx: cat.sapphire, title: cat.sapphire, sapphire: cat.sapphire, count: cat.count }; }); DC.smashGrouped(die, catItems, 'Grimoire', 0x8B8B8B); }; DC.handleLanding(die, d.face, faceInfo.title, faceInfo.thing_count + ' scrolls', DC.actionsFaceDie(grimEnter, grimSmash, grimDieEnter, grimDieSmash)); DC.hud.textContent = 'Rolled: ' + faceInfo.title; return; } }

// ── FID die → doors to gaps ── if (die.cube && die.cube.subuRole === 'fid' && DC.phase === PHASE_HOME) { if (!faceInfo || faceInfo.subu_type === 'empty') return; if (faceInfo.subu_type === 'fid_entry') { var pct = faceInfo.gap_total > 0 ? Math.round(faceInfo.gap_done / faceInfo.gap_total * 100) : 0; var fidSub = faceInfo.gap_done + '/' + faceInfo.gap_total + ' gaps done (' + pct + '%) \u00b7 ' + faceInfo.gap_active + ' active'; var fidEnter = function() { DC.hud.textContent = 'Loading gaps for ' + faceInfo.onyx + '...'; fetch('/api/worlds/dice/fid/' + faceInfo.fid_id) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.gaps || data.gaps.length === 0) { DC.hud.textContent = faceInfo.onyx + ' \u2014 no gaps'; return; } var gFaces = data.gaps.map(function(g, i) { var statusColor = g.status === 'done' ? '\u2714' : g.status === 'deferred' ? '\u23F8' : '\u25CB'; return { onyx: g.onyx, title: statusColor + ' ' + (g.title || g.onyx), face_number: i + 1, sapphire: 'gap', subu_type: 'thing', id: g.id, preview: (g.status || 'open') + (g.sars ? ' \u00b7 SARS ' + g.sars : '') }; }); enterSubuLevel('thing', gFaces, faceInfo.onyx, 0x8B8B8B); }); }; var fidSmash = function() { DC.hud.textContent = 'Smashing ' + faceInfo.onyx + '...'; fetch('/api/worlds/dice/fid/' + faceInfo.fid_id) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.gaps || data.gaps.length === 0) { DC.hud.textContent = faceInfo.onyx + ' \u2014 no gaps'; return; } var items = data.gaps.map(function(g) { var statusIcon = g.status === 'done' ? '\u2714' : g.status === 'deferred' ? '\u23F8' : '\u25CB'; return { onyx: g.onyx, title: statusIcon + ' ' + (g.title || g.onyx), id: g.id, sapphire: 'gap', preview: (g.status || 'open') + (g.sars ? ' \u00b7 SARS ' + g.sars : '') }; }); DC.smashDie(die, items, faceInfo.onyx, 0x8B8B8B); }); }; var fidDieEnter = function() { var allFids = fidData.fids || []; var fidFaces = allFids.map(function(f, i) { var p = f.gaps > 0 ? Math.round(f.done / f.gaps * 100) : 0; return { onyx: f.onyx, title: f.onyx + ' (' + p + '%)', face_number: i + 1, subu_type: 'fid_entry', fid_id: f.id, fid_title: f.title || f.onyx, gap_total: f.gaps, gap_done: f.done, gap_active: f.active }; }); enterSubuLevel('fid_entry', fidFaces, 'All FIDs', 0x8B8B8B); }; var fidDieSmash = function() { var allFids = fidData.fids || []; var fidItems = allFids.map(function(f) { return { onyx: f.onyx, title: f.title || f.onyx, id: f.id, sapphire: 'fid' }; }); DC.smashGrouped(die, fidItems, 'All FIDs', 0x8B8B8B); }; DC.handleLanding(die, d.face, faceInfo.fid_title, fidSub, DC.actionsFaceDie(fidEnter, fidSmash, fidDieEnter, fidDieSmash)); DC.hud.textContent = 'Rolled: ' + faceInfo.onyx; return; } }

// ── Deck die → doors (face + die level enter/smash) ── if (die.cube && die.cube.subuRole === 'deck' && DC.phase === PHASE_HOME) { if (!faceInfo || faceInfo.subu_type === 'empty') return; if (faceInfo.subu_type === 'deck_type_entry') { var dType = faceInfo.deck_type; var dLabel = faceInfo.deck_label; var dColor = DECK_TYPE_COLORS[dType] || 0x707070; var faceName = faceInfo.onyx; var faceEnter = function() { var dg = DC.groupDecks(allDecks.filter(function(d) { return d.ruby && d.ruby.deck_type === dType; })); var items = dg.groups[faceName] || []; if (items.length === 0) { DC.hud.textContent = faceName + ' \u2014 empty'; return; } DC.phase = PHASE_DECK; currentDeck = dType; DC.clearDice(); bandLabel.style.color = '#' + ('000000' + dColor.toString(16)).slice(-6); bandLabel.textContent = faceName; bandLabel.style.display = 'block'; bandLabel.style.opacity = '1'; var spacing = Math.min(4, 20 / items.length), startX = -((items.length - 1) * spacing) / 2; items.forEach(function(item, idx) { DC.fetchSolid(item.schlafli[0], item.schlafli[1]).then(function(sd) { if (sd.error) return; var fC = {}; for (var fi = 0; fi < sd.face_indices.length; fi++) fC[fi] = DC.dimColor(dColor, 0.35 + (fi / sd.face_indices.length) * 0.3); DC.createDie(sd, startX + idx * spacing, 0, { onyx: item.onyx, title: item.title || item.onyx, faces: item.faces || [], ruby: item.ruby, is_deck: true, subuRole: 'deck_card' }, fC); }); }); DC.hud.textContent = faceName + ' \u00b7 ' + items.length + ' dice \u00b7 E = back'; }; var faceSmash = function() { var dg = DC.groupDecks(allDecks.filter(function(d) { return d.ruby && d.ruby.deck_type === dType; })); var items = dg.groups[faceName] || []; var faceCards = []; for (var pi = 0; pi < items.length; pi++) { var faces = items[pi].faces || []; for (var ci = 0; ci < faces.length; ci++) { if (faces[ci].onyx !== 'empty') faceCards.push(faces[ci]); } } if (faceCards.length === 0) { DC.hud.textContent = faceName + ' \u2014 empty'; return; } DC.smashGrouped(die, faceCards, faceName, dColor); }; var dieEnter = function() { enterDeckPhase(dType); }; var dieSmash = function() { var projs = allDecks.filter(function(d) { return d.ruby && d.ruby.deck_type === dType; }); var dg2 = DC.groupDecks(projs); var cx = die.group.position.x, cz = die.group.position.z; var radius = Math.max(2.5, dg2.order.length * 1.2); for (var gi = 0; gi < dg2.order.length; gi++) { var subName = dg2.order[gi]; var subProjs = dg2.groups[subName]; var subCards = []; for (var sp = 0; sp < subProjs.length; sp++) { var sf = subProjs[sp].faces || []; for (var sc = 0; sc < sf.length; sc++) { if (sf[sc].onyx !== 'empty') subCards.push(sf[sc]); } } if (subCards.length === 0) continue; var angle = (gi / dg2.order.length) * Math.PI * 2; var sx = cx + Math.cos(angle) * radius; var sz = cz + Math.sin(angle) * radius; (function(name, cards, x, z) { var pq = DC.solidForCount(cards.length); DC.fetchSolid(pq[0], pq[1]).then(function(sd) { if (sd.error) return; var tf = sd.face_indices.length; var fC = {}; for (var fi = 0; fi < tf; fi++) fC[fi] = DC.dimColor(dColor, 0.5 + (fi / tf) * 0.3); var padded = []; for (var fi2 = 0; fi2 < tf; fi2++) { padded.push(fi2 < cards.length ? { onyx: cards[fi2].onyx, title: cards[fi2].title || cards[fi2].onyx, face_number: fi2 + 1, id: cards[fi2].id, subu_type: 'thing', preview: cards[fi2].preview || '' } : { onyx: 'empty', title: '', face_number: fi2 + 1, subu_type: 'empty' }); } DC.createDie(sd, x, z, { onyx: name, title: name + ' (' + cards.length + ')', faces: padded, subuRole: 'smash', subuLevel: 'thing', parentDie: die, parentFace: die.landedFace }, fC, 0.7); }); })(subName, subCards, sx, sz); } DC.hud.textContent = dLabel + ' \u00b7 ' + dg2.order.length + ' sub-decks smashed'; }; DC.handleLanding(die, d.face, faceInfo.onyx, faceInfo.deck_total + ' cards \u00b7 ' + dLabel, DC.actionsFaceDie(faceEnter, faceSmash, dieEnter, dieSmash)); DC.hud.textContent = 'Rolled: ' + faceInfo.onyx; return; } }

// ── Deck card dice (inside deck phase) ── if (die.cube && die.cube.subuRole === 'deck_card' && DC.phase === PHASE_DECK) { if (faceInfo && faceInfo.onyx !== 'empty') { DC.handleLanding(die, d.face, faceInfo.title || faceInfo.onyx, faceInfo.preview || '', DC.actionsThing(faceInfo.id, faceInfo.title || faceInfo.onyx, null, null, die)); } DC.hud.textContent = (die.cube.onyx || 'Deck') + ' \u25B2 ' + (d.face + 1) + '/' + d.faces + (faceInfo ? ' \u2014 ' + faceInfo.onyx : '') + ' \u00b7 E = back'; return; }

// ── Pulse cycle die → show current cycle detail ── if (die.cube && die.cube.subuRole === 'pulse' && DC.phase === PHASE_HOME) { if (!faceInfo || faceInfo.subu_type === 'empty') return; if (faceInfo.subu_type === 'pulse_cycle' && faceInfo.cycle) { var cyc = faceInfo.cycle; var pct = Math.round(cyc.progress * 100); var sub = cyc.days_into_cycle + ' days in \u00b7 ' + cyc.days_to_next + ' days to next' + '\nPeriod: ' + cyc.period + ' days'; DC.handleLanding(die, d.face, cyc.symbol + ' ' + cyc.name + ' \u2014 ' + pct + '%', sub, false, null); DC.hud.textContent = cyc.symbol + ' ' + cyc.name + ' \u00b7 ' + pct + '% through cycle ' + cyc.current_cycle; return; } }

// ── Shelf die → show completed cycle count ── if (die.cube && die.cube.subuRole === 'shelf' && DC.phase === PHASE_HOME) { if (!faceInfo || faceInfo.subu_type === 'empty') return; if (faceInfo.subu_type === 'shelf_cycle' && faceInfo.cycle) { var cyc2 = faceInfo.cycle; var sub2 = cyc2.completed + ' of ' + cyc2.total_cycles + ' total cycles' + '\nPeriod: ' + cyc2.period + ' days'; DC.handleLanding(die, d.face, cyc2.symbol + ' ' + cyc2.name + ' \u2014 \u00d7' + cyc2.completed, sub2, false, null); DC.hud.textContent = cyc2.symbol + ' ' + cyc2.name + ' \u00b7 ' + cyc2.completed + ' cycles completed'; return; } }

// ── HexCube die → doors ── if (die.cube && die.cube.subuRole === 'hexcube' && DC.phase === PHASE_HOME) { if (!faceInfo || faceInfo.subu_type === 'empty') return; if (faceInfo.subu_type === 'hc_realm') { var realmName = ['Earth','Air','Fire'][faceInfo.hc_element]; DC.handleLanding(die, d.face, HC_ELEMENTS[faceInfo.hc_element] + ' ' + realmName + ' Realm', 'dual dice within', DC.actionsEnter(function() { enterHexcubeLevel(1, realmName, '' + (faceInfo.hc_element - 1)); })); DC.hud.textContent = 'Rolled: ' + realmName + ' Realm'; return; } }

// HexCube dual dice — wall or direction die settled if (DC.phase === PHASE_HEXCUBE && die.cube && (die.cube.subuRole === 'hc_wall' || die.cube.subuRole === 'hc_dir')) { if (!faceInfo || faceInfo.subu_type === 'empty') return; if (die.cube.subuRole === 'hc_wall') { hcWallResult = faceInfo.hc_element; DC.hud.textContent = 'Wall: ' + ['Earth','Air','Fire'][hcWallResult] + (hcDirResult >= 0 ? '' : ' \u00b7 waiting for direction...'); } else { hcDirResult = faceInfo.hc_element; DC.hud.textContent = 'Dir: ' + ['Earth','Air','Fire'][hcDirResult] + (hcWallResult >= 0 ? '' : ' \u00b7 waiting for wall...'); } checkHexcubeDualDice(); return; }

// ── Alignment dice — dual axis settlement ── if (DC.phase === PHASE_HOME && die.cube && die.cube.subuRole === 'alignment') { if (!faceInfo || faceInfo.subu_type === 'empty') return; if (faceInfo.subu_type === 'align_value') { DC.alignResult[faceInfo.align_axis] = faceInfo.align_value; var axLabel = ALIGN_AXIS[faceInfo.align_axis].label; var valLabel = ALIGN_AXIS[faceInfo.align_axis].values[faceInfo.align_value]; var otherDone = DC.alignResult[1 - faceInfo.align_axis] >= 0; DC.hud.textContent = axLabel + ': ' + valLabel + (otherDone ? '' : ' \u00b7 waiting for ' + ALIGN_AXIS[1 - faceInfo.align_axis].label + '...'); checkAlignmentDice(); return; } }

// ── Gem die / Popped die → show gem facets ── var isGemDie = (DC.phase === PHASE_GEM && die.cube && die.cube.subuRole === 'gem'); var isPoppedDie = (die.cube && die.cube.subuRole === 'popped'); if (isGemDie || isPoppedDie) { if (!faceInfo || faceInfo.subu_type === 'empty') return; if (faceInfo.subu_type === 'gem_face') { var gName = faceInfo.gem_name; var gLabel = faceInfo.gem_label; var gData = faceInfo.gem_data; var formatted = formatGemValue(gData); var subtitle = ''; if (formatted.indexOf('\n') >= 0) { var lines = formatted.split('\n'); for (var li = 0; li < Math.min(lines.length, 8); li++) { subtitle += lines[li] + '\n'; } if (lines.length > 8) subtitle += '... (' + lines.length + ' facets)'; subtitle = subtitle.trim(); } else { subtitle = formatted; } var dieName = die.cube.onyx || (isPoppedDie ? die.cube.poppedThingId : 'gem'); DC.handleLanding(die, d.face, gName + ' \u2014 ' + gLabel, subtitle, false, null); DC.hud.textContent = dieName + ' \u00b7 ' + gName + ' (' + gLabel + ')'; return; } }

// ── Normal gnosis landing ── var label = d.cube || d.solid; var odds = (100/d.faces).toFixed(1)+'%'; var card = d.card ? ' \u2014 '+d.card : ''; var back = (DC.phase !== PHASE_HOME) ? ' \u00b7 E = back' : ''; DC.hud.textContent = label+' \u25B2 '+(d.face+1)+'/'+d.faces+' ('+odds+')'+card+' \u00b7 \u25BC face '+(d.sacredFace+1)+back;

// Show card for gnosis band dice — gaze inspects gems if (DC.phase === PHASE_BAND && faceInfo && faceInfo.onyx !== 'empty') { DC.handleLanding(die, d.face, faceInfo.title || faceInfo.onyx, faceInfo.preview || '', DC.actionsThing(faceInfo.id, faceInfo.title || faceInfo.onyx, null, null, die)); } });

// ============================================================ // Load State // ============================================================

fetch('/api/worlds/dice/state') .then(function(r) { return r.json(); }) .then(function(state) { if (state.config) { if (state.config.physics) Object.assign(DC.CFG.physics, state.config.physics); if (state.config.field) Object.assign(DC.CFG.field, state.config.field); if (state.config.camera) Object.assign(DC.CFG.camera, state.config.camera); } var cubes = state.cubes || []; allDecks = state.decks || []; subuData = state.subu || null; hexcubeData = state.hexcube || null; grimoireData = state.grimoire || null; fidData = state.fid || null; pulseData = state.pulse || null;

scaleCubeData = null; allCubes = []; for (var i = 0; i < cubes.length; i++) { if (cubes[i].ruby && cubes[i].ruby.type === 'scale-index') scaleCubeData = cubes[i]; else allCubes.push(cubes[i]); }

if (!scaleCubeData && !subuData && !hexcubeData) { showAllDice(cubes); return; }

if (scaleCubeData) { DC.fetchSolid(scaleCubeData.schlafli[0], scaleCubeData.schlafli[1]) .then(function(sd) { if (!sd.error) { scaleCubeSolid = sd; } enterHomePhase(); }) .catch(function() { enterHomePhase(); }); } else { enterHomePhase(); } }) .catch(function(e) { console.warn('State failed:', e); loadBareSolids(); });

function showAllDice(cubes) { if (cubes.length === 0) { loadBareSolids(); return; } var sp = Math.min(4, 20/cubes.length), sx = -((cubes.length-1)*sp)/2; cubes.forEach(function(c, i) { DC.fetchSolid(c.schlafli[0], c.schlafli[1]) .then(function(sd){ if(!sd.error) DC.createDie(sd, sx+i*sp, 0, c); }); }); DC.hud.textContent = cubes.length + ' dice \u00b7 Click to roll'; }

function loadBareSolids() { [{p:3,q:3,x:-6},{p:4,q:3,x:-3},{p:3,q:4,x:0},{p:5,q:3,x:3},{p:3,q:5,x:6}].forEach(function(s) { DC.fetchSolid(s.p, s.q).then(function(d){ if(!d.error) DC.createDie(d, s.x, 0, null); }); }); DC.hud.textContent = '5 solids \u00b7 Click to roll'; }

// Re-render glyph textures once TypeKit fonts are loaded document.fonts.ready.then(function() { DC.resetGlyphCache(); });

// ── Render loop ── function animate() { animId = requestAnimationFrame(animate); var dt = Math.min(clock.getDelta(), 0.05); DC.physicsTick(dt); DC.tickFaceWisps(dt); DC.tickBeams(dt); DC.tickLinks(); if (controls) controls.update(); renderer.render(scene, camera); } animate();

// ── Cleanup ── window.__diceCleanup = function() { cancelAnimationFrame(animId); if (DC.physicsCleanup) DC.physicsCleanup(); DC.hideAllCards(); var setPanel = document.getElementById('dice-set-panel'); if (setPanel) setPanel.parentNode.removeChild(setPanel); [DC.hud, DC.hoverEl, bandLabel].forEach(function(el) { if (el && el.parentNode) el.parentNode.removeChild(el); }); for (var d = 0; d < dice.length; d++) { DC.disposeWisps(dice[d]); scene.remove(dice[d].group); } scene.remove(slab); scene.remove(ring); scene.remove(ambient); scene.remove(dirLight); scene.remove(pointLight); dice.length = 0; renderer.dispose(); if (controls) controls.dispose(); container.innerHTML = ''; };

// ── Exports ── DC.enterHomePhase = enterHomePhase; DC.spawnColoredDie = spawnColoredDie; DC.spawnPackedDice = spawnPackedDice; DC.enterBandPhase = enterBandPhase; DC.enterSubuLevel = enterSubuLevel; DC.enterHexcubeLevel = enterHexcubeLevel; DC.enterDeckPhase = enterDeckPhase; DC.enterGemPhase = enterGemPhase; DC.checkHexcubeDualDice = checkHexcubeDualDice; DC.checkAlignmentDice = checkAlignmentDice; DC.toggleDiceSet = toggleDiceSet; DC.buildSetToggleUI = buildSetToggleUI; DC.formatGemValue = formatGemValue; DC.linkifyUUIDs = linkifyUUIDs; DC.buildHexcubeGrid = buildHexcubeGrid;

console.log('dc-state: Dice World state & render loop initialized'); })();