diff --git a/activities/triangulon-invasion/activity.config.js b/activities/triangulon-invasion/activity.config.js
new file mode 100644
index 00000000..b5d83397
--- /dev/null
+++ b/activities/triangulon-invasion/activity.config.js
@@ -0,0 +1,9 @@
+export default {
+ id: 'triangulon-invasion',
+ name: 'Triangulon Invasion',
+ description: 'Collaborative recursive subdivision game with Sierpinski-style triangles.',
+ color: 'indigo',
+ soloMode: false,
+ clientEntry: './client/index.jsx',
+ serverEntry: './server/routes.js',
+};
diff --git a/activities/triangulon-invasion/client/index.jsx b/activities/triangulon-invasion/client/index.jsx
new file mode 100644
index 00000000..36c74306
--- /dev/null
+++ b/activities/triangulon-invasion/client/index.jsx
@@ -0,0 +1,10 @@
+import TriangulonManager from './manager/Manager.jsx';
+import TriangulonStudent from './student/Student.jsx';
+
+const activity = {
+ ManagerComponent: TriangulonManager,
+ StudentComponent: TriangulonStudent,
+ footerContent: null,
+};
+
+export default activity;
diff --git a/activities/triangulon-invasion/client/manager/Manager.jsx b/activities/triangulon-invasion/client/manager/Manager.jsx
new file mode 100644
index 00000000..da0551f0
--- /dev/null
+++ b/activities/triangulon-invasion/client/manager/Manager.jsx
@@ -0,0 +1,251 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import Button from '@src/components/ui/Button';
+import { useResilientWebSocket } from '@src/hooks/useResilientWebSocket';
+import { useClipboard } from '@src/hooks/useClipboard';
+import '../student/triangulon.css';
+
+export default function TriangulonManager() {
+ const { sessionId } = useParams();
+ const [stage, setStage] = useState('training');
+ const [events, setEvents] = useState([]);
+ const [status, setStatus] = useState('disconnected');
+ const [activeTab, setActiveTab] = useState('map'); // 'map' | 'leaderboard'
+ const [specialWinner, setSpecialWinner] = useState(null);
+
+ const buildWsUrl = useCallback(() => {
+ if (!sessionId) return null;
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ return `${protocol}//${window.location.host}/ws/triangulon-invasion?sessionId=${sessionId}`;
+ }, [sessionId]);
+
+ const { connect, disconnect, socketRef } = useResilientWebSocket({
+ buildUrl: buildWsUrl,
+ shouldReconnect: Boolean(sessionId),
+ onOpen: () => setStatus('connected'),
+ onClose: () => setStatus('disconnected'),
+ onMessage: (event) => {
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.type === 'state') {
+ setStage(msg.stage || 'training');
+ setEvents(msg.events || []);
+ } else if (msg.type === 'events' && Array.isArray(msg.events)) {
+ setEvents((prev) => [...prev, ...msg.events]);
+ } else if (msg.type === 'special-winner' && msg.winner) {
+ setSpecialWinner(msg.winner);
+ }
+ } catch {
+ // ignore
+ }
+ },
+ });
+
+ const { copyToClipboard, isCopied } = useClipboard();
+
+ const handleJoinCodeClick = useCallback((e) => {
+ if (!sessionId) return;
+ if (e.ctrlKey || e.metaKey) {
+ window.open(`/${sessionId}`, '_blank');
+ } else {
+ copyToClipboard(sessionId);
+ }
+ }, [sessionId, copyToClipboard]);
+
+ useEffect(() => {
+ if (!sessionId) return undefined;
+ const ws = connect();
+ return () => {
+ disconnect();
+ if (ws && ws.readyState === 1) ws.close();
+ };
+ }, [sessionId, connect, disconnect]);
+
+ const send = useCallback((payload) => {
+ const ws = socketRef.current;
+ if (ws && ws.readyState === 1) {
+ ws.send(JSON.stringify(payload));
+ }
+ }, [socketRef]);
+
+ const advanceStage = useCallback((next) => {
+ send({ type: 'advance-stage', stage: next });
+ }, [send]);
+
+ const broadcastPing = useCallback(() => {
+ send({ type: 'manager-action', action: 'ping' });
+ }, [send]);
+
+ const recentEvents = useMemo(() => events.slice(-5).reverse(), [events]);
+
+ // Aggregate leaderboard by triangles made and memoize totals
+ const { leaderboard, totalTriangles } = useMemo(() => {
+ const counts = new Map();
+ let total = 0;
+
+ for (const evt of events) {
+ if (!evt) continue;
+ // Accept both planned and current stub event shapes
+ const isTriangleEvent = evt.type === 'triangle-made' || evt.type === 'triangle_made' || evt.type === 'subdivide';
+ if (!isTriangleEvent) continue;
+
+ total += 1;
+ if (evt.player) {
+ counts.set(evt.player, (counts.get(evt.player) || 0) + 1);
+ }
+ }
+
+ const rows = Array.from(counts.entries()).map(([player, triangles]) => ({ player, triangles }));
+ rows.sort((a, b) => b.triangles - a.triangles);
+ return { leaderboard: rows, totalTriangles: total };
+ }, [events]);
+
+ return (
+
+
+
+
+
+
Triangulon Sector
+
Instructor Dashboard
+
Manage stages, monitor map, and track leaders
+
+
+
+ {status === 'connected' ? 'Link Stable' : 'Link Lost'}
+ |
+
+
+
+
+
+
+ {/* Top Controls Panel */}
+
+
Mission Controls
+
+
+
+
+
Triangles
+
{totalTriangles}
+
+
+
+
+
+
+
+
+
+
+ {/* Central Map Panel with Tabs */}
+
+
+
+
+
+ {activeTab === 'map' ? (
+
Class-wide fractal map (coming soon)
+ ) : (
+
+
Leaderboard
+ {specialWinner && (
+
+ Special: {specialWinner.title} — {specialWinner.player || 'TBD'}
+
+ )}
+ {leaderboard.length === 0 ? (
+
No data yet.
+ ) : (
+
+ {leaderboard.map((row, i) => (
+ -
+ #{i + 1} — {row.player}
+ {row.triangles} triangles
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {/* Stats Panel */}
+
+
+
Recent Signal
+
{recentEvents[0]?.type || 'Awaiting'}
+
+
+
Events Logged
+
{events.length}
+
+
+
+
+
+ {/* Sidebar: Planned info */}
+
+
+
+
+ );
+}
diff --git a/activities/triangulon-invasion/client/student/Student.jsx b/activities/triangulon-invasion/client/student/Student.jsx
new file mode 100644
index 00000000..83ab3309
--- /dev/null
+++ b/activities/triangulon-invasion/client/student/Student.jsx
@@ -0,0 +1,133 @@
+import React, { useCallback, useEffect } from 'react';
+import { useSessionEndedHandler } from '@src/hooks/useSessionEndedHandler';
+import { useResilientWebSocket } from '@src/hooks/useResilientWebSocket';
+import Button from '@src/components/ui/Button';
+import TriangleNav from './TriangleNav';
+import './triangulon.css';
+
+export default function TriangulonStudent({ sessionData }) {
+ const attachSessionEndedHandler = useSessionEndedHandler();
+ const sessionId = sessionData?.sessionId;
+
+ const [state, setState] = React.useState({ stage: 'training', events: [] });
+ const [status, setStatus] = React.useState('connecting');
+
+ const buildWsUrl = useCallback(() => {
+ if (!sessionId) return null;
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ return `${protocol}//${window.location.host}/ws/triangulon-invasion?sessionId=${sessionId}`;
+ }, [sessionId]);
+
+ const { connect, disconnect } = useResilientWebSocket({
+ buildUrl: buildWsUrl,
+ shouldReconnect: Boolean(sessionId),
+ attachSessionEndedHandler,
+ onOpen: () => setStatus('connected'),
+ onClose: () => setStatus('disconnected'),
+ onMessage: (event) => {
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.type === 'state') {
+ setState({ stage: msg.stage, events: msg.events || [] });
+ } else if (msg.type === 'events' && Array.isArray(msg.events)) {
+ setState((prev) => ({ ...prev, events: [...prev.events, ...msg.events] }));
+ }
+ } catch {
+ // ignore
+ }
+ },
+ });
+
+ useEffect(() => {
+ if (!sessionId) return undefined;
+ const ws = connect();
+ return () => {
+ disconnect();
+ if (ws && ws.readyState === 1) {
+ ws.close();
+ }
+ };
+ }, [sessionId, connect, disconnect]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
Tap to subdivide sector
+
+
+
+
+
+
+
+
+
+
+
+
+
Recent Signals
+
{state.events.slice(-1)[0]?.type || 'Awaiting data'}
+
+
+
Events Logged
+
{state.events.length}
+
+
+
Stage
+
{state.stage}
+
+
+
+
+ );
+}
diff --git a/activities/triangulon-invasion/client/student/TriangleNav.jsx b/activities/triangulon-invasion/client/student/TriangleNav.jsx
new file mode 100644
index 00000000..b47cac37
--- /dev/null
+++ b/activities/triangulon-invasion/client/student/TriangleNav.jsx
@@ -0,0 +1,319 @@
+import React from 'react';
+
+export default function TriangleNav({ onNavigate, disabled = true, disabledButtons = {} }) {
+ const handleClick = (direction) => {
+ if (!disabled && onNavigate) {
+ onNavigate(direction);
+ }
+ };
+
+ // Colors from CSS variables
+ const glowColor = '#6ff0ff';
+ const accentColor = '#7df2c9';
+ const warnColor = '#ffb347';
+ const fillColor = 'rgba(111, 240, 255, 0.2)';
+ const fillColorCenter = 'rgba(255, 179, 71, 0.25)';
+
+ const size = 280;
+ const padding = 20;
+
+ // Center upright triangle (main focus)
+ const centerTop = { x: size / 2, y: padding };
+ const centerLeft = { x: padding, y: size - padding };
+ const centerRight = { x: size - padding, y: size - padding };
+
+ const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
+ const normalize = (v) => {
+ const len = Math.hypot(v.x, v.y);
+ if (len === 0) return { x: 0, y: 0 };
+ return { x: v.x / len, y: v.y / len };
+ };
+
+ // Helper to extend a line from a point through another point
+ const extendLine = (from, through, distance) => {
+ const dx = through.x - from.x;
+ const dy = through.y - from.y;
+ const len = Math.sqrt(dx * dx + dy * dy);
+ const nx = dx / len;
+ const ny = dy / len;
+ return {
+ x: through.x + nx * distance,
+ y: through.y + ny * distance
+ };
+ };
+
+ // Helper to shrink a point toward a center
+ const shrinkPoint = (point, center, factor) => ({
+ x: center.x + (point.x - center.x) * factor,
+ y: center.y + (point.y - center.y) * factor
+ });
+
+ // Geometry notes:
+ // - Treat the main triangle as equilateral-ish and derive all nav triangles from its edges.
+ // - guideLineLength is the extension distance used to project lines outward from edges/vertices.
+ // - Outer nav buttons use guide endpoints as bases, with outwardPoint nudging a third vertex along a normal.
+ const baseLength = distance(centerLeft, centerRight);
+ const halfSide = baseLength / 2;
+ const guideLineLength = baseLength / 4; // half of midpoint span
+ const dirLeftEdge = normalize({ x: centerLeft.x - centerTop.x, y: centerLeft.y - centerTop.y });
+ const dirRightEdge = normalize({ x: centerRight.x - centerTop.x, y: centerRight.y - centerTop.y });
+ const dirBase = normalize({ x: centerRight.x - centerLeft.x, y: centerRight.y - centerLeft.y });
+
+ // Guide endpoints (length = guideLineLength)
+ // Top: one leg along triangle edge, one horizontal through the tip
+ const topUpExtend = { x: centerTop.x, y: centerTop.y - guideLineLength };
+ const topLeftExtend = { x: centerTop.x - dirLeftEdge.x * guideLineLength, y: centerTop.y - dirLeftEdge.y * guideLineLength };
+ const topRightExtend = { x: centerTop.x - dirRightEdge.x * guideLineLength, y: centerTop.y - dirRightEdge.y * guideLineLength };
+ const topHorizLeft = { x: centerTop.x - guideLineLength, y: centerTop.y };
+ const topHorizRight = { x: centerTop.x + guideLineLength, y: centerTop.y };
+
+ // Bottom: horizontal base extension and angled extensions continuing the sides
+ const leftBaseExtend = { x: centerLeft.x - dirBase.x * guideLineLength, y: centerLeft.y - dirBase.y * guideLineLength };
+ const rightBaseExtend = { x: centerRight.x + dirBase.x * guideLineLength, y: centerRight.y + dirBase.y * guideLineLength };
+ const leftDownExtend = { x: centerLeft.x + dirLeftEdge.x * guideLineLength, y: centerLeft.y + dirLeftEdge.y * guideLineLength };
+ const rightDownExtend = { x: centerRight.x + dirRightEdge.x * guideLineLength, y: centerRight.y + dirRightEdge.y * guideLineLength };
+
+ // Larger outer navigation triangles constructed from guide endpoints
+ const midpoint = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
+ const outwardPoint = (a, b, outwardDir, factor = 0.6) => {
+ const mid = midpoint(a, b);
+ const n = normalize(outwardDir);
+ return { x: mid.x + n.x * guideLineLength * factor, y: mid.y + n.y * guideLineLength * factor };
+ };
+
+ // Up-left: edge-aligned point and horizontal-left point; outward normal goes up-left
+ const upLeftBaseA = topLeftExtend;
+ const upLeftBaseB = topHorizLeft;
+ const upLeftNormal = normalize({ x: -1, y: -1 });
+ const upLeftThird = outwardPoint(upLeftBaseA, upLeftBaseB, upLeftNormal);
+
+ // Up-right: edge-aligned and horizontal-right; outward up-right
+ const upRightBaseA = topHorizRight;
+ const upRightBaseB = topRightExtend;
+ const upRightNormal = normalize({ x: 1, y: -1 });
+ const upRightThird = outwardPoint(upRightBaseA, upRightBaseB, upRightNormal);
+
+ // Down-left: base extension and angled extension; outward down-left
+ const downLeftBaseA = leftBaseExtend;
+ const downLeftBaseB = leftDownExtend;
+ const downLeftNormal = normalize({ x: -1, y: 1 });
+ const downLeftThird = outwardPoint(downLeftBaseA, downLeftBaseB, downLeftNormal);
+
+ // Down-right: base extension and angled extension; outward down-right
+ const downRightBaseA = rightDownExtend;
+ const downRightBaseB = rightBaseExtend;
+ const downRightNormal = normalize({ x: 1, y: 1 });
+ const downRightThird = outwardPoint(downRightBaseA, downRightBaseB, downRightNormal);
+
+ const navButtons = [
+ {
+ id: 'up-left',
+ points: `${upLeftBaseA.x},${upLeftBaseA.y} ${upLeftBaseB.x},${upLeftBaseB.y} ${upLeftThird.x},${upLeftThird.y}`
+ },
+ {
+ id: 'up-right',
+ points: `${upRightBaseA.x},${upRightBaseA.y} ${upRightBaseB.x},${upRightBaseB.y} ${upRightThird.x},${upRightThird.y}`
+ },
+ {
+ id: 'down-left',
+ points: `${downLeftBaseA.x},${downLeftBaseA.y} ${downLeftBaseB.x},${downLeftBaseB.y} ${downLeftThird.x},${downLeftThird.y}`
+ },
+ {
+ id: 'down-right',
+ points: `${downRightBaseA.x},${downRightBaseA.y} ${downRightBaseB.x},${downRightBaseB.y} ${downRightThird.x},${downRightThird.y}`
+ }
+ ];
+
+ // Subdivision midpoints of center triangle
+ const centerSubMidLeft = { x: (centerTop.x + centerLeft.x) / 2, y: (centerTop.y + centerLeft.y) / 2 };
+ const centerSubMidRight = { x: (centerTop.x + centerRight.x) / 2, y: (centerTop.y + centerRight.y) / 2 };
+ const centerSubMidBottom = { x: (centerLeft.x + centerRight.x) / 2, y: centerLeft.y };
+
+ // Inner triangles with subdivision indicators
+ const innerTriangles = [
+ {
+ id: 'top',
+ points: `${centerTop.x},${centerTop.y} ${centerSubMidLeft.x},${centerSubMidLeft.y} ${centerSubMidRight.x},${centerSubMidRight.y}`,
+ subTriangle: (() => {
+ const p1 = centerTop;
+ const p2 = centerSubMidLeft;
+ const p3 = centerSubMidRight;
+ const m1 = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
+ const m2 = { x: (p2.x + p3.x) / 2, y: (p2.y + p3.y) / 2 };
+ const m3 = { x: (p1.x + p3.x) / 2, y: (p1.y + p3.y) / 2 };
+ return `${m2.x},${m2.y} ${m1.x},${m1.y} ${m3.x},${m3.y}`;
+ })()
+ },
+ {
+ id: 'left',
+ points: `${centerLeft.x},${centerLeft.y} ${centerSubMidBottom.x},${centerSubMidBottom.y} ${centerSubMidLeft.x},${centerSubMidLeft.y}`,
+ subTriangle: (() => {
+ const p1 = centerLeft;
+ const p2 = centerSubMidBottom;
+ const p3 = centerSubMidLeft;
+ const m1 = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
+ const m2 = { x: (p2.x + p3.x) / 2, y: (p2.y + p3.y) / 2 };
+ const m3 = { x: (p1.x + p3.x) / 2, y: (p1.y + p3.y) / 2 };
+ return `${m2.x},${m2.y} ${m1.x},${m1.y} ${m3.x},${m3.y}`;
+ })()
+ },
+ {
+ id: 'right',
+ points: `${centerRight.x},${centerRight.y} ${centerSubMidRight.x},${centerSubMidRight.y} ${centerSubMidBottom.x},${centerSubMidBottom.y}`,
+ subTriangle: (() => {
+ const p1 = centerRight;
+ const p2 = centerSubMidRight;
+ const p3 = centerSubMidBottom;
+ const m1 = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
+ const m2 = { x: (p2.x + p3.x) / 2, y: (p2.y + p3.y) / 2 };
+ const m3 = { x: (p1.x + p3.x) / 2, y: (p1.y + p3.y) / 2 };
+ return `${m2.x},${m2.y} ${m1.x},${m1.y} ${m3.x},${m3.y}`;
+ })()
+ }
+ ];
+
+ const shrinkFactor = 0.65;
+ const outerCenter = { x: (centerSubMidLeft.x + centerSubMidRight.x + centerSubMidBottom.x) / 3, y: (centerSubMidLeft.y + centerSubMidRight.y + centerSubMidBottom.y) / 3 };
+ const innerP1 = shrinkPoint(centerSubMidBottom, outerCenter, shrinkFactor);
+ const innerP2 = shrinkPoint(centerSubMidLeft, outerCenter, shrinkFactor);
+ const innerP3 = shrinkPoint(centerSubMidRight, outerCenter, shrinkFactor);
+
+ const innerInnerFactor = 0.5;
+ const innerInnerP1 = shrinkPoint(innerP1, outerCenter, innerInnerFactor);
+ const innerInnerP2 = shrinkPoint(innerP2, outerCenter, innerInnerFactor);
+ const innerInnerP3 = shrinkPoint(innerP3, outerCenter, innerInnerFactor);
+
+ const viewPad = halfSide;
+
+ return (
+
+ );
+}
diff --git a/activities/triangulon-invasion/client/student/triangulon.css b/activities/triangulon-invasion/client/student/triangulon.css
new file mode 100644
index 00000000..6124f382
--- /dev/null
+++ b/activities/triangulon-invasion/client/student/triangulon.css
@@ -0,0 +1,384 @@
+:root {
+ --tri-bg: #05060d;
+ --tri-panel: rgba(13, 17, 30, 0.8);
+ --tri-border: rgba(120, 197, 255, 0.5);
+ --tri-glow: #6ff0ff;
+ --tri-accent: #7df2c9;
+ --tri-warn: #ffb347;
+ --tri-text: #e8f1ff;
+ --tri-muted: #8aa3c2;
+ --tri-grid: rgba(111, 240, 255, 0.08);
+}
+
+html, body {
+ background: radial-gradient(120% 120% at 20% 10%, rgba(125, 242, 201, 0.15), transparent),
+ radial-gradient(120% 120% at 80% 0%, rgba(111, 240, 255, 0.18), transparent),
+ radial-gradient(80% 80% at 50% 90%, rgba(255, 105, 180, 0.08), transparent),
+ var(--tri-bg);
+ background-attachment: fixed;
+ background-repeat: no-repeat;
+ background-size: cover;
+ color: var(--tri-text);
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ width: 100%;
+}
+
+.triangulon-shell {
+ position: fixed;
+ inset: 0;
+ background: radial-gradient(120% 120% at 20% 10%, rgba(125, 242, 201, 0.15), transparent),
+ radial-gradient(120% 120% at 80% 0%, rgba(111, 240, 255, 0.18), transparent),
+ radial-gradient(80% 80% at 50% 90%, rgba(255, 105, 180, 0.08), transparent),
+ var(--tri-bg);
+ color: var(--tri-text);
+ padding: 28px 18px;
+ overflow: auto;
+ font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
+}
+
+.triangulon-grid {
+ position: absolute;
+ inset: 0;
+ background-image:
+ linear-gradient(60deg, var(--tri-grid) 1px, transparent 1px),
+ linear-gradient(-60deg, var(--tri-grid) 1px, transparent 1px),
+ linear-gradient(0deg, rgba(111, 240, 255, 0.03) 1px, transparent 1px);
+ background-size: 80px 80px, 80px 80px, 60px 60px;
+ pointer-events: none;
+ mask-image: radial-gradient(70% 70% at 50% 40%, rgba(0, 0, 0, 0.8), transparent 90%);
+}
+
+.triangulon-frame {
+ position: relative;
+ max-width: 1200px;
+ margin: 0 auto;
+ background: linear-gradient(135deg, rgba(20, 26, 44, 0.85), rgba(9, 12, 22, 0.9));
+ border: 1px solid var(--tri-border);
+ box-shadow: 0 0 30px rgba(111, 240, 255, 0.18), 0 0 0 1px rgba(125, 242, 201, 0.12) inset;
+ border-radius: 18px;
+ padding: 20px;
+ backdrop-filter: blur(4px);
+}
+
+.triangulon-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: flex-start;
+ padding: 12px 14px;
+ border: 1px solid var(--tri-border);
+ border-radius: 12px;
+ background: linear-gradient(90deg, rgba(111, 240, 255, 0.12), rgba(125, 242, 201, 0.04));
+ box-shadow: 0 0 0 1px rgba(111, 240, 255, 0.08) inset;
+}
+
+.triangulon-header h1 {
+ margin: 2px 0 0;
+ font-size: 1.6rem;
+ letter-spacing: 0.02em;
+}
+
+.triangulon-kicker {
+ margin: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.18em;
+ font-size: 0.78rem;
+ color: var(--tri-accent);
+}
+
+.triangulon-sub {
+ margin: 4px 0 0;
+ color: var(--tri-muted);
+ font-size: 0.95rem;
+}
+
+.triangulon-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: rgba(8, 15, 26, 0.7);
+ border: 1px solid var(--tri-border);
+ border-radius: 999px;
+ font-size: 0.9rem;
+ color: var(--tri-text);
+}
+
+.dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ display: inline-block;
+ box-shadow: 0 0 8px currentColor;
+}
+
+.dot.ok { color: var(--tri-accent); }
+.dot.warn { color: var(--tri-warn); }
+
+.triangulon-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
+ gap: 16px;
+ margin-top: 16px;
+}
+
+.triangulon-main {
+ border: 1px solid var(--tri-border);
+ border-radius: 14px;
+ background: linear-gradient(135deg, rgba(17, 23, 38, 0.75), rgba(8, 12, 22, 0.9));
+ box-shadow: 0 0 18px rgba(111, 240, 255, 0.08) inset;
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.triangulon-main-hud {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+ padding: 10px;
+ border: 1px solid rgba(111, 240, 255, 0.22);
+ border-radius: 10px;
+ background: rgba(10, 15, 26, 0.7);
+}
+
+.triangulon-main-hud .label {
+ margin: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-size: 0.75rem;
+ color: var(--tri-muted);
+}
+
+.triangulon-main-hud .value {
+ margin: 2px 0 0;
+ font-size: 1.1rem;
+}
+
+.triangulon-canvas {
+ position: relative;
+ height: 340px;
+ border-radius: 12px;
+ border: 1px solid var(--tri-border);
+ background: radial-gradient(65% 70% at 50% 40%, rgba(111, 240, 255, 0.18), rgba(8, 12, 22, 0.9)),
+ repeating-linear-gradient(60deg, rgba(125, 242, 201, 0.05), rgba(125, 242, 201, 0.05) 6px, transparent 6px, transparent 16px),
+ repeating-linear-gradient(-60deg, rgba(111, 240, 255, 0.05), rgba(111, 240, 255, 0.05) 6px, transparent 6px, transparent 16px),
+ rgba(7, 10, 19, 0.9);
+ overflow: hidden;
+}
+
+.triangulon-canvas::before {
+ content: '';
+ position: absolute;
+ inset: 16px 20%;
+ background: linear-gradient(135deg, rgba(111, 240, 255, 0.25), rgba(125, 242, 201, 0.1));
+ clip-path: polygon(50% 0%, 100% 100%, 0% 100%);
+ filter: drop-shadow(0 0 12px rgba(111, 240, 255, 0.4));
+ opacity: 0.8;
+}
+
+.triangulon-canvas::after {
+ content: '';
+ position: absolute;
+ inset: 40px 28%;
+ border: 1px dashed rgba(111, 240, 255, 0.5);
+ clip-path: polygon(50% 0%, 100% 100%, 0% 100%);
+ opacity: 0.6;
+}
+
+.triangulon-canvas-overlay {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-size: 0.9rem;
+ color: var(--tri-text);
+ background: linear-gradient(transparent 65%, rgba(5, 6, 13, 0.8));
+}
+
+.triangulon-actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+}
+
+.triangulon-sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.triangulon-panel {
+ border: 1px solid var(--tri-border);
+ border-radius: 12px;
+ background: rgba(10, 14, 24, 0.85);
+ box-shadow: 0 0 12px rgba(111, 240, 255, 0.07) inset;
+ padding: 12px;
+}
+
+.panel-header {
+ font-size: 0.95rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--tri-accent);
+ margin-bottom: 10px;
+}
+
+.panel-note {
+ margin: 0;
+ color: var(--tri-muted);
+ font-size: 0.9rem;
+}
+
+.mini-map {
+ position: relative;
+ height: 180px;
+ border: 1px solid var(--tri-border);
+ border-radius: 10px;
+ background: radial-gradient(60% 60% at 50% 50%, rgba(111, 240, 255, 0.12), rgba(7, 10, 19, 0.95));
+ overflow: hidden;
+}
+
+.mini-map::before,
+.mini-map::after {
+ content: '';
+ position: absolute;
+ inset: 12px;
+ border: 1px dashed rgba(111, 240, 255, 0.35);
+ clip-path: polygon(50% 0%, 100% 100%, 0% 100%);
+}
+
+.mini-map::after {
+ inset: 36px;
+ opacity: 0.5;
+}
+
+.mini-spark {
+ position: absolute;
+ width: 14px;
+ height: 14px;
+ background: var(--tri-accent);
+ border-radius: 2px;
+ filter: drop-shadow(0 0 8px rgba(125, 242, 201, 0.8));
+ top: 50%;
+ left: 48%;
+ transform: translate(-50%, -50%) rotate(45deg);
+}
+
+.mini-controls {
+ margin-top: 10px;
+}
+
+.triangle-nav-svg .tri-nav-btn {
+ transition: transform 0.15s ease, filter 0.15s ease, fill 0.15s ease, opacity 0.15s ease;
+ transform-box: fill-box;
+ transform-origin: center;
+}
+
+.triangle-nav-svg .tri-nav-btn:not(.disabled):hover {
+ filter: drop-shadow(0 0 12px rgba(125, 242, 201, 0.7));
+ transform: scale(1.05);
+ fill: rgba(125, 242, 201, 0.45);
+}
+
+.triangle-nav-svg .tri-nav-group:hover .tri-nav-btn:not(.disabled) {
+ filter: drop-shadow(0 0 14px rgba(125, 242, 201, 0.8));
+ transform: scale(1.05);
+}
+
+.triangle-nav-svg .tri-nav-group:hover .tri-nav-inset {
+ stroke: #c7fff6;
+ opacity: 1;
+}
+
+.triangle-nav-svg .tri-nav-group:hover .tri-nav-subfill {
+ opacity: 0.95;
+ filter: drop-shadow(0 0 10px rgba(125, 242, 201, 0.6));
+}
+
+.triangle-nav-svg .tri-nav-group:hover .tri-nav-btn:not(.disabled) + .tri-nav-subfill {
+ opacity: 1;
+}
+
+.triangle-nav-svg .tri-nav-btn.disabled {
+ cursor: default;
+ filter: none;
+}
+
+.triangle-nav-svg .tri-nav-btn:not(.disabled):active {
+ transform: scale(0.95);
+}
+
+.diamond-nav:hover:not(:disabled) {
+ background: rgba(125, 242, 201, 0.25);
+ box-shadow: 0 0 12px rgba(125, 242, 201, 0.7), inset 0 0 8px rgba(125, 242, 201, 0.3);
+ transform: scale(1.05);
+}
+
+.diamond-nav:active:not(:disabled) {
+ transform: scale(0.98);
+}
+
+.diamond-nav:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.diamond-up {
+ grid-column: 2;
+ grid-row: 1;
+}
+
+.diamond-left {
+ grid-column: 1;
+ grid-row: 2;
+}
+
+.diamond-right {
+ grid-column: 3;
+ grid-row: 2;
+}
+
+.diamond-down {
+ grid-column: 2;
+ grid-row: 3;
+}
+
+.triangulon-stats {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+ margin-top: 14px;
+ padding: 10px;
+ border: 1px solid rgba(111, 240, 255, 0.2);
+ border-radius: 10px;
+ background: rgba(8, 11, 20, 0.7);
+}
+
+.triangulon-stats .label {
+ margin: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.09em;
+ font-size: 0.75rem;
+ color: var(--tri-muted);
+}
+
+.triangulon-stats .value {
+ margin: 2px 0 0;
+ font-size: 1.05rem;
+}
+
+@media (max-width: 980px) {
+ .triangulon-layout {
+ grid-template-columns: 1fr;
+ }
+ .triangulon-shell {
+ padding: 18px 12px 28px;
+ }
+}
diff --git a/activities/triangulon-invasion/server/fractalStore.js b/activities/triangulon-invasion/server/fractalStore.js
new file mode 100644
index 00000000..5572a562
--- /dev/null
+++ b/activities/triangulon-invasion/server/fractalStore.js
@@ -0,0 +1,124 @@
+// Fractal data helpers for Triangulon Invasion (activity-local, not global server)
+// Index scheme: ternary heap. Root = 0. Children of i => 3*i + 1, 3*i + 2, 3*i + 3. Parent of i>0 => Math.floor((i - 1) / 3).
+
+/**
+ * Compute the parent index in the ternary heap for a given node index.
+ * Returns null for the root node.
+ * @param {number} index - Heap index (0-based).
+ * @returns {number|null} Parent index or null when at root.
+ */
+function getParentIndex(index) {
+ if (index <= 0) return null;
+ return Math.floor((index - 1) / 3);
+}
+
+/**
+ * Compute the three child indices for a given node index in the ternary heap.
+ * @param {number} index - Heap index (0-based).
+ * @returns {[number, number, number]} Child indices ordered left-to-right.
+ */
+function getChildrenIndices(index) {
+ const base = 3 * index + 1;
+ return [base, base + 1, base + 2];
+}
+
+/**
+ * Compute the depth (0-based) of a node index in the ternary heap.
+ * Uses closed form: depth = floor(log_3(2 * index + 1)).
+ * @param {number} index - Heap index (0-based).
+ * @returns {number} Depth of the node.
+ * @throws {Error} When index is negative.
+ */
+function getDepth(index) {
+ if (index < 0) throw new Error('Index must be non-negative');
+ if (index === 0) return 0;
+ // Closed form: depth = floor(log_3(2 * index + 1))
+ return Math.floor(Math.log(2 * index + 1) / Math.log(3));
+}
+
+class FractalStore {
+ constructor() {
+ // Sparse map: key = index, value = node data { owner, createdAt, meta }
+ this.nodes = new Map();
+ }
+
+ /**
+ * Check if a node exists at the given index.
+ * @param {number} index - Heap index to query.
+ * @returns {boolean} True when the node is present.
+ */
+ has(index) {
+ return this.nodes.has(index);
+ }
+
+ /**
+ * Retrieve a node at the given index.
+ * @param {number} index - Heap index to query.
+ * @returns {{owner: string|null, createdAt: number, meta: object}|null} Node data or null if absent.
+ */
+ get(index) {
+ return this.nodes.get(index) || null;
+ }
+
+ /**
+ * Create or claim a node at the given index. Parent must exist unless index is 0.
+ * @param {number} index - Heap index to create.
+ * @param {{ owner?: string|null, createdAt?: number, meta?: object }} data - Node metadata.
+ * @returns {{owner: string|null, createdAt: number, meta: object}} The stored node.
+ * @throws {Error} When index is negative or parent is missing.
+ */
+ addNode(index, data) {
+ if (index < 0) throw new Error('Index must be non-negative');
+ if (index !== 0 && !this.nodes.has(getParentIndex(index))) {
+ throw new Error('Parent must exist before creating a child');
+ }
+ const node = {
+ owner: data.owner || null,
+ createdAt: data.createdAt || Date.now(),
+ meta: data.meta || {},
+ };
+ this.nodes.set(index, node);
+ return node;
+ }
+
+ /**
+ * Return sibling indices for a node. Optionally filters to existing nodes only.
+ * @param {number} index - Heap index whose siblings to fetch.
+ * @param {{ filterExisting?: boolean }} options - Filter flag.
+ * @returns {number[]} Sibling indices.
+ */
+ getSiblings(index, { filterExisting = true } = {}) {
+ const parent = getParentIndex(index);
+ if (parent === null) return [];
+ const sibs = getChildrenIndices(parent);
+ if (filterExisting) return sibs.filter((i) => this.nodes.has(i));
+ return sibs;
+ }
+
+ /**
+ * Serialize the store to a compact payload.
+ * @returns {Array<[number, { owner: string|null, createdAt: number, meta: object }]>} Entries payload.
+ */
+ toPayload() {
+ return Array.from(this.nodes.entries());
+ }
+
+ /**
+ * Rehydrate a store from a serialized payload.
+ * @param {Array<[number, { owner: string|null, createdAt: number, meta: object }]>} payload - Serialized entries.
+ * @returns {FractalStore} New store instance populated with entries.
+ */
+ static fromPayload(payload) {
+ const store = new FractalStore();
+ for (const [idx, node] of payload || []) {
+ store.nodes.set(Number(idx), node);
+ }
+ return store;
+ }
+}
+export {
+ FractalStore,
+ getParentIndex,
+ getChildrenIndices,
+ getDepth,
+};
diff --git a/activities/triangulon-invasion/server/routes.js b/activities/triangulon-invasion/server/routes.js
new file mode 100644
index 00000000..b6c42476
--- /dev/null
+++ b/activities/triangulon-invasion/server/routes.js
@@ -0,0 +1,179 @@
+import { createSession } from 'activebits-server/core/sessions.js';
+import { registerSessionNormalizer } from 'activebits-server/core/sessionNormalization.js';
+
+registerSessionNormalizer('triangulon-invasion', (session) => {
+ const data = session.data || {};
+ data.stage = typeof data.stage === 'string' ? data.stage : 'training';
+ data.events = Array.isArray(data.events) ? data.events : [];
+ data.map = Array.isArray(data.map) ? data.map : []; // optional: holds triangle state
+ session.data = data;
+});
+
+const MAX_EVENTS = 500; // cap in-memory event history per session
+
+function appendEvent(session, evt) {
+ session.data.events.push(evt);
+ if (session.data.events.length > MAX_EVENTS) {
+ const excess = session.data.events.length - MAX_EVENTS;
+ session.data.events.splice(0, excess);
+ }
+}
+
+// TODO: Once fractalStore diffs are broadcast, keep only non-triangle events here
+// and rely on the fractal state for triangle history to avoid duplication.
+
+export default function setupTriangulonRoutes(app, sessions, ws = null) {
+ app.post('/api/triangulon-invasion/create', async (req, res) => {
+ const session = await createSession(sessions, { data: {} });
+ session.type = 'triangulon-invasion';
+ session.data.stage = 'training';
+ session.data.events = [];
+ await sessions.set(session.id, session);
+ res.json({ id: session.id });
+ });
+
+ app.get('/api/triangulon-invasion/:sessionId/state', async (req, res) => {
+ const session = await sessions.get(req.params.sessionId);
+ if (!session || session.type !== 'triangulon-invasion') {
+ return res.status(404).json({ error: 'invalid session' });
+ }
+ res.json({ stage: session.data.stage, events: session.data.events });
+ });
+
+ if (ws && typeof ws.register === 'function') {
+ const peers = new Map(); // sessionId -> Set
+
+ const sendSafe = (socket, payload) => {
+ if (socket.readyState === 1) {
+ socket.send(payload);
+ }
+ };
+
+ const broadcast = (sessionId, message) => {
+ const set = peers.get(sessionId);
+ if (!set || set.size === 0) return;
+ const payload = typeof message === 'string' ? message : JSON.stringify(message);
+ const stale = [];
+ for (const sock of set) {
+ if (sock.readyState === 1) {
+ sock.send(payload);
+ } else {
+ stale.push(sock);
+ }
+ }
+ if (stale.length) {
+ stale.forEach(sock => set.delete(sock));
+ if (set.size === 0) peers.delete(sessionId);
+ }
+ };
+
+ const sendSnapshot = async (socket, sessionId) => {
+ const session = await sessions.get(sessionId);
+ if (!session || session.type !== 'triangulon-invasion') {
+ socket.close(1008, 'Invalid session');
+ return false;
+ }
+ const snapshot = {
+ type: 'state',
+ stage: session.data.stage,
+ events: session.data.events,
+ map: session.data.map,
+ };
+ sendSafe(socket, JSON.stringify(snapshot));
+ return true;
+ };
+
+ ws.register('/ws/triangulon-invasion', (socket, qp) => {
+ const sessionId = qp.get('sessionId');
+ if (!sessionId) {
+ socket.close(1008, 'Missing sessionId');
+ return;
+ }
+
+ socket.sessionId = sessionId;
+
+ // Track peers for broadcast
+ let set = peers.get(sessionId);
+ if (!set) {
+ set = new Set();
+ peers.set(sessionId, set);
+ }
+ set.add(socket);
+
+ (async () => {
+ const ok = await sendSnapshot(socket, sessionId);
+ if (!ok) return;
+ sendSafe(socket, JSON.stringify({ type: 'connected' }));
+ })().catch((err) => {
+ console.error('[triangulon-invasion] failed to send snapshot', err);
+ socket.close(1011, 'Snapshot failed');
+ });
+
+ socket.on('message', async (data) => {
+ let parsed;
+ try {
+ parsed = JSON.parse(data);
+ } catch {
+ return; // ignore non-JSON
+ }
+
+ const session = await sessions.get(sessionId);
+ if (!session || session.type !== 'triangulon-invasion') return;
+
+ // Basic protocol scaffold
+ switch (parsed.type) {
+ case 'advance-stage': {
+ const stage = typeof parsed.stage === 'string' ? parsed.stage : null;
+ if (!stage) return;
+ session.data.stage = stage;
+ appendEvent(session, { t: Date.now(), type: 'stage', stage });
+ await sessions.set(session.id, session);
+ broadcast(sessionId, { type: 'state', stage: session.data.stage, events: session.data.events, map: session.data.map });
+ break;
+ }
+ case 'subdivide': {
+ // Minimal stub: record the path and timestamp; real implementation can mutate map tree
+ const rawPath = Array.isArray(parsed.path) ? parsed.path.slice(0, 12) : [];
+ const path = rawPath.filter((step) => Number.isInteger(step) && step >= 0);
+ const evt = { t: Date.now(), type: 'subdivide', path };
+ appendEvent(session, evt);
+ await sessions.set(session.id, session);
+ broadcast(sessionId, { type: 'events', events: [evt] });
+ break;
+ }
+ case 'manager-action': {
+ // Generic manager action envelope
+ const action = typeof parsed.action === 'string' ? parsed.action : null;
+ if (!action) return;
+ const evt = { t: Date.now(), type: 'manager', action, payload: parsed.payload || null };
+ appendEvent(session, evt);
+ await sessions.set(session.id, session);
+ broadcast(sessionId, { type: 'events', events: [evt] });
+ break;
+ }
+ case 'event': {
+ // Generic event envelope for now
+ const safeEvent = typeof parsed.event === 'object' && parsed.event !== null ? parsed.event : {};
+ const evt = { ...safeEvent, t: Date.now() }; // ensure server timestamp wins
+ appendEvent(session, evt);
+ await sessions.set(session.id, session);
+ broadcast(sessionId, { type: 'events', events: [evt] });
+ break;
+ }
+ default:
+ break;
+ }
+ });
+
+ const cleanup = () => {
+ const set = peers.get(sessionId);
+ if (!set) return;
+ set.delete(socket);
+ if (set.size === 0) peers.delete(sessionId);
+ };
+
+ socket.on('close', cleanup);
+ socket.on('error', cleanup);
+ });
+ }
+}
diff --git a/client/src/activities/index.test.js b/client/src/activities/index.test.js
index 923e224b..8a8d7d80 100644
--- a/client/src/activities/index.test.js
+++ b/client/src/activities/index.test.js
@@ -17,6 +17,7 @@ const EXPECTED_ACTIVITIES = [
"java-string-practice",
"java-format-practice",
"python-list-practice",
+ "triangulon-invasion",
"raffle",
"gallery-walk",
"www-sim",
diff --git a/package-lock.json b/package-lock.json
index 4d528b3a..f2f2276b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -78,7 +78,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -200,7 +199,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"dev": true,
- "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -1634,7 +1632,6 @@
"version": "24.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz",
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
- "peer": true,
"dependencies": {
"undici-types": "~7.10.0"
}
@@ -1644,7 +1641,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
"dev": true,
- "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1717,7 +1713,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1862,7 +1857,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -2321,7 +2315,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz",
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
"dev": true,
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3754,7 +3747,6 @@
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3763,7 +3755,6 @@
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
- "peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -4287,7 +4278,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -4429,7 +4419,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -4521,7 +4510,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
diff --git a/server/activities/activityRegistry.test.js b/server/activities/activityRegistry.test.js
index 603f8baf..13e4db4a 100644
--- a/server/activities/activityRegistry.test.js
+++ b/server/activities/activityRegistry.test.js
@@ -19,6 +19,7 @@ const EXPECTED_ACTIVITIES = [
"java-string-practice",
"java-format-practice",
"python-list-practice",
+ "triangulon-invasion",
"raffle",
"gallery-walk",
"www-sim",
diff --git a/server/core/wsRouter.js b/server/core/wsRouter.js
index db2cb5d1..5f58dcfb 100644
--- a/server/core/wsRouter.js
+++ b/server/core/wsRouter.js
@@ -90,6 +90,22 @@ export function createWsRouter(server, sessions) {
const url = new URL(req.url, "http://x");
const onConn = namespaces.get(url.pathname);
if (!onConn) return socket.destroy();
+
+ // CSRF protection: require and validate Origin header
+ const origin = req.headers.origin;
+ if (!origin) {
+ console.warn('[wsRouter] Rejected WS with missing Origin header');
+ return socket.destroy();
+ }
+ const allowedOrigins = [
+ `http://${req.headers.host}`,
+ `https://${req.headers.host}`,
+ ];
+ if (!allowedOrigins.includes(origin)) {
+ console.warn(`[wsRouter] Rejected WS from untrusted origin: ${origin}`);
+ return socket.destroy();
+ }
+
wss.handleUpgrade(req, socket, head, (ws) => {
ws.isAlive = true;
ws.clientIp = getClientIp(req);
diff --git a/server/fractalStore.test.js b/server/fractalStore.test.js
new file mode 100644
index 00000000..8afc7507
--- /dev/null
+++ b/server/fractalStore.test.js
@@ -0,0 +1,53 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import * as fractal from '../activities/triangulon-invasion/server/fractalStore.js';
+
+const { FractalStore, getParentIndex, getChildrenIndices, getDepth } = fractal;
+
+test('getParentIndex and getChildrenIndices reflect ternary heap', () => {
+ assert.equal(getParentIndex(0), null);
+ assert.equal(getParentIndex(1), 0);
+ assert.equal(getParentIndex(3), 0);
+ assert.equal(getParentIndex(4), 1);
+ assert.deepEqual(getChildrenIndices(0), [1, 2, 3]);
+ assert.deepEqual(getChildrenIndices(5), [16, 17, 18]);
+});
+
+test('getDepth closed form matches expected levels', () => {
+ const cases = [
+ [0, 0], // root
+ [1, 1], [2, 1], [3, 1], // depth 1
+ [4, 2], [12, 2], // depth 2 max index 12
+ [13, 3], [39, 3], // depth 3 max index 39
+ [40, 4],
+ ];
+ for (const [idx, expected] of cases) {
+ assert.equal(getDepth(idx), expected);
+ }
+ assert.throws(() => getDepth(-1), /non-negative/);
+});
+
+test('FractalStore enforces parent presence and stores nodes', () => {
+ const emptyStore = new FractalStore();
+ assert.throws(() => emptyStore.addNode(1, { owner: 'orphan' }), /Parent must exist/);
+
+ const store = new FractalStore();
+ const root = store.addNode(0, { owner: 'alpha' });
+ assert.equal(root.owner, 'alpha');
+ assert(store.has(0));
+ assert.equal(store.get(0).owner, 'alpha');
+ const child = store.addNode(1, { owner: 'child' });
+ assert.equal(child.owner, 'child');
+ assert.deepEqual(store.getSiblings(1, { filterExisting: false }), [1, 2, 3]);
+ assert.deepEqual(store.getSiblings(1), [1]);
+});
+
+test('FractalStore payload round-trip', () => {
+ const store = new FractalStore();
+ store.addNode(0, { owner: 'alpha', createdAt: 111, meta: { note: 'root' } });
+ store.addNode(1, { owner: 'beta', createdAt: 222, meta: { note: 'left' } });
+ const payload = store.toPayload();
+ const restored = FractalStore.fromPayload(payload);
+ assert.deepEqual(restored.get(0), { owner: 'alpha', createdAt: 111, meta: { note: 'root' } });
+ assert.deepEqual(restored.get(1), { owner: 'beta', createdAt: 222, meta: { note: 'left' } });
+});