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 ( +
+ + ); +} 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 ( +
+ + ); +} 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 ( + + + + + + + + + + + + {/* Center upright triangle - main focus */} + + + {/* Guide lines extending from the top vertex */} + + + + + {/* Guide lines extending from bottom and angled edges */} + + + + + + {/* Navigation buttons (4 directional) */} + {navButtons.map((btn) => { + const isDisabled = disabled || disabledButtons[btn.id]; + return ( + + !isDisabled && handleClick(btn.id)} + style={{ + cursor: isDisabled ? 'default' : 'pointer', + opacity: isDisabled ? 0.4 : 0.6, + filter: 'url(#glow)' + }} + /> + + ); + })} + + {/* Inner triangles with subdivision indicators */} + {innerTriangles.map(tri => { + const isDisabled = disabled || disabledButtons[`inner-${tri.id}`]; + return ( + + !isDisabled && handleClick(`inner-${tri.id}`)} + style={{ + cursor: isDisabled ? 'default' : 'pointer', + opacity: isDisabled ? 0.3 : 0.5, + filter: 'url(#glow)' + }} + /> + {/* Subdivision indicator - center triangle */} + + + ); + })} + + {/* Center - parent button with nested triangles */} + + {(() => { + const isDisabled = disabled || disabledButtons['parent']; + return ( + <> + !isDisabled && handleClick('parent')} + style={{ + cursor: isDisabled ? 'default' : 'pointer', + opacity: isDisabled ? 0.4 : 0.6, + filter: 'url(#glow)' + }} + /> + {/* Inner nested triangle */} + + + ); + })()} + + + ); +} 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' } }); +});