first commit
Pablo Murad pblmrd@gmail.com
Thu, 30 Jul 2026 08:07:18 -0300
12 files changed,
2886 insertions(+),
0 deletions(-)
A
.gitignore
@@ -0,0 +1,15 @@
+# OS +.DS_Store +Thumbs.db +desktop.ini + +# Editor +*.swp +*~ +.vscode/ +.idea/ + +# Local / accidental +*.log +.env +.env.*
A
CHANGELOG.md
@@ -0,0 +1,30 @@
+# Changelog + +All notable changes to EMOJESIS. Dates are UTC. + +## [1.0.0] — 2026-07-30 + +First release. + +- 110 elements across 12 categories: nature, weather, life, animals, + humanity, civilization, culture, technology, internet, occult, cosmos, + absurd. +- 168 recipes with alternate routes, one order-sensitive recipe + (the Forbidden Book must be burned *on purpose*), and two night-only + discoveries. +- Combination laboratory: drag & drop, tap-tap, click-to-fill, swap, + repeat-last-combination, self-combinations. +- Collection with search, category filters, sorting, favorites, and + undiscovered silhouettes. +- Journal with per-element lore, known recipes, and vague hints. +- Daily challenge with deterministic seeded goals and a shareable result. +- Hint system: 3 stored, one regenerates every 10 minutes, plus a free + hint after 90 idle seconds. +- World view that fills in as you discover. +- Six secret systems: the IndieWeb chain (personal website → blog → RSS), + self-combination secrets, the title-clicking egg, night-only combos, + the `>_` terminal (`help`, `sudo`, `netuno`), and cosmic/occult rarities. +- Synthesized WebAudio blips — no audio files. +- Light/dark themes, reduced-motion support, keyboard playable, + aria-live discovery announcements. +- Local save with JSON export/import and a confirmed reset.
A
README.md
@@ -0,0 +1,72 @@
+EMOJESIS — a tiny universe made of symbols. + +Combine four primordial emojis (fire, water, earth, air) and see what +happens. 110 elements. 168 recipes. No server. No accounts. No bullshit. + + +WHAT IS THIS +------------ + +Browser game. HTML + CSS + JavaScript. Zero dependencies. Zero build step. +Save goes to localStorage. Export/import as JSON if you care. + + 🔥 + 💧 → steam + steam + earth → time + ...and so on, until the absurd. + + +REQUIREMENTS +------------ + +- Any modern browser +- To play offline: serve over HTTP(S) so the service worker can install +- To validate data: Node.js (any recent version) + + +QUICK START +----------- + + # option A — just open it + open index.html + + # option B — local server + python -m http.server 8080 + npx serve . + + # option C — validate the recipe graph + node validate.js + + +FILES +----- + + index.html UI shell + css/style.css layout + themes + js/data.js elements + recipes (110 / 168) + js/app.js game logic + sw.js offline cache + validate.js data integrity checks + feed.xml atom feed + CHANGELOG.md release notes + deploy/ nginx config for production + + +PRODUCTION (VPS) +---------------- + +Copy the site to your web root, then use the nginx snippet in: + + deploy/emojesis.pablomurad.com.conf + +Panel placeholders ({{root}}, {{ssl_certificate}}, etc.) are kept as-is. +`sw.js` is excluded from long-term cache so updates actually ship. + + +AUTHOR +------ + + Pablo Murad + Developer — 2026 + https://github.com/pablomurad/emojesis + +License: use it, fork it, learn from it. Have fun.
A
css/style.css
@@ -0,0 +1,752 @@
+/* EMOJESIS — styles. Digital laboratory / creation notebook. */ + +:root { + --bg: #f2efe7; + --bg-raised: #faf8f2; + --ink: #201d17; + --ink-soft: #5c564a; + --line: #d8d2c2; + --accent: #a3501c; + --accent-ink: #fff; + --gold: #8a6d1d; + --danger: #9c2b1f; + --slot-empty: #e7e2d3; + --shadow: 0 1px 0 rgba(32, 29, 23, 0.08); + --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; +} + +html[data-theme="dark"] { + --bg: #14151a; + --bg-raised: #1c1e25; + --ink: #e9e6dc; + --ink-soft: #a39c8d; + --line: #33363f; + --accent: #e0915a; + --accent-ink: #181410; + --gold: #d3b155; + --danger: #e06c5b; + --slot-empty: #23252d; + --shadow: 0 1px 0 rgba(0, 0, 0, 0.4); +} + +@media (prefers-color-scheme: dark) { + html:not([data-theme="light"]):not([data-theme="dark"]) { + --bg: #14151a; + --bg-raised: #1c1e25; + --ink: #e9e6dc; + --ink-soft: #a39c8d; + --line: #33363f; + --accent: #e0915a; + --accent-ink: #181410; + --gold: #d3b155; + --danger: #e06c5b; + --slot-empty: #23252d; + --shadow: 0 1px 0 rgba(0, 0, 0, 0.4); + } +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--sans); + line-height: 1.45; +} + +body { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* subtle graph-paper lab backdrop */ +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(var(--line) 1px, transparent 1px), + linear-gradient(90deg, var(--line) 1px, transparent 1px); + background-size: 44px 44px; + opacity: 0.16; + z-index: -1; +} + +.sr-only { + position: absolute; + width: 1px; height: 1px; + margin: -1px; padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: absolute; + left: -9999px; + top: 0; + background: var(--accent); + color: var(--accent-ink); + padding: 0.5rem 1rem; + z-index: 100; +} +.skip-link:focus { left: 0; } + +a { color: var(--accent); } + +button { + font-family: inherit; + color: inherit; + cursor: pointer; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.mono { font-family: var(--mono); } +.muted { color: var(--ink-soft); } + +/* ---------- header ---------- */ +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.7rem 1rem; + border-bottom: 2px solid var(--ink); + background: var(--bg-raised); + flex-wrap: wrap; +} +.brand { display: flex; align-items: center; gap: 0.7rem; } +.title-emoji { + font-size: 2rem; + background: none; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.15rem 0.4rem; + line-height: 1.2; + min-width: 44px; + min-height: 44px; +} +.brand-text h1 { + margin: 0; + font-size: 1.25rem; + letter-spacing: 0.22em; + font-family: var(--mono); +} +.tagline { + margin: 0; + font-size: 0.75rem; + color: var(--ink-soft); +} +.header-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; } +.hbtn { + position: relative; + font-size: 1.15rem; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 6px; + min-width: 44px; + min-height: 44px; + padding: 0.3rem 0.55rem; +} +.hbtn:hover { border-color: var(--ink); } +.badge { + position: absolute; + top: -6px; right: -6px; + background: var(--accent); + color: var(--accent-ink); + border-radius: 999px; + font-size: 0.65rem; + font-family: var(--mono); + padding: 0.05rem 0.35rem; +} +.dot { + position: absolute; + top: 2px; right: 2px; + width: 8px; height: 8px; + border-radius: 50%; + background: var(--accent); +} + +/* ---------- world strip ---------- */ +.world { + border-bottom: 1px solid var(--line); + background: var(--bg-raised); +} +.world-toggle { + background: none; + border: none; + font-family: var(--mono); + font-size: 0.7rem; + color: var(--ink-soft); + padding: 0.3rem 1rem; + min-height: 32px; +} +.world-strip { + display: flex; + gap: 0.5rem; + align-items: flex-end; + overflow-x: auto; + padding: 0.2rem 1rem 0.6rem; + min-height: 56px; +} +.world.collapsed .world-strip { display: none; } +.w-slot { + flex: 0 0 auto; + width: 44px; + height: 44px; + display: grid; + place-items: center; + border: 1px dashed var(--line); + border-radius: 6px; + background: var(--bg); +} +.w-slot span { + font-size: 1.5rem; + opacity: 0; + transform: scale(0.5); + transition: opacity 0.4s ease, transform 0.4s ease; +} +.w-slot.found span { opacity: 1; transform: scale(1); } +.w-sky { border-bottom-style: solid; } +.w-sea { background: color-mix(in srgb, var(--bg) 80%, #4a7fa5 20%); } + +/* ---------- main layout ---------- */ +main { + flex: 1; + display: grid; + grid-template-columns: minmax(300px, 1fr) minmax(320px, 420px); + gap: 1rem; + padding: 1rem; + max-width: 1200px; + width: 100%; + margin: 0 auto; +} + +/* ---------- creation ---------- */ +.creation { + align-self: start; + background: var(--bg-raised); + border: 2px solid var(--ink); + border-radius: 10px; + padding: 1.25rem; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.9rem; +} +.slots { + display: flex; + align-items: center; + gap: 0.8rem; +} +.slot { + width: 104px; + height: 104px; + border: 2px dashed var(--line); + border-radius: 12px; + background: var(--slot-empty); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.15rem; +} +.slot[data-filled="true"] { + border-style: solid; + border-color: var(--ink); + background: var(--bg); +} +.slot.drop-hover { border-color: var(--accent); background: var(--bg); } +.slot-emoji { font-size: 2.6rem; line-height: 1; } +.slot-name { + font-size: 0.7rem; + font-family: var(--mono); + color: var(--ink-soft); +} +.plus { font-size: 1.6rem; color: var(--ink-soft); } + +.creation-actions { + display: flex; + align-items: center; + gap: 0.6rem; +} +.combine-btn { + background: var(--accent); + color: var(--accent-ink); + border: 2px solid var(--ink); + border-radius: 8px; + font-size: 1.05rem; + font-weight: 600; + padding: 0.6rem 1.4rem; + min-height: 44px; +} +.combine-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.abtn { + background: var(--bg); + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.45rem 0.8rem; + min-height: 44px; + min-width: 44px; + font-size: 1rem; +} +.abtn:hover:not(:disabled) { border-color: var(--ink); } +.abtn:disabled { opacity: 0.4; cursor: not-allowed; } +.abtn.danger { color: var(--danger); border-color: var(--danger); } + +.result { + display: flex; + flex-direction: column; + align-items: center; + min-height: 84px; + justify-content: center; + gap: 0.2rem; +} +.result-emoji { font-size: 3.2rem; line-height: 1; } +.result-name { + font-family: var(--mono); + font-size: 0.85rem; + color: var(--ink-soft); +} +.message { + margin: 0; + text-align: center; + font-size: 0.95rem; + color: var(--ink-soft); + min-height: 1.4em; + max-width: 40ch; +} +.message.announce { color: var(--ink); } +.daily-line { + margin: 0; + font-size: 0.78rem; + font-family: var(--mono); + color: var(--gold); + text-align: center; +} + +/* combine animation */ +@keyframes fuse { + 0% { transform: translateX(0) scale(1); } + 40% { transform: translateX(var(--fuse-x, 10px)) scale(1.08); } + 60% { transform: translateX(var(--fuse-x, 10px)) scale(1.08); } + 100% { transform: translateX(0) scale(1); } +} +.slot.fusing { animation: fuse 0.5s ease; } +@keyframes pop { + 0% { transform: scale(0.3); opacity: 0; } + 60% { transform: scale(1.15); opacity: 1; } + 100% { transform: scale(1); } +} +.result.reveal { animation: pop 0.45s ease; } +@keyframes fzzap { 0%,100% { filter: none; } 50% { filter: brightness(1.8) saturate(1.6); } } +.result.fx-zap { animation: pop 0.45s ease, fzzap 0.5s ease; } +@keyframes fxflare { 0% { filter: none; } 50% { filter: brightness(1.6) hue-rotate(-20deg); } 100% { filter: none; } } +.result.fx-flare { animation: pop 0.45s ease, fxflare 0.6s ease; } +@keyframes fxshake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } } +.result.fx-shake { animation: pop 0.45s ease, fxshake 0.45s ease; } +@keyframes fxcosmic { 0% { filter: none; transform: scale(0.3); } 50% { filter: brightness(1.7) hue-rotate(90deg); transform: scale(1.2); } 100% { filter: none; transform: scale(1); } } +.result.fx-cosmic { animation: fxcosmic 0.7s ease; } +@keyframes fxspooky { 0% { opacity: 0; } 30% { opacity: 1; } 45% { opacity: 0.3; } 60% { opacity: 1; } 100% { opacity: 1; } } +.result.fx-spooky { animation: pop 0.45s ease, fxspooky 0.7s ease; } + +/* ---------- inventory ---------- */ +.inventory { + align-self: start; + background: var(--bg-raised); + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; +} +.inv-toggle { + width: 100%; + text-align: left; + background: none; + border: none; + border-bottom: 1px solid var(--line); + padding: 0.7rem 0.9rem; + font-family: var(--mono); + font-size: 0.85rem; + min-height: 44px; +} +.inv-count { color: var(--ink-soft); } +.inventory.collapsed .inv-body { display: none; } +.inv-body { padding: 0.7rem; } +.inv-tools { + display: flex; + gap: 0.4rem; + margin-bottom: 0.5rem; +} +.inv-tools input[type="search"] { + flex: 1; + min-width: 0; + padding: 0.45rem 0.6rem; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg); + color: var(--ink); + font: inherit; + min-height: 44px; +} +.inv-tools select { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg); + color: var(--ink); + font: inherit; + min-height: 44px; +} +.chip { + border: 1px solid var(--line); + background: var(--bg); + border-radius: 999px; + padding: 0.2rem 0.6rem; + font-size: 0.75rem; + font-family: var(--mono); + min-height: 30px; +} +.chip[aria-pressed="true"] { + background: var(--ink); + color: var(--bg); + border-color: var(--ink); +} +.inv-cats { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-bottom: 0.6rem; +} +.inv-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); + gap: 0.45rem; + max-height: 54vh; + overflow-y: auto; +} +.inv-item { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg); + padding: 0.5rem 0.3rem 0.4rem; +} +.inv-item.is-new { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.inv-main { + background: none; + border: none; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + width: 100%; + min-height: 44px; + cursor: grab; +} +.inv-main:active { cursor: grabbing; } +.inv-emoji { font-size: 1.8rem; line-height: 1; } +.inv-name { + font-size: 0.62rem; + font-family: var(--mono); + color: var(--ink-soft); + text-align: center; + overflow-wrap: anywhere; +} +.inv-fav, .inv-info { + position: absolute; + top: 1px; + background: none; + border: none; + font-size: 0.7rem; + padding: 0.2rem; + min-width: 28px; + min-height: 28px; + opacity: 0.55; +} +.inv-fav { left: 1px; } +.inv-info { right: 1px; } +.inv-fav.on { opacity: 1; } +.inv-item:hover .inv-fav, .inv-item:hover .inv-info, +.inv-fav:focus-visible, .inv-info:focus-visible { opacity: 1; } +.inv-empty { + grid-column: 1 / -1; + text-align: center; + color: var(--ink-soft); + font-size: 0.85rem; + padding: 1rem 0; +} + +@keyframes pulseHint { + 0%, 100% { box-shadow: 0 0 0 0 var(--accent); } + 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 40%, transparent); } +} +.inv-item.ob-pulse { animation: pulseHint 1.4s ease infinite; } + +/* ---------- footer ---------- */ +.site-footer { + border-top: 1px solid var(--line); + padding: 0.8rem 1rem; + text-align: center; + font-size: 0.78rem; + color: var(--ink-soft); + background: var(--bg-raised); +} +.linklike { + background: none; + border: none; + padding: 0; + color: var(--accent); + text-decoration: underline; + font-size: inherit; +} + +/* ---------- modals ---------- */ +.modal { + position: fixed; + inset: 0; + background: rgba(10, 10, 12, 0.55); + display: grid; + place-items: center; + padding: 1rem; + z-index: 50; +} +.modal[hidden] { display: none; } +.modal-card { + background: var(--bg-raised); + border: 2px solid var(--ink); + border-radius: 10px; + padding: 1.2rem; + max-width: 480px; + width: 100%; + max-height: 85vh; + overflow-y: auto; +} +.modal-card h2 { margin-top: 0; font-size: 1.1rem; } +.modal-card h3 { font-size: 0.95rem; margin-bottom: 0.3rem; } +.modal-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 1rem; +} +.modal-actions.wrap { flex-wrap: wrap; justify-content: flex-start; } +.about-links { padding-left: 1.1rem; } +.settings-grid { + display: flex; + gap: 1.2rem; + flex-wrap: wrap; + align-items: center; +} +.settings-grid select { + font: inherit; + background: var(--bg); + color: var(--ink); + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.3rem; + min-height: 40px; +} +.checkline { display: flex; align-items: center; gap: 0.4rem; min-height: 44px; } +.reset-confirm { + margin-top: 0.8rem; + border: 1px solid var(--danger); + border-radius: 8px; + padding: 0.7rem; +} +.daily-goal { font-size: 1.05rem; } +.share-block { + background: var(--bg); + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.6rem; + font-family: var(--mono); + font-size: 0.8rem; + white-space: pre-wrap; +} +.stats-list { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.3rem 1rem; +} +.stats-list dt { font-family: var(--mono); color: var(--ink-soft); } +.stats-list dd { margin: 0; text-align: right; } + +/* journal */ +.journal-head { + display: flex; + align-items: center; + gap: 0.8rem; + margin-bottom: 0.6rem; +} +.journal-emoji { font-size: 2.6rem; } +.journal-head h2 { margin: 0; } +.journal-meta { + font-family: var(--mono); + font-size: 0.72rem; + color: var(--ink-soft); +} +.journal-sec { margin: 0.7rem 0 0.2rem; font-size: 0.85rem; } +.journal-recipes { margin: 0; padding-left: 1.2rem; font-size: 0.88rem; } +.journal-hint { color: var(--gold); font-size: 0.85rem; font-style: italic; } + +/* terminal */ +.term-card { background: #101216; color: #c9e7c9; border-color: #3a4a3a; } +.term-card h2 { color: #8fce8f; font-size: 0.9rem; } +.term-out { + min-height: 160px; + max-height: 40vh; + overflow-y: auto; + font-size: 0.8rem; + white-space: pre-wrap; + margin-bottom: 0.5rem; +} +.term-form { display: flex; align-items: center; gap: 0.3rem; } +.term-form input { + flex: 1; + background: none; + border: none; + color: #c9e7c9; + font: inherit; + outline: none; + min-height: 40px; +} + +/* toasts */ +.toasts { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 0.4rem; + z-index: 80; + pointer-events: none; + width: min(92vw, 420px); +} +.toast { + background: var(--ink); + color: var(--bg); + border-radius: 8px; + padding: 0.55rem 0.9rem; + font-size: 0.85rem; + text-align: center; + animation: toastIn 0.25s ease; +} +@keyframes toastIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* onboarding */ +.onboarding { + position: fixed; + inset: 0; + background: var(--bg); + display: grid; + place-items: center; + z-index: 90; + padding: 1rem; +} +.onboarding[hidden] { display: none; } +.ob-card { + text-align: center; + max-width: 420px; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.8rem; +} +.ob-card h2 { + font-family: var(--mono); + letter-spacing: 0.3em; + margin: 0; +} +.ob-primordials { + display: flex; + gap: 1rem; + margin: 0.6rem 0; +} +.ob-el { + font-size: 2.4rem; + display: flex; + flex-direction: column; + align-items: center; +} +.ob-el small { + font-size: 0.68rem; + font-family: var(--mono); + color: var(--ink-soft); +} +.ob-hint { font-size: 0.85rem; color: var(--ink-soft); } + +/* ---------- world animations ---------- */ +@keyframes drift { 0%,100% { transform: translateX(0); } 50% { transform: translateX(7px); } } +@keyframes twinkle { 0%,100% { opacity: 1; } 50% { opacity: 0.45; } } +@keyframes sway { 0%,100% { transform: rotate(-3deg); } 50% { transform: rotate(3deg); } } +@keyframes bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(3px); } } +@keyframes flap { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } } +@keyframes hover { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } } +@keyframes fall { 0%,100% { transform: translateY(0); } 50% { transform: translateY(2px); } } +@keyframes rise { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } } +.w-slot.found.anim-drift span { animation: drift 5s ease-in-out infinite; } +.w-slot.found.anim-twinkle span { animation: twinkle 2.4s ease-in-out infinite; } +.w-slot.found.anim-sway span { animation: sway 3.6s ease-in-out infinite; transform-origin: bottom center; } +.w-slot.found.anim-bob span { animation: bob 2.8s ease-in-out infinite; } +.w-slot.found.anim-flap span { animation: flap 1.6s ease-in-out infinite; } +.w-slot.found.anim-hover span { animation: hover 3.2s ease-in-out infinite; } +.w-slot.found.anim-fall span { animation: fall 2.2s ease-in-out infinite; } +.w-slot.found.anim-rise span { animation: rise 2.6s ease-in-out infinite; } + +/* ---------- responsive ---------- */ +@media (max-width: 860px) { + main { + grid-template-columns: 1fr; + padding: 0.7rem; + } + .inventory { + position: fixed; + left: 0; right: 0; bottom: 0; + z-index: 40; + border-radius: 14px 14px 0 0; + border: 2px solid var(--ink); + border-bottom: none; + max-height: 70vh; + display: flex; + flex-direction: column; + transform: translateY(calc(100% - 52px)); + transition: transform 0.25s ease; + } + .inventory.open { transform: translateY(0); } + .inventory.collapsed .inv-body { display: block; } + .inv-body { overflow-y: auto; } + .inv-grid { max-height: none; } + .inv-toggle { text-align: center; } + main { padding-bottom: 64px; } +} + +/* ---------- reduced motion ---------- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001s !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001s !important; + } + .slot.fusing, .result.reveal, .result.fx-zap, .result.fx-flare, + .result.fx-shake, .result.fx-cosmic, .result.fx-spooky, + .inv-item.ob-pulse { animation: none !important; } +}
A
deploy/emojesis.pablomurad.com.conf
@@ -0,0 +1,69 @@
+# EMOJESIS — nginx vhost for emojesis.pablomurad.com +# Static HTML/CSS/JS. Service worker needs sw.js without long cache. +# +# Panel placeholders: {{root}}, {{ssl_certificate}}, {{settings}}, etc. +# Set {{root}} to the directory containing index.html (e.g. /home/.../emojesis/public) + +server { + listen 80; + listen [::]:80; + listen 443 quic; + listen 443 ssl; + listen [::]:443 quic; + listen [::]:443 ssl; + http2 on; + http3 off; + {{ssl_certificate_key}} + {{ssl_certificate}} + server_name emojesis.pablomurad.com; + {{root}} + + {{nginx_access_log}} + {{nginx_error_log}} + + if ($scheme != "https") { + rewrite ^ https://$host$request_uri permanent; + } + + location ~ /.well-known { + auth_basic off; + allow all; + } + + {{settings}} + + include /etc/nginx/global_settings; + + index index.html; + + # Service worker — must revalidate or updates never reach clients + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Service-Worker-Allowed "/"; + try_files $uri =404; + } + + # Shell — avoid aggressive cache on the entry document + location = /index.html { + add_header Cache-Control "no-cache, must-revalidate"; + try_files $uri =404; + } + + # Versioned-ish assets (bump sw.js CACHE in app releases) + location ~* ^.+\.(css|js|jpg|jpeg|gif|png|ico|gz|svg|svgz|ttf|otf|woff|woff2|eot|mp4|ogg|ogv|webm|webp|zip|swf|xml|md)$ { + add_header Access-Control-Allow-Origin "*"; + add_header alt-svc 'h3=":443"; ma=86400'; + expires 7d; + access_log off; + try_files $uri =404; + } + + # Everything else: file if present, else index.html (single-page game) + location / { + try_files $uri $uri/ /index.html; + } + + if (-f $request_filename) { + break; + } +}
A
favicon.svg
@@ -0,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="0.9em" font-size="90">🌌</text></svg>
A
feed.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?> +<feed xmlns="http://www.w3.org/2005/Atom"> + <title>EMOJESIS</title> + <subtitle>A tiny universe made of symbols — project updates</subtitle> + <id>https://github.com/pablomurad/emojesis</id> + <link href="https://github.com/pablomurad/emojesis"/> + <link rel="self" href="https://github.com/pablomurad/emojesis/feed.xml"/> + <updated>2026-07-30T00:00:00Z</updated> + <author> + <name>Pablo Murad</name> + </author> + <entry> + <title>EMOJESIS 1.0.0 — first release</title> + <id>https://github.com/pablomurad/emojesis/releases/tag/v1.0.0</id> + <link href="https://github.com/pablomurad/emojesis"/> + <updated>2026-07-30T00:00:00Z</updated> + <summary>110 elements, 168 recipes, 12 categories, 16 secrets, a daily challenge, and a terminal that listens to the outer dark. No accounts, no tracking, no ads.</summary> + </entry> +</feed>
A
index.html
@@ -0,0 +1,236 @@
+<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> + <title>EMOJESIS — a tiny universe made of symbols</title> + <meta name="description" content="EMOJESIS: a tiny universe made of symbols. Combine four primordial emojis into weather, life, civilization, and stranger things. No accounts, no tracking, no ads."> + <link rel="icon" href="favicon.svg" type="image/svg+xml"> + <link rel="alternate" type="application/atom+xml" title="EMOJESIS updates" href="feed.xml"> + <link rel="stylesheet" href="css/style.css"> +</head> +<body> + <a class="skip-link" href="#creation">Skip to the laboratory</a> + + <div id="live" class="sr-only" aria-live="polite"></div> + + <header class="site-header"> + <div class="brand"> + <button id="titleEmoji" class="title-emoji" aria-label="EMOJESIS logo" title="EMOJESIS">🌌</button> + <div class="brand-text"> + <h1>EMOJESIS</h1> + <p class="tagline">A tiny universe made of symbols.</p> + </div> + </div> + <nav class="header-actions" aria-label="Game actions"> + <button id="btnDaily" class="hbtn" aria-label="Daily challenge" title="Daily challenge">📅<span id="dailyDot" class="dot" hidden></span></button> + <button id="btnHint" class="hbtn" aria-label="Use a hint" title="Hint"><span aria-hidden="true">💡</span><span id="hintCount" class="badge">3</span></button> + <button id="btnStats" class="hbtn" aria-label="Statistics" title="Statistics">📊</button> + <button id="btnAbout" class="hbtn" aria-label="About and settings" title="About & settings">ℹ️</button> + <button id="btnSound" class="hbtn" aria-label="Toggle sound" title="Sound">🔊</button> + <button id="btnTheme" class="hbtn" aria-label="Toggle theme" title="Theme">🌗</button> + </nav> + </header> + + <!-- World view: a quiet sky/land/sea strip that fills in as you discover --> + <section id="world" class="world" aria-label="Your world"> + <button id="worldToggle" class="world-toggle" aria-expanded="true" aria-controls="worldStrip">🗺️ world</button> + <div id="worldStrip" class="world-strip"> + <div class="w-slot w-sky" data-el="sun"><span>☀️</span></div> + <div class="w-slot w-sky" data-el="moon"><span>🌙</span></div> + <div class="w-slot w-sky anim-twinkle" data-el="star"><span>⭐</span></div> + <div class="w-slot w-sky anim-drift" data-el="cloud"><span>☁️</span></div> + <div class="w-slot w-sky anim-fall" data-el="rain"><span>🌧️</span></div> + <div class="w-slot w-sky" data-el="rainbow"><span>🌈</span></div> + <div class="w-slot w-sky anim-drift" data-el="satellite"><span>🛰️</span></div> + <div class="w-slot w-sky anim-hover" data-el="ufo"><span>🛸</span></div> + <div class="w-slot w-air anim-flap" data-el="bird"><span>🐦</span></div> + <div class="w-slot w-air anim-flap" data-el="owl"><span>🦉</span></div> + <div class="w-slot w-land" data-el="mountain"><span>⛰️</span></div> + <div class="w-slot w-land anim-sway" data-el="tree"><span>🌳</span></div> + <div class="w-slot w-land anim-sway" data-el="forest"><span>🌲</span></div> + <div class="w-slot w-land anim-sway" data-el="flower"><span>🌸</span></div> + <div class="w-slot w-land" data-el="house"><span>🏠</span></div> + <div class="w-slot w-land" data-el="city"><span>🏙️</span></div> + <div class="w-slot w-land anim-hover" data-el="robot"><span>🤖</span></div> + <div class="w-slot w-land anim-rise" data-el="rocket"><span>🚀</span></div> + <div class="w-slot w-sea anim-bob" data-el="fish"><span>🐟</span></div> + <div class="w-slot w-sea anim-bob" data-el="whale"><span>🐋</span></div> + <div class="w-slot w-sea" data-el="ocean"><span>🌊</span></div> + <div class="w-slot w-misc anim-hover" data-el="ghost"><span>👻</span></div> + </div> + </section> + + <main id="main"> + <!-- Creation area --> + <section id="creation" class="creation" aria-label="Combination laboratory"> + <div class="slots"> + <button id="slotA" class="slot" aria-label="First ingredient slot. Empty." data-filled="false"> + <span class="slot-emoji" aria-hidden="true">·</span> + <span class="slot-name">first</span> + </button> + <span class="plus" aria-hidden="true">+</span> + <button id="slotB" class="slot" aria-label="Second ingredient slot. Empty." data-filled="false"> + <span class="slot-emoji" aria-hidden="true">·</span> + <span class="slot-name">second</span> + </button> + </div> + <div class="creation-actions"> + <button id="btnSwap" class="abtn" aria-label="Swap the two ingredients" title="Swap" disabled>⇄</button> + <button id="btnCombine" class="combine-btn" disabled>Combine ✨</button> + <button id="btnRepeat" class="abtn" aria-label="Repeat last successful combination" title="Repeat last combination" disabled>⟳</button> + </div> + <div id="result" class="result" aria-hidden="true"> + <span id="resultEmoji" class="result-emoji"></span> + <span id="resultName" class="result-name"></span> + </div> + <p id="message" class="message">Combine two symbols and see what happens.</p> + <p id="dailyLine" class="daily-line" hidden></p> + </section> + + <!-- Inventory --> + <section id="inventory" class="inventory" aria-label="Your collection"> + <div class="inv-head"> + <button id="invToggle" class="inv-toggle" aria-expanded="true" aria-controls="invBody"> + 🎒 Collection <span id="invCount" class="inv-count"></span> + </button> + <div id="invBody" class="inv-body"> + <div class="inv-tools"> + <input id="invSearch" type="search" placeholder="Search elements…" aria-label="Search elements by name"> + <select id="invSort" aria-label="Sort elements"> + <option value="order">Discovery order</option> + <option value="alpha">Alphabetical</option> + </select> + <button id="invFav" class="chip" aria-pressed="false" title="Show favorites only">⭐</button> + </div> + <div id="invCats" class="inv-cats" role="group" aria-label="Filter by category"></div> + <div id="invGrid" class="inv-grid"></div> + </div> + </div> + </section> + </main> + + <footer class="site-footer"> + <span>EMOJESIS — a tiny universe made of symbols · Pablo Murad, 2026 · + <a href="https://github.com/pablomurad/emojesis" rel="me noopener">source</a> · + <button id="footAbout" class="linklike">about</button> · + <button id="footTerminal" class="linklike mono" aria-label="Open terminal">>_</button> + </span> + </footer> + + <!-- Daily challenge modal --> + <div id="modalDaily" class="modal" role="dialog" aria-modal="true" aria-labelledby="dailyTitle" hidden> + <div class="modal-card"> + <h2 id="dailyTitle">📅 Daily challenge</h2> + <p id="dailyGoal" class="daily-goal"></p> + <p id="dailyProgress" class="muted"></p> + <pre id="dailyShare" class="share-block" aria-label="Shareable result"></pre> + <div class="modal-actions"> + <button id="btnCopyDaily" class="abtn">Copy result</button> + <button class="abtn modal-close" data-close="modalDaily">Close</button> + </div> + </div> + </div> + + <!-- Stats modal --> + <div id="modalStats" class="modal" role="dialog" aria-modal="true" aria-labelledby="statsTitle" hidden> + <div class="modal-card"> + <h2 id="statsTitle">📊 Notebook margins</h2> + <dl id="statsList" class="stats-list"></dl> + <div class="modal-actions"> + <button class="abtn modal-close" data-close="modalStats">Close</button> + </div> + </div> + </div> + + <!-- Journal (element detail) modal --> + <div id="modalJournal" class="modal" role="dialog" aria-modal="true" aria-labelledby="journalTitle" hidden> + <div class="modal-card"> + <div id="journalBody"></div> + <div class="modal-actions"> + <button id="journalUse" class="abtn">Send to slot</button> + <button class="abtn modal-close" data-close="modalJournal">Close</button> + </div> + </div> + </div> + + <!-- About / settings modal --> + <div id="modalAbout" class="modal" role="dialog" aria-modal="true" aria-labelledby="aboutTitle" hidden> + <div class="modal-card"> + <h2 id="aboutTitle">ℹ️ About EMOJESIS</h2> + <p><strong>EMOJESIS</strong> — a tiny universe made of symbols. You begin with four primordial + emojis and combine them into weather, life, civilization, and stranger things.</p> + <p>Developed by <strong>Pablo Murad</strong> (2026). No accounts, no tracking, no ads, no currencies. + Your universe lives in your browser and nowhere else.</p> + <ul class="about-links"> + <li><a href="https://github.com/pablomurad/emojesis" rel="me noopener">Source code</a></li> + <li><a href="CHANGELOG.md">Changelog</a></li> + <li><a href="feed.xml">Update feed (Atom)</a></li> + <li><a href="README.md">README</a></li> + </ul> + <h3>Settings</h3> + <div class="settings-grid"> + <label>Theme + <select id="setTheme"> + <option value="auto">Follow system</option> + <option value="light">Light</option> + <option value="dark">Dark</option> + </select> + </label> + <label class="checkline"><input type="checkbox" id="setSound"> Sound effects</label> + </div> + <div class="modal-actions wrap"> + <button id="btnExport" class="abtn">Export save</button> + <button id="btnImport" class="abtn">Import save</button> + <input type="file" id="importFile" accept="application/json,.json" hidden> + <button id="btnReset" class="abtn danger">Reset universe</button> + </div> + <div id="resetConfirm" class="reset-confirm" hidden> + <p>Everything you discovered will be forgotten. Really?</p> + <button id="btnResetYes" class="abtn danger">Yes, forget it all</button> + <button id="btnResetNo" class="abtn">Keep my universe</button> + </div> + <div class="modal-actions"> + <button class="abtn modal-close" data-close="modalAbout">Close</button> + </div> + </div> + </div> + + <!-- Terminal easter egg --> + <div id="modalTerm" class="modal" role="dialog" aria-modal="true" aria-labelledby="termTitle" hidden> + <div class="modal-card term-card"> + <h2 id="termTitle" class="mono">guest@emojesis:~</h2> + <div id="termOut" class="term-out mono" aria-live="polite"></div> + <form id="termForm" class="term-form mono"> + <span aria-hidden="true">> </span> + <input id="termInput" type="text" autocomplete="off" spellcheck="false" aria-label="Terminal input" placeholder="type 'help'"> + </form> + <div class="modal-actions"> + <button class="abtn modal-close" data-close="modalTerm">Close</button> + </div> + </div> + </div> + + <!-- Onboarding --> + <div id="onboarding" class="onboarding" role="dialog" aria-modal="true" aria-labelledby="obTitle" hidden> + <div class="ob-card"> + <h2 id="obTitle">EMOJESIS</h2> + <p class="tagline">A tiny universe made of symbols.</p> + <div class="ob-primordials" aria-label="The four primordial elements"> + <span class="ob-el" data-el="fire">🔥<small>Fire</small></span> + <span class="ob-el" data-el="water">💧<small>Water</small></span> + <span class="ob-el" data-el="earth">🪨<small>Earth</small></span> + <span class="ob-el" data-el="air">💨<small>Air</small></span> + </div> + <p>Everything begins with four symbols. Combine them and see what happens.</p> + <p class="ob-hint">Try <span class="mono">🔥 + 💧</span> — or don't. The universe is patient.</p> + <button id="btnBegin" class="combine-btn">Begin</button> + </div> + </div> + + <div id="toasts" class="toasts" aria-hidden="false"></div> + + <script src="js/data.js"></script> + <script src="js/app.js"></script> +</body> +</html>
A
js/app.js
@@ -0,0 +1,1100 @@
+/* EMOJESIS — game logic and UI. Vanilla JS, no modules. */ +(function () { + 'use strict'; + + var DATA = globalThis.EMOJESIS_DATA; + var ELEMENTS = DATA.ELEMENTS; + var RECIPES = DATA.RECIPES; + var CATEGORIES = DATA.CATEGORIES; + var STARTERS = DATA.STARTERS; + + var SAVE_KEY = 'emojesis-save-v1'; + var DAY_EPOCH = Date.UTC(2025, 0, 1); + var HINT_MAX = 3; + var HINT_REGEN_MS = 10 * 60 * 1000; + var IDLE_HINT_MS = 90 * 1000; + + var FAILURE_LINES = [ + 'Nothing happened. Probably for the best.', + 'These symbols refuse to cooperate.', + 'The universe is not ready for that.', + 'An interesting idea with no physical consequences.', + 'The laboratory politely ignores this.', + 'Somewhere, a physicist felt a disturbance.', + 'That is not how any of this works.', + 'The symbols exchanged a look and moved on.' + ]; + var CLUE_SUFFIX = ' (Something about this feels almost right.)'; + + /* ---------- recipe indexes ---------- */ + var recipeByPair = {}; // unordered "a|b" -> recipe + var recipeByOrdered = {}; // "a>b" -> recipe + var recipesByInput = {}; // elementId -> [recipes using it] + var recipesByResult = {}; // elementId -> [recipes creating it] + + RECIPES.forEach(function (r) { + if (r.ordered) recipeByOrdered[r.a + '>' + r.b] = r; + else recipeByPair[[r.a, r.b].sort().join('|')] = r; + (recipesByInput[r.a] = recipesByInput[r.a] || []).push(r); + (recipesByInput[r.b] = recipesByInput[r.b] || []).push(r); + (recipesByResult[r.result] = recipesByResult[r.result] || []).push(r); + }); + + function findRecipe(a, b) { + var ord = recipeByOrdered[a + '>' + b]; + if (ord) return ord; + return recipeByPair[[a, b].sort().join('|')] || null; + } + + /* ---------- tiny helpers ---------- */ + function $(id) { return document.getElementById(id); } + function el(tag, cls, text) { + var n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; + } + function mulberry32(seed) { + var t = seed >>> 0; + return function () { + t += 0x6D2B79F5; + var x = t; + x = Math.imul(x ^ (x >>> 15), x | 1); + x ^= x + Math.imul(x ^ (x >>> 7), x | 61); + return ((x ^ (x >>> 14)) >>> 0) / 4294967296; + }; + } + function todayInfo() { + var d = new Date(); + var y = d.getFullYear(), m = d.getMonth(), day = d.getDate(); + return { + dateStr: y + '-' + String(m + 1).padStart(2, '0') + '-' + String(day).padStart(2, '0'), + seed: y * 10000 + (m + 1) * 100 + day, + dayNum: Math.floor((Date.UTC(y, m, day) - DAY_EPOCH) / 86400000) + }; + } + function isNight() { + var h = new Date().getHours(); + return h >= 22 || h < 5; + } + function reducedMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } + + /* ---------- state ---------- */ + function defaultState() { + var order = {}; + STARTERS.forEach(function (id, i) { order[id] = i + 1; }); + return { + elements: STARTERS.slice(), + order: order, + recipes: [], + favorites: [], + newItems: [], + daily: { date: '', goalId: null, fallbackN: 0, count: 0, done: false, usedHint: false }, + hints: { stored: HINT_MAX, lastRegen: Date.now(), used: 0 }, + settings: { theme: 'auto', sound: true }, + stats: { fails: 0, successes: 0, hintsUsed: 0, secrets: 0, dailyDone: 0, playMs: 0, combos: {} }, + lastActive: Date.now(), + firstRun: true, + eggClicks: 0 + }; + } + + var state = load(); + + function load() { + try { + var raw = localStorage.getItem(SAVE_KEY); + if (!raw) return defaultState(); + var s = JSON.parse(raw); + var d = defaultState(); + // shallow-merge known keys, keep nested objects sane + var merged = Object.assign(d, s); + merged.daily = Object.assign(d.daily, s.daily || {}); + merged.hints = Object.assign(d.hints, s.hints || {}); + merged.settings = Object.assign(d.settings, s.settings || {}); + merged.stats = Object.assign(d.stats, s.stats || {}); + // drop elements that no longer exist in data + merged.elements = merged.elements.filter(function (id) { return ELEMENTS[id]; }); + STARTERS.forEach(function (id) { + if (merged.elements.indexOf(id) === -1) merged.elements.push(id); + }); + return merged; + } catch (e) { + return defaultState(); + } + } + + function save() { + var now = Date.now(); + var delta = now - (state.lastActive || now); + if (delta > 0) state.stats.playMs += Math.min(delta, 5 * 60 * 1000); + state.lastActive = now; + try { localStorage.setItem(SAVE_KEY, JSON.stringify(state)); } catch (e) { /* storage full/blocked */ } + } + + function has(id) { return state.elements.indexOf(id) !== -1; } + + /* ---------- audio ---------- */ + var actx = null; + function audioCtx() { + if (!actx) { + var AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return null; + actx = new AC(); + } + if (actx.state === 'suspended') actx.resume(); + return actx; + } + function tone(freq, delay, dur, type, vol) { + if (!state.settings.sound) return; + var ctx = audioCtx(); + if (!ctx) return; + var t = ctx.currentTime + delay; + var osc = ctx.createOscillator(); + var gain = ctx.createGain(); + osc.type = type || 'sine'; + osc.frequency.setValueAtTime(freq, t); + gain.gain.setValueAtTime(0.0001, t); + gain.gain.exponentialRampToValueAtTime(vol || 0.08, t + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, t + dur); + osc.connect(gain).connect(ctx.destination); + osc.start(t); + osc.stop(t + dur + 0.05); + } + var SOUNDS = { + select: function () { tone(660, 0, 0.06, 'triangle', 0.05); }, + combine: function () { tone(440, 0, 0.08, 'triangle', 0.06); tone(587, 0.07, 0.1, 'triangle', 0.06); }, + discover: function () { tone(523, 0, 0.09, 'sine', 0.07); tone(659, 0.08, 0.09, 'sine', 0.07); tone(784, 0.16, 0.14, 'sine', 0.08); }, + invalid: function () { tone(160, 0, 0.14, 'sawtooth', 0.04); }, + secret: function () { tone(880, 0, 0.08, 'sine', 0.06); tone(1108, 0.09, 0.08, 'sine', 0.06); tone(1318, 0.18, 0.2, 'sine', 0.07); } + }; + function playSound(name) { + if (SOUNDS[name]) SOUNDS[name](); + } + // WebAudio needs a gesture; warm up on first interaction. + document.addEventListener('pointerdown', function () { if (state.settings.sound) audioCtx(); }, { once: true }); + + /* ---------- toasts & announcements ---------- */ + function toast(msg) { + var box = $('toasts'); + var t = el('div', 'toast', msg); + box.appendChild(t); + setTimeout(function () { t.remove(); }, 3200); + } + function announce(msg) { + $('live').textContent = ''; + // force re-announcement + setTimeout(function () { $('live').textContent = msg; }, 30); + } + function setMessage(msg, isAnnounce) { + var m = $('message'); + m.textContent = msg; + m.classList.toggle('announce', !!isAnnounce); + if (isAnnounce) announce(msg); + } + + /* ---------- slots & combining ---------- */ + var slots = [null, null]; + var lastCombo = null; // {a, b} of last successful combo + var lastAction = Date.now(); + + function slotEls() { return [$('slotA'), $('slotB')]; } + + function renderSlots() { + slotEls().forEach(function (s, i) { + var id = slots[i]; + var emoji = s.querySelector('.slot-emoji'); + var name = s.querySelector('.slot-name'); + if (id) { + emoji.textContent = ELEMENTS[id].emoji; + name.textContent = ELEMENTS[id].name; + s.dataset.filled = 'true'; + s.setAttribute('aria-label', (i === 0 ? 'First' : 'Second') + ' ingredient: ' + ELEMENTS[id].name + '. Activate to remove.'); + } else { + emoji.textContent = '·'; + name.textContent = i === 0 ? 'first' : 'second'; + s.dataset.filled = 'false'; + s.setAttribute('aria-label', (i === 0 ? 'First' : 'Second') + ' ingredient slot. Empty.'); + } + }); + var both = slots[0] && slots[1]; + $('btnCombine').disabled = !both; + $('btnSwap').disabled = !(slots[0] || slots[1]); + $('btnRepeat').disabled = !(lastCombo && has(lastCombo.a) && has(lastCombo.b)); + } + + function fillNextSlot(id) { + if (!has(id)) return; + if (slots[0] === null) slots[0] = id; + else if (slots[1] === null) slots[1] = id; + else { slots[1] = id; } // both filled: replace second + playSound('select'); + markSeen(id); + renderSlots(); + touch(); + } + + function clearSlot(i) { + if (slots[i] !== null) { + slots[i] = null; + playSound('select'); + renderSlots(); + touch(); + } + } + + function combine() { + if (!slots[0] || !slots[1]) return; + var a = slots[0], b = slots[1]; + touch(); + var r = findRecipe(a, b); + + if (r && r.nightOnly && !isNight()) { + state.stats.fails++; + save(); + playSound('invalid'); + setMessage('Something about this feels nocturnal.'); + renderSlots(); + return; + } + + if (!r) { + state.stats.fails++; + bumpCombo(a); bumpCombo(b); + save(); + playSound('invalid'); + var line = FAILURE_LINES[Math.floor(Math.random() * FAILURE_LINES.length)]; + if (nearMiss(a, b) && Math.random() < 0.5) line += CLUE_SUFFIX; + setMessage(line); + renderSlots(); + return; + } + + lastCombo = { a: a, b: b }; + var resultId = r.result; + var already = has(resultId); + state.stats.successes++; + bumpCombo(a); bumpCombo(b); + + // charming fuse animation, then reveal + var sEls = slotEls(); + if (!reducedMotion()) { + sEls[0].classList.add('fusing'); + sEls[1].classList.add('fusing'); + } + var revealDelay = reducedMotion() ? 0 : 420; + setTimeout(function () { + sEls[0].classList.remove('fusing'); + sEls[1].classList.remove('fusing'); + revealResult(resultId, r); + if (already) { + setMessage('You already know this one: ' + ELEMENTS[resultId].name + '.', false); + playSound('combine'); + } else { + discoverElement(resultId, r); + } + save(); + renderSlots(); + }, revealDelay); + } + + function revealResult(resultId, r) { + var box = $('result'); + $('resultEmoji').textContent = ELEMENTS[resultId].emoji; + $('resultName').textContent = ELEMENTS[resultId].name; + box.setAttribute('aria-hidden', 'false'); + box.className = 'result'; + if (!reducedMotion()) { + void box.offsetWidth; // restart animation + box.classList.add('reveal'); + if (r.fx) box.classList.add('fx-' + r.fx); + } + } + + function discoverElement(id, r) { + state.elements.push(id); + state.order[id] = state.elements.length; + state.newItems.push(id); + var key = r.ordered ? r.a + '>' + r.b : [r.a, r.b].sort().join('|'); + if (state.recipes.indexOf(key) === -1) state.recipes.push(key); + + var isSecret = !!(r.secret || ELEMENTS[id].secret); + if (isSecret) { state.stats.secrets++; playSound('secret'); } + else playSound('discover'); + + var msg = r.msg; + if (r.msgs && r.msgs.length) { + msg = Math.random() < 0.5 ? r.msg : r.msgs[Math.floor(Math.random() * r.msgs.length)]; + } + setMessage(msg, true); + + updateDaily(id); + renderInventory(); + updateWorld(); + updateDailyLine(); + } + + function bumpCombo(id) { + state.stats.combos[id] = (state.stats.combos[id] || 0) + 1; + } + + // vague failure clue: an undiscovered recipe exists where one input is `a` + // and the other input shares a category with `b` (or vice versa) + function nearMiss(a, b) { + var catB = ELEMENTS[b].category, catA = ELEMENTS[a].category; + return RECIPES.some(function (r) { + if (has(r.result) || r.secret || r.nightOnly) return false; + var inputs = [r.a, r.b]; + var ia = inputs.indexOf(a), ib = inputs.indexOf(b); + if (ia !== -1) { + var other = inputs[1 - ia]; + return other !== b && ELEMENTS[other].category === catB; + } + if (ib !== -1) { + var other2 = inputs[1 - ib]; + return other2 !== a && ELEMENTS[other2].category === catA; + } + return false; + }); + } + + function touch() { + lastAction = Date.now(); + } + + /* ---------- daily challenge ---------- */ + function rollDaily() { + var info = todayInfo(); + if (state.daily.date === info.dateStr) return; + var rng = mulberry32(info.seed); + var undiscovered = Object.keys(ELEMENTS).filter(function (id) { + return STARTERS.indexOf(id) === -1 && !has(id); + }); + // prefer goals that are craftable right now + var craftable = undiscovered.filter(function (id) { + return (recipesByResult[id] || []).some(function (r) { return has(r.a) && has(r.b) && !r.nightOnly; }); + }); + var goalId = null, fallbackN = 0; + if (craftable.length) { + goalId = craftable[Math.floor(rng() * craftable.length)]; + } else if (undiscovered.length) { + goalId = undiscovered[Math.floor(rng() * undiscovered.length)]; + } else { + fallbackN = 3 + Math.floor(rng() * 3); + } + state.daily = { date: info.dateStr, goalId: goalId, fallbackN: fallbackN, count: 0, done: false, usedHint: false }; + save(); + } + + function updateDaily(newId) { + var d = state.daily; + if (d.done) return; + d.count++; + if (d.goalId && newId === d.goalId) completeDaily(); + else if (!d.goalId && d.fallbackN && d.count >= d.fallbackN) completeDaily(); + } + + function completeDaily() { + state.daily.done = true; + state.stats.dailyDone++; + toast('Daily challenge complete! ✨'); + playSound('secret'); + updateDailyLine(); + } + + function dailyShareText() { + var info = todayInfo(); + var d = state.daily; + var goal = d.goalId ? ELEMENTS[d.goalId].emoji + ' ' + ELEMENTS[d.goalId].name : d.fallbackN + ' new discoveries'; + var lines = [ + 'EMOJESIS #' + info.dayNum, + 'Goal: ' + goal, + d.count + ' discoveries · ' + (d.usedHint ? 'with hints 💡' : 'no hints ✨'), + d.done ? 'Completed ✅' : 'In progress…' + ]; + return lines.join('\n'); + } + + function updateDailyLine() { + var d = state.daily; + var line = $('dailyLine'); + if (!d.date) { line.hidden = true; return; } + line.hidden = false; + var goal = d.goalId ? 'Create ' + ELEMENTS[d.goalId].emoji + ' today' : 'Make ' + d.fallbackN + ' new discoveries today'; + line.textContent = (d.done ? '✅ ' : '📅 ') + goal + ' · ' + d.count + ' so far'; + $('dailyDot').hidden = !!d.done; + } + + /* ---------- hints ---------- */ + function regenHints() { + var now = Date.now(); + var elapsed = now - (state.hints.lastRegen || now); + if (elapsed >= HINT_REGEN_MS && state.hints.stored < HINT_MAX) { + var gained = Math.floor(elapsed / HINT_REGEN_MS); + state.hints.stored = Math.min(HINT_MAX, state.hints.stored + gained); + state.hints.lastRegen = now; + save(); + } + $('hintCount').textContent = state.hints.stored; + } + + function useHint() { + touch(); + if (state.hints.stored <= 0) { + toast('No hints stored. One drifts in every 10 minutes.'); + playSound('invalid'); + return; + } + var candidates = RECIPES.filter(function (r) { + return !has(r.result) && !r.secret && !r.nightOnly && has(r.a) && has(r.b); + }); + if (!candidates.length) { + toast('No hint available right now — try combining things you haven\u2019t paired yet.'); + return; + } + var r = candidates[Math.floor(Math.random() * candidates.length)]; + state.hints.stored--; + state.hints.used++; + state.stats.hintsUsed++; + state.daily.usedHint = true; + save(); + regenHints(); + playSound('select'); + var eA = ELEMENTS[r.a], eB = ELEMENTS[r.b]; + setMessage('💡 ' + eA.emoji + ' ' + eA.name + ' still has something to teach ' + eB.emoji + ' ' + eB.name + '.', true); + } + + // free hint after 90s of inactivity with 0 stored + setInterval(function () { + if (state.hints.stored === 0 && Date.now() - lastAction >= IDLE_HINT_MS) { + state.hints.stored = 1; + state.hints.lastRegen = Date.now(); + save(); + regenHints(); + toast('A thought drifted in. (+1 hint)'); + } + regenHints(); + }, 30000); + + /* ---------- inventory ---------- */ + var invFilter = { search: '', category: 'all', favOnly: false, showUnknown: false, sort: 'order' }; + + function unlockedCategories() { + var cats = {}; + state.elements.forEach(function (id) { cats[ELEMENTS[id].category] = true; }); + return CATEGORIES.filter(function (c) { return cats[c]; }); + } + + function renderCategoryChips() { + var box = $('invCats'); + box.innerHTML = ''; + var mk = function (value, label, pressed) { + var b = el('button', 'chip', label); + b.setAttribute('aria-pressed', pressed ? 'true' : 'false'); + b.addEventListener('click', function () { + invFilter.category = value; + renderInventory(); + }); + return b; + }; + box.appendChild(mk('all', 'all ' + state.elements.length, invFilter.category === 'all')); + unlockedCategories().forEach(function (c) { + var n = state.elements.filter(function (id) { return ELEMENTS[id].category === c; }).length; + box.appendChild(mk(c, c + ' ' + n, invFilter.category === c)); + }); + } + + function markSeen(id) { + var i = state.newItems.indexOf(id); + if (i !== -1) { + state.newItems.splice(i, 1); + save(); + var card = document.querySelector('.inv-item[data-id="' + id + '"]'); + if (card) card.classList.remove('is-new'); + } + } + + function renderInventory() { + renderCategoryChips(); + var grid = $('invGrid'); + grid.innerHTML = ''; + $('invCount').textContent = state.elements.length + '/' + Object.keys(ELEMENTS).length; + + var ids = state.elements.slice(); + if (invFilter.search) { + var q = invFilter.search.toLowerCase(); + ids = ids.filter(function (id) { return ELEMENTS[id].name.toLowerCase().indexOf(q) !== -1; }); + } + if (invFilter.category !== 'all') { + ids = ids.filter(function (id) { return ELEMENTS[id].category === invFilter.category; }); + } + if (invFilter.favOnly) { + ids = ids.filter(function (id) { return state.favorites.indexOf(id) !== -1; }); + } + ids.sort(function (x, y) { + var fx = state.favorites.indexOf(x) !== -1 ? 0 : 1; + var fy = state.favorites.indexOf(y) !== -1 ? 0 : 1; + if (fx !== fy) return fx - fy; + if (invFilter.sort === 'alpha') return ELEMENTS[x].name.localeCompare(ELEMENTS[y].name); + return (state.order[x] || 0) - (state.order[y] || 0); + }); + + ids.forEach(function (id) { grid.appendChild(buildCard(id)); }); + + if (invFilter.showUnknown && !invFilter.search && invFilter.favOnly === false) { + Object.keys(ELEMENTS).forEach(function (id) { + var e = ELEMENTS[id]; + if (has(id) || e.secret) return; + if (invFilter.category !== 'all' && e.category !== invFilter.category) return; + grid.appendChild(buildUnknownCard(e)); + }); + } + + if (!grid.children.length) { + grid.appendChild(el('p', 'inv-empty', invFilter.favOnly ? 'No favorites yet. Star something you love.' : 'Nothing here yet.')); + } + } + + function buildCard(id) { + var e = ELEMENTS[id]; + var card = el('div', 'inv-item'); + card.dataset.id = id; + if (state.newItems.indexOf(id) !== -1) card.classList.add('is-new'); + if (state.firstRun && (id === 'fire' || id === 'water')) card.classList.add('ob-pulse'); + + var main = el('button', 'inv-main'); + main.draggable = true; + main.setAttribute('aria-label', e.name + ' (' + e.category + '). Activate to place in a combination slot.'); + var em = el('span', 'inv-emoji', e.emoji); + em.setAttribute('aria-hidden', 'true'); + main.appendChild(em); + main.appendChild(el('span', 'inv-name', e.name)); + main.addEventListener('click', function () { + if (state.firstRun) { state.firstRun = false; save(); clearOnboardingPulse(); } + fillNextSlot(id); + }); + main.addEventListener('dragstart', function (ev) { + ev.dataTransfer.setData('text/plain', id); + ev.dataTransfer.effectAllowed = 'copy'; + }); + + var fav = el('button', 'inv-fav', state.favorites.indexOf(id) !== -1 ? '⭐' : '☆'); + if (state.favorites.indexOf(id) !== -1) fav.classList.add('on'); + fav.setAttribute('aria-label', 'Toggle favorite for ' + e.name); + fav.addEventListener('click', function () { toggleFavorite(id); }); + + var info = el('button', 'inv-info', 'ⓘ'); + info.setAttribute('aria-label', 'Open journal entry for ' + e.name); + info.addEventListener('click', function () { openJournal(id); }); + + card.appendChild(main); + card.appendChild(fav); + card.appendChild(info); + return card; + } + + function buildUnknownCard(e) { + var card = el('div', 'inv-item'); + var main = el('button', 'inv-main'); + main.setAttribute('aria-label', 'Undiscovered element in category ' + e.category); + var em = el('span', 'inv-emoji', '❓'); + em.setAttribute('aria-hidden', 'true'); + main.appendChild(em); + main.appendChild(el('span', 'inv-name', e.category)); + main.addEventListener('click', function () { + setMessage('Something undiscovered in ' + e.category + '. The laboratory waits.', false); + }); + card.appendChild(main); + return card; + } + + function toggleFavorite(id) { + var i = state.favorites.indexOf(id); + if (i === -1) state.favorites.push(id); + else state.favorites.splice(i, 1); + save(); + playSound('select'); + renderInventory(); + } + + function clearOnboardingPulse() { + document.querySelectorAll('.ob-pulse').forEach(function (n) { n.classList.remove('ob-pulse'); }); + } + + /* ---------- journal ---------- */ + var journalId = null; + + function openJournal(id) { + journalId = id; + markSeen(id); + var e = ELEMENTS[id]; + var body = $('journalBody'); + body.innerHTML = ''; + + var head = el('div', 'journal-head'); + head.appendChild(el('span', 'journal-emoji', e.emoji)); + var titleWrap = el('div'); + var h2 = el('h2', null, e.name); + h2.id = 'journalTitle'; + titleWrap.appendChild(h2); + titleWrap.appendChild(el('div', 'journal-meta', + e.category + ' · discovery #' + (state.order[id] || '?'))); + head.appendChild(titleWrap); + body.appendChild(head); + body.appendChild(el('p', null, e.description)); + + var madeBy = (recipesByResult[id] || []).filter(function (r) { return recipeDiscovered(r); }); + if (madeBy.length) { + body.appendChild(el('h3', 'journal-sec', 'Created by')); + body.appendChild(recipeList(madeBy)); + } + var usedIn = (recipesByInput[id] || []).filter(function (r) { return recipeDiscovered(r) && has(r.result); }); + if (usedIn.length) { + body.appendChild(el('h3', 'journal-sec', 'Used in')); + body.appendChild(recipeList(usedIn)); + } + + // vague hint about undiscovered relationships + var unknownCats = {}; + (recipesByInput[id] || []).forEach(function (r) { + if (!has(r.result)) { + var other = r.a === id ? r.b : r.a; + if (has(other)) unknownCats[ELEMENTS[r.result].category] = true; + } + }); + var cats = Object.keys(unknownCats); + if (cats.length) { + body.appendChild(el('p', 'journal-hint', + 'It still reacts with something in ' + cats.join(' and ') + '.')); + } + openModal('modalJournal'); + } + + function recipeDiscovered(r) { + var key = r.ordered ? r.a + '>' + r.b : [r.a, r.b].sort().join('|'); + return state.recipes.indexOf(key) !== -1; + } + + function recipeList(recipes) { + var ul = el('ul', 'journal-recipes'); + recipes.forEach(function (r) { + var li = el('li', null, + ELEMENTS[r.a].emoji + ' ' + ELEMENTS[r.a].name + ' + ' + + ELEMENTS[r.b].emoji + ' ' + ELEMENTS[r.b].name + ' → ' + + ELEMENTS[r.result].emoji + ' ' + ELEMENTS[r.result].name); + ul.appendChild(li); + }); + return ul; + } + + /* ---------- world view ---------- */ + function updateWorld() { + document.querySelectorAll('.w-slot').forEach(function (slot) { + slot.classList.toggle('found', has(slot.dataset.el)); + }); + } + + /* ---------- stats ---------- */ + function renderStats() { + var dl = $('statsList'); + dl.innerHTML = ''; + var totalEl = Object.keys(ELEMENTS).length; + var catDone = unlockedCategories().map(function (c) { + var total = Object.keys(ELEMENTS).filter(function (id) { return ELEMENTS[id].category === c; }).length; + var got = state.elements.filter(function (id) { return ELEMENTS[id].category === c; }).length; + return c + ' ' + got + '/' + total; + }); + var favId = null, favN = 0; + Object.keys(state.stats.combos).forEach(function (id) { + if (state.stats.combos[id] > favN) { favN = state.stats.combos[id]; favId = id; } + }); + var mins = Math.floor(state.stats.playMs / 60000); + var rows = [ + ['elements', state.elements.length + ' / ' + totalEl], + ['recipes', state.recipes.length + ' / ' + RECIPES.length], + ['categories', catDone.join(' · ')], + ['experiments ✓', String(state.stats.successes)], + ['experiments ✗', String(state.stats.fails)], + ['hints used', String(state.stats.hintsUsed)], + ['secrets found', String(state.stats.secrets)], + ['daily challenges', String(state.stats.dailyDone)], + ['favorites', String(state.favorites.length)], + ['most combined', favId ? ELEMENTS[favId].emoji + ' ' + ELEMENTS[favId].name + ' (' + favN + ')' : '—'], + ['time in lab', mins + ' min'] + ]; + rows.forEach(function (row) { + dl.appendChild(el('dt', null, row[0])); + dl.appendChild(el('dd', null, row[1])); + }); + } + + /* ---------- modals ---------- */ + function openModal(id) { + var m = $(id); + m.hidden = false; + var btn = m.querySelector('button, input, select'); + if (btn) btn.focus(); + } + function closeModal(id) { + $(id).hidden = true; + } + document.addEventListener('click', function (ev) { + var closer = ev.target.closest('.modal-close'); + if (closer) closeModal(closer.dataset.close); + var modal = ev.target.classList && ev.target.classList.contains('modal') ? ev.target : null; + if (modal) modal.hidden = true; + }); + document.addEventListener('keydown', function (ev) { + if (ev.key === 'Escape') { + document.querySelectorAll('.modal:not([hidden])').forEach(function (m) { m.hidden = true; }); + } + }); + + /* ---------- theme & sound ---------- */ + function applyTheme() { + var t = state.settings.theme; + if (t === 'auto') document.documentElement.removeAttribute('data-theme'); + else document.documentElement.setAttribute('data-theme', t); + $('btnTheme').textContent = t === 'dark' ? '🌙' : t === 'light' ? '☀️' : '🌗'; + $('setTheme').value = t; + } + function cycleTheme() { + var order = ['auto', 'light', 'dark']; + var i = order.indexOf(state.settings.theme); + state.settings.theme = order[(i + 1) % order.length]; + save(); + applyTheme(); + } + function applySound() { + $('btnSound').textContent = state.settings.sound ? '🔊' : '🔇'; + $('btnSound').setAttribute('aria-label', state.settings.sound ? 'Mute sound' : 'Unmute sound'); + $('setSound').checked = state.settings.sound; + } + + /* ---------- save export / import / reset ---------- */ + function exportSave() { + var blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'emojesis-save.json'; + a.click(); + setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000); + } + function importSave(file) { + var reader = new FileReader(); + reader.onload = function () { + try { + var s = JSON.parse(reader.result); + if (!s || !Array.isArray(s.elements)) throw new Error('bad save'); + localStorage.setItem(SAVE_KEY, JSON.stringify(s)); + location.reload(); + } catch (e) { + toast('That file is not an EMOJESIS save.'); + } + }; + reader.readAsText(file); + } + + /* ---------- easter egg: title clicking ---------- */ + var eggTimer = null; + function titleClick() { + state.eggClicks++; + save(); + clearTimeout(eggTimer); + eggTimer = setTimeout(function () { + if (state.eggClicks < 7) { state.eggClicks = 0; save(); } + }, 2500); + if (state.eggClicks === 7) { + toast('Stop that.'); + playSound('invalid'); + } else if (state.eggClicks >= 10) { + state.eggClicks = 0; + if (!has('egg')) { + state.elements.push('egg'); + state.order.egg = state.elements.length; + state.newItems.push('egg'); + state.stats.secrets++; + save(); + renderInventory(); + updateWorld(); + playSound('secret'); + toast('…fine. 🥚'); + } else { + toast('You already have the egg. There is no second egg.'); + } + } else { + playSound('select'); + } + } + + /* ---------- easter egg: terminal ---------- */ + function termPrint(text) { + var out = $('termOut'); + out.textContent += text + '\n'; + out.scrollTop = out.scrollHeight; + } + function termRun(cmdRaw) { + var cmd = cmdRaw.trim().toLowerCase(); + if (!cmd) return; + termPrint('> ' + cmdRaw); + if (cmd === 'help') { + termPrint('available commands:\n help — this\n about — what is this place\n sudo — escalate\n netuno — listen to the outer dark\n feed — subscribe to the signal\n clear — wipe the glass\n exit — leave'); + } else if (cmd === 'about') { + termPrint('EMOJESIS v1.0 — a tiny universe made of symbols.\nPablo Murad, 2026.\nno accounts. no tracking. no ads. only combinations.'); + } else if (cmd === 'sudo') { + termPrint('permission granted. you were always the admin of this universe.'); + } else if (cmd === 'netuno') { + if (!has('netuno')) { + state.elements.push('netuno'); + state.order.netuno = state.elements.length; + state.newItems.push('netuno'); + state.stats.secrets++; + save(); + renderInventory(); + updateWorld(); + playSound('secret'); + termPrint('🜲 NETUNO: signal received from the outer dark.\n🪐 Netuno has been added to your collection.'); + toast('🪐 Netuno discovered.'); + } else { + termPrint('🜲 NETUNO: the signal is already within you.'); + } + } else if (cmd === 'feed') { + termPrint('the signal broadcasts at ./feed.xml — bring your own reader.'); + } else if (cmd === 'clear') { + $('termOut').textContent = ''; + } else if (cmd === 'exit') { + closeModal('modalTerm'); + } else if (cmd === 'ls') { + termPrint('fire water earth air … ' + state.elements.length + ' symbols and counting'); + } else if (cmd === 'whoami') { + termPrint('a person combining symbols at ' + new Date().toLocaleTimeString() + '.'); + } else { + termPrint('command not found: ' + cmd + ' — try \u2019help\u2019'); + } + } + + /* ---------- onboarding ---------- */ + function showOnboarding() { + $('onboarding').hidden = false; + $('btnBegin').focus(); + } + + /* ---------- wiring ---------- */ + function init() { + regenHints(); + rollDaily(); + applyTheme(); + applySound(); + renderSlots(); + renderInventory(); + updateWorld(); + updateDailyLine(); + + // slots: click to clear + slotEls().forEach(function (s, i) { + s.addEventListener('click', function () { clearSlot(i); }); + s.addEventListener('dragover', function (ev) { + ev.preventDefault(); + s.classList.add('drop-hover'); + }); + s.addEventListener('dragleave', function () { s.classList.remove('drop-hover'); }); + s.addEventListener('drop', function (ev) { + ev.preventDefault(); + s.classList.remove('drop-hover'); + var id = ev.dataTransfer.getData('text/plain'); + if (id && has(id)) { + slots[i] = id; + playSound('select'); + markSeen(id); + if (state.firstRun) { state.firstRun = false; save(); clearOnboardingPulse(); } + renderSlots(); + touch(); + } + }); + }); + + $('btnCombine').addEventListener('click', combine); + $('btnSwap').addEventListener('click', function () { + var t = slots[0]; slots[0] = slots[1]; slots[1] = t; + playSound('select'); + renderSlots(); + touch(); + }); + $('btnRepeat').addEventListener('click', function () { + if (lastCombo && has(lastCombo.a) && has(lastCombo.b)) { + slots[0] = lastCombo.a; + slots[1] = lastCombo.b; + renderSlots(); + combine(); + } + }); + + // inventory tools + $('invSearch').addEventListener('input', function (ev) { + invFilter.search = ev.target.value; + renderInventory(); + }); + $('invSort').addEventListener('change', function (ev) { + invFilter.sort = ev.target.value; + renderInventory(); + }); + $('invFav').addEventListener('click', function () { + invFilter.favOnly = !invFilter.favOnly; + $('invFav').setAttribute('aria-pressed', invFilter.favOnly ? 'true' : 'false'); + renderInventory(); + }); + var unknownChip = el('button', 'chip', '❓'); + unknownChip.setAttribute('aria-pressed', 'false'); + unknownChip.title = 'Show undiscovered silhouettes'; + unknownChip.setAttribute('aria-label', 'Show undiscovered elements'); + unknownChip.addEventListener('click', function () { + invFilter.showUnknown = !invFilter.showUnknown; + unknownChip.setAttribute('aria-pressed', invFilter.showUnknown ? 'true' : 'false'); + renderInventory(); + }); + document.querySelector('.inv-tools').appendChild(unknownChip); + + // inventory drawer (mobile) / collapse (desktop) + $('invToggle').addEventListener('click', function () { + var inv = $('inventory'); + if (window.matchMedia('(max-width: 860px)').matches) { + inv.classList.toggle('open'); + $('invToggle').setAttribute('aria-expanded', inv.classList.contains('open') ? 'true' : 'false'); + } else { + inv.classList.toggle('collapsed'); + $('invToggle').setAttribute('aria-expanded', inv.classList.contains('collapsed') ? 'false' : 'true'); + } + }); + + // world toggle + $('worldToggle').addEventListener('click', function () { + var w = $('world'); + w.classList.toggle('collapsed'); + $('worldToggle').setAttribute('aria-expanded', w.classList.contains('collapsed') ? 'false' : 'true'); + }); + + // header buttons + $('btnHint').addEventListener('click', useHint); + $('btnTheme').addEventListener('click', cycleTheme); + $('btnSound').addEventListener('click', function () { + state.settings.sound = !state.settings.sound; + save(); + applySound(); + if (state.settings.sound) playSound('select'); + }); + $('btnStats').addEventListener('click', function () { renderStats(); openModal('modalStats'); }); + $('btnAbout').addEventListener('click', function () { openModal('modalAbout'); }); + $('footAbout').addEventListener('click', function () { openModal('modalAbout'); }); + $('btnDaily').addEventListener('click', function () { + rollDaily(); + var d = state.daily; + $('dailyGoal').textContent = d.goalId + ? 'Create ' + ELEMENTS[d.goalId].emoji + ' ' + ELEMENTS[d.goalId].name + ' today.' + : 'Make ' + d.fallbackN + ' new discoveries today.'; + $('dailyProgress').textContent = d.count + ' discoveries so far' + (d.done ? ' — completed ✅' : '.'); + $('dailyShare').textContent = dailyShareText(); + openModal('modalDaily'); + }); + $('btnCopyDaily').addEventListener('click', function () { + var text = dailyShareText(); + var doneOk = function () { toast('Copied. Share it somewhere small and personal.'); }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(doneOk, function () { fallbackCopy(text); doneOk(); }); + } else { + fallbackCopy(text); + doneOk(); + } + }); + + // journal "send to slot" + $('journalUse').addEventListener('click', function () { + if (journalId) fillNextSlot(journalId); + closeModal('modalJournal'); + }); + + // settings + $('setTheme').addEventListener('change', function (ev) { + state.settings.theme = ev.target.value; + save(); + applyTheme(); + }); + $('setSound').addEventListener('change', function (ev) { + state.settings.sound = ev.target.checked; + save(); + applySound(); + }); + $('btnExport').addEventListener('click', exportSave); + $('btnImport').addEventListener('click', function () { $('importFile').click(); }); + $('importFile').addEventListener('change', function (ev) { + if (ev.target.files && ev.target.files[0]) importSave(ev.target.files[0]); + }); + $('btnReset').addEventListener('click', function () { $('resetConfirm').hidden = false; }); + $('btnResetNo').addEventListener('click', function () { $('resetConfirm').hidden = true; }); + $('btnResetYes').addEventListener('click', function () { + localStorage.removeItem(SAVE_KEY); + location.reload(); + }); + + // easter eggs + $('titleEmoji').addEventListener('click', titleClick); + $('footTerminal').addEventListener('click', function () { + openModal('modalTerm'); + if (!$('termOut').textContent) { + termPrint('EMOJESIS shell v1.0 — type \u2019help\u2019.'); + } + $('termInput').focus(); + }); + $('termForm').addEventListener('submit', function (ev) { + ev.preventDefault(); + var v = $('termInput').value; + $('termInput').value = ''; + termRun(v); + }); + + // onboarding + $('btnBegin').addEventListener('click', function () { + $('onboarding').hidden = true; + touch(); + }); + if (state.firstRun && state.elements.length <= STARTERS.length) { + showOnboarding(); + } + + // activity tracking for the idle hint + ['pointerdown', 'keydown'].forEach(function (evt) { + document.addEventListener(evt, touch, { passive: true }); + }); + + // re-roll daily if the date changes while open + setInterval(function () { + var info = todayInfo(); + if (state.daily.date !== info.dateStr) { + rollDaily(); + updateDailyLine(); + toast('A new day, a new challenge. 📅'); + } + }, 60000); + } + + function fallbackCopy(text) { + var ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); } catch (e) { /* best effort */ } + ta.remove(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + // optional offline cache; never registered on file:// + if ('serviceWorker' in navigator && location.protocol.indexOf('http') === 0) { + navigator.serviceWorker.register('sw.js').catch(function () { /* offline cache unavailable */ }); + } +})();
A
js/data.js
@@ -0,0 +1,359 @@
+/* EMOJESIS — game data. Pure data, no DOM. + Works in the browser (attaches to globalThis.EMOJESIS_DATA) + and in Node (module.exports) for validate.js. */ +(function () { + 'use strict'; + + var CATEGORIES = [ + 'nature', 'weather', 'life', 'animals', 'humanity', 'civilization', + 'culture', 'technology', 'internet', 'occult', 'cosmos', 'absurd' + ]; + + var STARTERS = ['fire', 'water', 'earth', 'air']; + + /* Element: { id, emoji, name, category, description, secret? } */ + var ELEMENTS = { + /* ---- starters ---- */ + fire: { id: 'fire', emoji: '🔥', name: 'Fire', category: 'nature', description: 'Warmth, destruction, and the first bad idea.' }, + water: { id: 'water', emoji: '💧', name: 'Water', category: 'nature', description: 'It remembers every shape it has ever been.' }, + earth: { id: 'earth', emoji: '🪨', name: 'Earth', category: 'nature', description: 'Patient, heavy, and full of opinions.' }, + air: { id: 'air', emoji: '💨', name: 'Air', category: 'nature', description: 'Invisible, everywhere, and slightly dramatic.' }, + + /* ---- nature ---- */ + mud: { id: 'mud', emoji: '🟤', name: 'Mud', category: 'nature', description: 'Earth that gave up on posture.' }, + lava: { id: 'lava', emoji: '🌋', name: 'Lava', category: 'nature', description: 'The planet\u2019s way of thinking out loud.' }, + ocean: { id: 'ocean', emoji: '🌊', name: 'Ocean', category: 'nature', description: 'Water, ambition edition.' }, + mountain: { id: 'mountain', emoji: '⛰️', name: 'Mountain', category: 'nature', description: 'A very long argument with the sky.' }, + wood: { id: 'wood', emoji: '🪵', name: 'Wood', category: 'nature', description: 'A tree, repurposed.' }, + honey: { id: 'honey', emoji: '🍯', name: 'Honey', category: 'nature', description: 'A lifetime of small errands, bottled.' }, + + /* ---- weather ---- */ + steam: { id: 'steam', emoji: '🌫️', name: 'Steam', category: 'weather', description: 'Opposites, agreeing to disagree upward.' }, + cloud: { id: 'cloud', emoji: '☁️', name: 'Cloud', category: 'weather', description: 'The sky\u2019s rough draft.' }, + rain: { id: 'rain', emoji: '🌧️', name: 'Rain', category: 'weather', description: 'The cloud finally admitted something.' }, + wind: { id: 'wind', emoji: '🌬️', name: 'Wind', category: 'weather', description: 'Air with somewhere to be.' }, + storm: { id: 'storm', emoji: '⛈️', name: 'Storm', category: 'weather', description: 'Two clouds having it out.' }, + lightning: { id: 'lightning', emoji: '⚡', name: 'Lightning', category: 'weather', description: 'The sky, briefly honest.' }, + snow: { id: 'snow', emoji: '❄️', name: 'Snow', category: 'weather', description: 'Rain, but it learned to whisper.' }, + rainbow: { id: 'rainbow', emoji: '🌈', name: 'Rainbow', category: 'weather', description: 'An apology from the weather.' }, + fog: { id: 'fog', emoji: '🌁', name: 'Fog', category: 'weather', description: 'A cloud that lost its nerve.' }, + + /* ---- life ---- */ + life: { id: 'life', emoji: '🌱', name: 'Life', category: 'life', description: 'The mud flinched first.' }, + plant: { id: 'plant', emoji: '🌿', name: 'Plant', category: 'life', description: 'Life, rooted and smug about it.' }, + tree: { id: 'tree', emoji: '🌳', name: 'Tree', category: 'life', description: 'Patience, made visible.' }, + flower: { id: 'flower', emoji: '🌸', name: 'Flower', category: 'life', description: 'The plant\u2019s loud opinion.' }, + wheat: { id: 'wheat', emoji: '🌾', name: 'Wheat', category: 'life', description: 'Grass with ambitions.' }, + forest: { id: 'forest', emoji: '🌲', name: 'Forest', category: 'life', description: 'Trees discovered crowds.' }, + + /* ---- animals ---- */ + fish: { id: 'fish', emoji: '🐟', name: 'Fish', category: 'animals', description: 'Life learned to swim before it learned to walk. Priorities.' }, + bird: { id: 'bird', emoji: '🐦', name: 'Bird', category: 'animals', description: 'Life, ignoring gravity\u2019s memo.' }, + egg: { id: 'egg', emoji: '🥚', name: 'Egg', category: 'animals', description: 'A bird\u2019s most confident decision.' }, + cat: { id: 'cat', emoji: '🐈', name: 'Cat', category: 'animals', description: 'Something small decided to live with you.' }, + dog: { id: 'dog', emoji: '🐶', name: 'Dog', category: 'animals', description: 'The wolf made an excellent mistake.' }, + wolf: { id: 'wolf', emoji: '🐺', name: 'Wolf', category: 'animals', description: 'The mountain learned to hunt.' }, + bee: { id: 'bee', emoji: '🐝', name: 'Bee', category: 'animals', description: 'A flower\u2019s courier service.' }, + snake: { id: 'snake', emoji: '🐍', name: 'Snake', category: 'animals', description: 'The garden got interesting.' }, + whale: { id: 'whale', emoji: '🐋', name: 'Whale', category: 'animals', description: 'Time and water, taken seriously.' }, + owl: { id: 'owl', emoji: '🦉', name: 'Night Owl', category: 'animals', secret: true, description: 'It only interviews at night.' }, + dinosaur: { id: 'dinosaur', emoji: '🦖', name: 'Dinosaur', category: 'animals', description: 'Some experiments take longer.' }, + butterfly: { id: 'butterfly', emoji: '🦋', name: 'Butterfly', category: 'animals', description: 'A rumor with wings.' }, + + /* ---- humanity ---- */ + human: { id: 'human', emoji: '🧑', name: 'Human', category: 'humanity', description: 'From mud, something stood up.' }, + brain: { id: 'brain', emoji: '🧠', name: 'Brain', category: 'humanity', description: 'A library that argues back.' }, + love: { id: 'love', emoji: '❤️', name: 'Love', category: 'humanity', description: 'A self-combination with consequences.' }, + + /* ---- civilization ---- */ + tool: { id: 'tool', emoji: '🔨', name: 'Tool', category: 'civilization', description: 'The rock was promoted.' }, + house: { id: 'house', emoji: '🏠', name: 'House', category: 'civilization', description: 'A tree, forgiven.' }, + village: { id: 'village', emoji: '🏘️', name: 'Village', category: 'civilization', description: 'Houses discovered gossip.' }, + city: { id: 'city', emoji: '🏙️', name: 'City', category: 'civilization', description: 'A village that stopped sleeping.' }, + wheel: { id: 'wheel', emoji: '🛞', name: 'Wheel', category: 'civilization', description: 'The circle\u2019s career began.' }, + bread: { id: 'bread', emoji: '🍞', name: 'Bread', category: 'civilization', description: 'Grain, transformed by impatience.' }, + coffee: { id: 'coffee', emoji: '☕', name: 'Coffee', category: 'civilization', description: 'The bean demanded fire.' }, + paper: { id: 'paper', emoji: '📄', name: 'Paper', category: 'civilization', description: 'The tree, flattened into memory.' }, + book: { id: 'book', emoji: '📚', name: 'Book', category: 'civilization', description: 'Paper with a spine and opinions.' }, + metal: { id: 'metal', emoji: '🔩', name: 'Metal', category: 'civilization', description: 'The mountain\u2019s hidden temper.' }, + sword: { id: 'sword', emoji: '⚔️', name: 'Sword', category: 'civilization', description: 'An argument made of metal.' }, + boat: { id: 'boat', emoji: '⛵', name: 'Boat', category: 'civilization', description: 'The wood forgave the water.' }, + ship: { id: 'ship', emoji: '🚢', name: 'Ship', category: 'civilization', description: 'A boat that stopped being polite.' }, + bridge: { id: 'bridge', emoji: '🌉', name: 'Bridge', category: 'civilization', description: 'The mountain\u2019s shortcut.' }, + castle: { id: 'castle', emoji: '🏰', name: 'Castle', category: 'civilization', description: 'A house with trust issues.' }, + engine: { id: 'engine', emoji: '🚂', name: 'Engine', category: 'civilization', description: 'Steam, given a job.' }, + gold: { id: 'gold', emoji: '🪙', name: 'Gold', category: 'civilization', description: 'Shinier with age.' }, + money: { id: 'money', emoji: '💵', name: 'Money', category: 'civilization', description: 'Gold that fits in a pocket.' }, + key: { id: 'key', emoji: '🗝️', name: 'Key', category: 'civilization', description: 'It opens exactly one thing. Good luck.' }, + + /* ---- culture ---- */ + writing: { id: 'writing', emoji: '✍️', name: 'Writing', category: 'culture', description: 'Thoughts, taxidermied.' }, + art: { id: 'art', emoji: '🎨', name: 'Art', category: 'culture', description: 'The rainbow, on purpose.' }, + music: { id: 'music', emoji: '🎵', name: 'Music', category: 'culture', description: 'The bird\u2019s idea, stolen gracefully.' }, + philosophy: { id: 'philosophy', emoji: '🤔', name: 'Philosophy', category: 'culture', description: 'Two books arguing forever.' }, + forbiddenBook: { id: 'forbiddenBook', emoji: '📕', name: 'Forbidden Book', category: 'culture', secret: true, description: 'Some books are burned because they burn back.' }, + library: { id: 'library', emoji: '🏛️', name: 'Library', category: 'culture', description: 'A house that remembers for you.' }, + statue: { id: 'statue', emoji: '🗿', name: 'Statue', category: 'culture', description: 'Someone wanted to be remembered badly.' }, + theatre: { id: 'theatre', emoji: '🎭', name: 'Theatre', category: 'culture', description: 'Art that watches you back.' }, + idea: { id: 'idea', emoji: '💡', name: 'Idea', category: 'culture', description: 'A dangerous amount of electricity, safely contained.' }, + + /* ---- technology ---- */ + computer: { id: 'computer', emoji: '💻', name: 'Computer', category: 'technology', description: 'An idea with a fan.' }, + robot: { id: 'robot', emoji: '🤖', name: 'Robot', category: 'technology', description: 'A brain with a warranty.' }, + ai: { id: 'ai', emoji: '💭', name: 'AI', category: 'technology', description: 'The machine started finishing your sentences.' }, + developer: { id: 'developer', emoji: '👨💻', name: 'Developer', category: 'technology', description: 'Runs on caffeine and unresolved tickets.' }, + battery: { id: 'battery', emoji: '🔋', name: 'Battery', category: 'technology', description: 'Lightning, grounded.' }, + antenna: { id: 'antenna', emoji: '📡', name: 'Antenna', category: 'technology', description: 'A metal ear for invisible things.' }, + rocket: { id: 'rocket', emoji: '🚀', name: 'Rocket', category: 'technology', description: 'A ship that chose violence over water.' }, + satellite: { id: 'satellite', emoji: '🛰️', name: 'Satellite', category: 'technology', description: 'An antenna that moved out.' }, + telescope: { id: 'telescope', emoji: '🔭', name: 'Telescope', category: 'technology', description: 'The mountain had questions too.' }, + phone: { id: 'phone', emoji: '📱', name: 'Phone', category: 'technology', description: 'A computer for your pocket and your every waking moment.' }, + wifi: { id: 'wifi', emoji: '📶', name: 'Wi-Fi', category: 'technology', description: 'The internet, untethered and unbothered.' }, + syntheticHeart: { id: 'syntheticHeart', emoji: '🦾', name: 'Synthetic Heart', category: 'technology', secret: true, description: 'It beats. That\u2019s the disturbing part.' }, + + /* ---- internet ---- */ + internet: { id: 'internet', emoji: '🌐', name: 'Internet', category: 'internet', description: 'Two computers started talking. Nobody stopped them.' }, + link: { id: 'link', emoji: '🔗', name: 'Link', category: 'internet', description: 'A door made of text.' }, + personalWebsite: { id: 'personalWebsite', emoji: '🛖', name: 'Personal Website', category: 'internet', description: 'You made a home on the web.' }, + blog: { id: 'blog', emoji: '📰', name: 'Blog', category: 'internet', secret: true, description: 'A diary the whole world is welcome to ignore.' }, + rss: { id: 'rss', emoji: '🔁', name: 'RSS', category: 'internet', secret: true, description: 'The web, as it was meant to be read.' }, + internetCat: { id: 'internetCat', emoji: '🐱', name: 'Internet Cat', category: 'internet', description: 'The internet\u2019s true purpose.' }, + meme: { id: 'meme', emoji: '🤣', name: 'Meme', category: 'internet', description: 'An idea in its final, unstoppable form.' }, + deadWeb: { id: 'deadWeb', emoji: '🕸️', name: 'Dead Web', category: 'internet', secret: true, description: 'The links still point somewhere. Nothing answers.' }, + virus: { id: 'virus', emoji: '🦠', name: 'Virus', category: 'internet', description: 'It only wanted to multiply.' }, + glitch: { id: 'glitch', emoji: '👾', name: 'Glitch', category: 'internet', secret: true, description: 'The machine dreamed of a machine.' }, + + /* ---- occult ---- */ + skull: { id: 'skull', emoji: '💀', name: 'Skull', category: 'occult', description: 'Everyone\u2019s final form.' }, + ghost: { id: 'ghost', emoji: '👻', name: 'Ghost', category: 'occult', secret: true, description: 'Death, but it left the light on.' }, + undead: { id: 'undead', emoji: '🧟', name: 'Undead', category: 'occult', description: 'Love, unfortunately, finds a way.' }, + secret: { id: 'secret', emoji: '🔮', name: 'Secret', category: 'occult', secret: true, description: 'Some doors only open at night.' }, + blackHole: { id: 'blackHole', emoji: '🕳️', name: 'Black Hole', category: 'occult', secret: true, description: 'The universe\u2019s locked drawer.' }, + phoenix: { id: 'phoenix', emoji: '🐦🔥', name: 'Phoenix', category: 'occult', secret: true, description: 'It read the whole thing and came back louder.' }, + + /* ---- cosmos ---- */ + time: { id: 'time', emoji: '⏳', name: 'Time', category: 'cosmos', description: 'The world learned patience.' }, + night: { id: 'night', emoji: '🌃', name: 'Night', category: 'cosmos', description: 'Time, left alone, becomes night.' }, + moon: { id: 'moon', emoji: '🌙', name: 'Moon', category: 'cosmos', description: 'The sky\u2019s oldest rock.' }, + star: { id: 'star', emoji: '⭐', name: 'Star', category: 'cosmos', description: 'A light that outlived its fire.' }, + sun: { id: 'sun', emoji: '☀️', name: 'Sun', category: 'cosmos', secret: true, description: 'Fire, squared and glorified.' }, + galaxy: { id: 'galaxy', emoji: '🌌', name: 'Galaxy', category: 'cosmos', description: 'A city of suns.' }, + eclipse: { id: 'eclipse', emoji: '🌑', name: 'Eclipse', category: 'cosmos', secret: true, description: 'The sky\u2019s private joke.' }, + ufo: { id: 'ufo', emoji: '🛸', name: 'UFO', category: 'cosmos', secret: true, description: 'It blinked back.' }, + alien: { id: 'alien', emoji: '👽', name: 'Alien', category: 'cosmos', description: 'Life, from a different draft.' }, + netuno: { id: 'netuno', emoji: '🪐', name: 'Netuno', category: 'cosmos', secret: true, description: 'The outer dark has a name, and it\u2019s this one.' }, + supernova: { id: 'supernova', emoji: '💥', name: 'Supernova', category: 'cosmos', description: 'An ending so bright it counts as a beginning.' }, + + /* ---- absurd ---- */ + hotTake: { id: 'hotTake', emoji: '🌶️', name: 'Hot Take', category: 'absurd', description: 'Everyone has one. No one wants yours.' }, + ancientInfluencer: { id: 'ancientInfluencer', emoji: '🤳', name: 'Ancient Influencer', category: 'absurd', secret: true, description: 'Two million followers. Zero posts.' }, + meeting: { id: 'meeting', emoji: '📅', name: 'Meeting', category: 'absurd', description: 'Two developers produced a third meeting.' } + }; + + /* Recipe: { a, b, result, msg, msgs?, secret?, ordered?, nightOnly?, fx? } */ + var RECIPES = [ + /* ---- the teaching chain ---- */ + { a: 'fire', b: 'water', result: 'steam', msg: 'Opposites create atmosphere.' }, + { a: 'water', b: 'earth', result: 'mud', msg: 'Earth that gave up on posture.' }, + { a: 'air', b: 'water', result: 'cloud', msg: 'The sky\u2019s rough draft.' }, + { a: 'cloud', b: 'water', result: 'rain', msg: 'The cloud finally admitted something.' }, + { a: 'rain', b: 'earth', result: 'life', msg: 'The mud flinched first.' }, + { a: 'life', b: 'water', result: 'plant', msg: 'Life, rooted and smug about it.' }, + { a: 'plant', b: 'time', result: 'tree', msg: 'Patience, made visible.' }, + { a: 'tree', b: 'fire', result: 'wood', msg: 'A tree, repurposed.' }, + { a: 'earth', b: 'earth', result: 'mountain', msg: 'A very long argument with the sky.' }, + { a: 'human', b: 'earth', result: 'tool', msg: 'The rock was promoted.' }, + { a: 'steam', b: 'earth', result: 'time', msg: 'The world learned patience.' }, + + /* ---- nature & weather ---- */ + { a: 'fire', b: 'earth', result: 'lava', msg: 'The planet\u2019s way of thinking out loud.', fx: 'flare' }, + { a: 'water', b: 'water', result: 'ocean', msg: 'Water, ambition edition.' }, + { a: 'air', b: 'air', result: 'wind', msg: 'Air with somewhere to be.' }, + { a: 'cloud', b: 'cloud', result: 'storm', msg: 'Two clouds having it out.', fx: 'shake' }, + { a: 'storm', b: 'air', result: 'lightning', msg: 'The sky, briefly honest.', fx: 'zap' }, + { a: 'rain', b: 'wind', result: 'snow', msg: 'Rain, but it learned to whisper.' }, + { a: 'rain', b: 'sun', result: 'rainbow', msg: 'An apology from the weather.' }, + { a: 'steam', b: 'cloud', result: 'fog', msg: 'A cloud that lost its nerve.' }, + { a: 'lava', b: 'water', result: 'steam', msg: 'The planet sighed.' }, + { a: 'storm', b: 'water', result: 'rain', msg: 'The storm, softened.' }, + { a: 'steam', b: 'air', result: 'cloud', msg: 'Steam, promoted.' }, + { a: 'cloud', b: 'wind', result: 'storm', msg: 'The cloud learned to move, and regretted it.', fx: 'shake' }, + { a: 'storm', b: 'fire', result: 'lightning', msg: 'The storm struck a match.', fx: 'zap' }, + { a: 'mountain', b: 'cloud', result: 'snow', msg: 'The mountain collects its mail.' }, + + /* ---- life ---- */ + { a: 'ocean', b: 'lightning', result: 'life', msg: 'Primordial soup, stirred violently.', fx: 'zap' }, + { a: 'mud', b: 'rain', result: 'plant', msg: 'The mud sprouted an opinion.' }, + { a: 'plant', b: 'plant', result: 'tree', msg: 'Two plants entered a long agreement.' }, + { a: 'plant', b: 'sun', result: 'flower', msg: 'The plant\u2019s loud opinion.' }, + { a: 'plant', b: 'earth', result: 'wheat', msg: 'Grass with ambitions.' }, + { a: 'tree', b: 'tree', result: 'forest', msg: 'Trees discovered crowds.' }, + { a: 'bee', b: 'flower', result: 'honey', msg: 'A lifetime of small errands, bottled.' }, + + /* ---- animals ---- */ + { a: 'ocean', b: 'life', result: 'fish', msg: 'Life learned to swim before it learned to walk. Priorities.' }, + { a: 'life', b: 'air', result: 'bird', msg: 'Life, ignoring gravity\u2019s memo.' }, + { a: 'bird', b: 'bird', result: 'egg', msg: 'A bird\u2019s most confident decision.' }, + { a: 'house', b: 'life', result: 'cat', msg: 'Something small decided to live with you.' }, + { a: 'wolf', b: 'human', result: 'dog', msg: 'The wolf made an excellent mistake.' }, + { a: 'mountain', b: 'life', result: 'wolf', msg: 'The mountain learned to hunt.' }, + { a: 'flower', b: 'wind', result: 'bee', msg: 'A flower\u2019s courier service.' }, + { a: 'wood', b: 'life', result: 'snake', msg: 'The garden got interesting.' }, + { a: 'fish', b: 'time', result: 'whale', msg: 'Time and water, taken seriously.' }, + { a: 'life', b: 'time', result: 'dinosaur', msg: 'Some experiments take longer.' }, + { a: 'flower', b: 'air', result: 'butterfly', msg: 'A rumor with wings.' }, + { a: 'ocean', b: 'egg', result: 'fish', msg: 'The egg chose water.' }, + { a: 'egg', b: 'air', result: 'bird', msg: 'The egg chose poorly, then brilliantly.' }, + { a: 'house', b: 'fish', result: 'cat', msg: 'It came for the fish. It stayed for the couch.' }, + { a: 'fish', b: 'fish', result: 'whale', msg: 'The fish kept growing its story.' }, + { a: 'plant', b: 'mud', result: 'snake', msg: 'The garden\u2019s quiet rumor.' }, + { a: 'dog', b: 'moon', result: 'wolf', msg: 'Something old answers the moon.' }, + { a: 'egg', b: 'time', result: 'dinosaur', msg: 'The egg waited too well.' }, + { a: 'flower', b: 'life', result: 'butterfly', msg: 'Life, in a decorative mood.' }, + + /* ---- humanity ---- */ + { a: 'life', b: 'mud', result: 'human', msg: 'From mud, something stood up.' }, + { a: 'mud', b: 'lightning', result: 'human', msg: 'Prometheus would like a word.', fx: 'zap' }, + { a: 'human', b: 'book', result: 'brain', msg: 'A library that argues back.' }, + { a: 'human', b: 'human', result: 'love', msg: 'A self-combination with consequences.' }, + { a: 'human', b: 'star', result: 'love', msg: 'A clich\u00e9 that keeps working.' }, + + /* ---- civilization ---- */ + { a: 'wood', b: 'earth', result: 'house', msg: 'A tree, forgiven.' }, + { a: 'house', b: 'house', result: 'village', msg: 'Houses discovered gossip.' }, + { a: 'village', b: 'village', result: 'city', msg: 'A village that stopped sleeping.' }, + { a: 'wood', b: 'wood', result: 'wheel', msg: 'The circle\u2019s career began.' }, + { a: 'wheat', b: 'fire', result: 'bread', msg: 'Grain, transformed by impatience.' }, + { a: 'plant', b: 'fire', result: 'coffee', msg: 'The bean demanded fire.' }, + { a: 'wood', b: 'tool', result: 'paper', msg: 'The tree, flattened into memory.' }, + { a: 'paper', b: 'paper', result: 'book', msg: 'Paper with a spine and opinions.' }, + { a: 'mountain', b: 'fire', result: 'metal', msg: 'The mountain\u2019s hidden temper.' }, + { a: 'metal', b: 'fire', result: 'sword', msg: 'An argument made of metal.' }, + { a: 'wood', b: 'water', result: 'boat', msg: 'The wood forgave the water.' }, + { a: 'boat', b: 'metal', result: 'ship', msg: 'A boat that stopped being polite.' }, + { a: 'metal', b: 'mountain', result: 'bridge', msg: 'The mountain\u2019s shortcut.' }, + { a: 'house', b: 'mountain', result: 'castle', msg: 'A house with trust issues.' }, + { a: 'steam', b: 'metal', result: 'engine', msg: 'Steam, given a job.' }, + { a: 'metal', b: 'time', result: 'gold', msg: 'Shinier with age.' }, + { a: 'gold', b: 'paper', result: 'money', msg: 'Gold that fits in a pocket.' }, + { a: 'tool', b: 'metal', result: 'key', msg: 'It opens exactly one thing. Good luck.' }, + { a: 'village', b: 'money', result: 'city', msg: 'The village got expensive.' }, + { a: 'village', b: 'mountain', result: 'castle', msg: 'The village grew walls and opinions.' }, + { a: 'boat', b: 'boat', result: 'ship', msg: 'Two boats became an argument for a third.' }, + { a: 'ship', b: 'mountain', result: 'bridge', msg: 'The mountain compromised.' }, + { a: 'steam', b: 'tool', result: 'engine', msg: 'The tool learned to breathe.' }, + { a: 'gold', b: 'gold', result: 'money', msg: 'Gold that gossiped.' }, + { a: 'human', b: 'mountain', result: 'statue', msg: 'The mountain, flattered.' }, + { a: 'human', b: 'wood', result: 'tool', msg: 'The branch volunteered.' }, + { a: 'wood', b: 'ocean', result: 'boat', msg: 'The wood studied the water carefully.' }, + { a: 'metal', b: 'rainbow', result: 'gold', msg: 'The end of the rainbow has accounting.' }, + + /* ---- culture ---- */ + { a: 'human', b: 'paper', result: 'writing', msg: 'Thoughts, taxidermied.' }, + { a: 'tool', b: 'paper', result: 'writing', msg: 'The tool learned to leave marks.' }, + { a: 'human', b: 'rainbow', result: 'art', msg: 'The rainbow, on purpose.' }, + { a: 'human', b: 'flower', result: 'art', msg: 'The flower, framed.' }, + { a: 'human', b: 'bird', result: 'music', msg: 'The bird\u2019s idea, stolen gracefully.' }, + { a: 'human', b: 'whale', result: 'music', msg: 'The first album was fifty minutes of ocean.' }, + { a: 'book', b: 'book', result: 'philosophy', msg: 'Two books arguing forever.' }, + { a: 'brain', b: 'book', result: 'philosophy', msg: 'The brain read until it looped.' }, + { a: 'book', b: 'fire', result: 'forbiddenBook', msg: 'Some books are burned because they burn back.', secret: true, fx: 'flare' }, + { a: 'library', b: 'fire', result: 'forbiddenBook', msg: 'The smoke spelled something.', secret: true, fx: 'flare' }, + { a: 'book', b: 'house', result: 'library', msg: 'A house that remembers for you.' }, + { a: 'mountain', b: 'tool', result: 'statue', msg: 'Someone wanted to be remembered badly.' }, + { a: 'art', b: 'music', result: 'theatre', msg: 'Art that watches you back.' }, + { a: 'music', b: 'book', result: 'theatre', msg: 'The book got a stage and lost its manners.' }, + { a: 'brain', b: 'lightning', result: 'idea', msg: 'A dangerous amount of electricity reached the brain.', fx: 'zap' }, + + /* ---- technology ---- */ + { a: 'idea', b: 'metal', result: 'computer', msg: 'An idea with a fan.' }, + { a: 'metal', b: 'brain', result: 'robot', msg: 'A brain with a warranty.' }, + { a: 'computer', b: 'brain', result: 'ai', msg: 'The machine started finishing your sentences.' }, + { a: 'computer', b: 'coffee', result: 'developer', msg: 'Nothing compiles without caffeine.' }, + { a: 'human', b: 'computer', result: 'developer', msg: 'The first day is mostly installing things.' }, + { a: 'lightning', b: 'metal', result: 'battery', msg: 'Lightning, grounded.' }, + { a: 'lightning', b: 'gold', result: 'battery', msg: 'Expensive lightning, stored politely.' }, + { a: 'metal', b: 'air', result: 'antenna', msg: 'A metal ear for invisible things.' }, + { a: 'ship', b: 'fire', result: 'rocket', msg: 'A ship that chose violence over water.', fx: 'flare' }, + { a: 'engine', b: 'metal', result: 'rocket', msg: 'The engine looked up.', fx: 'flare' }, + { a: 'rocket', b: 'antenna', result: 'satellite', msg: 'An antenna that moved out.' }, + { a: 'phone', b: 'rocket', result: 'satellite', msg: 'The phone achieved orbit and still no signal.' }, + { a: 'book', b: 'mountain', result: 'telescope', msg: 'The mountain had questions too.' }, + { a: 'computer', b: 'battery', result: 'phone', msg: 'A computer that fits in your pocket and your every waking moment.' }, + { a: 'internet', b: 'antenna', result: 'wifi', msg: 'The internet, untethered and unbothered.' }, + { a: 'robot', b: 'love', result: 'syntheticHeart', msg: 'It beats. That\u2019s the disturbing part.', secret: true }, + { a: 'computer', b: 'tool', result: 'robot', msg: 'The computer got hands. Mostly harmless.' }, + { a: 'robot', b: 'book', result: 'ai', msg: 'It read everything. It has notes.' }, + + /* ---- internet ---- */ + { a: 'computer', b: 'computer', result: 'internet', msg: 'Two computers started talking. Nobody stopped them.' }, + { a: 'internet', b: 'tool', result: 'link', msg: 'A door made of text.' }, + { a: 'internet', b: 'house', result: 'personalWebsite', msg: 'You made a home on the web.' }, + { a: 'internet', b: 'writing', result: 'personalWebsite', msg: 'A room of one\u2019s own, with hyperlinks.' }, + { a: 'personalWebsite', b: 'writing', result: 'blog', msg: 'A diary the whole world is welcome to ignore.', secret: true }, + { a: 'book', b: 'internet', result: 'blog', msg: 'Chapters, posted into the void.', secret: true }, + { a: 'blog', b: 'antenna', result: 'rss', msg: 'The web, as it was meant to be read.', secret: true }, + { a: 'cat', b: 'computer', result: 'internetCat', msg: 'The internet has found its true purpose.' }, + { a: 'cat', b: 'internet', result: 'internetCat', msg: 'The wires learned to purr.' }, + { a: 'internetCat', b: 'idea', result: 'meme', msg: 'An idea in its final, unstoppable form.' }, + { a: 'internet', b: 'art', result: 'meme', msg: 'Art, optimized for chaos.' }, + { a: 'internet', b: 'computer', result: 'deadWeb', msg: 'The links still point somewhere. Nothing answers.', secret: true, fx: 'spooky' }, + { a: 'internet', b: 'ghost', result: 'deadWeb', msg: 'Some pages only load in memory.', secret: true, fx: 'spooky' }, + { a: 'computer', b: 'life', result: 'virus', msg: 'It only wanted to multiply.' }, + { a: 'computer', b: 'bee', result: 'virus', msg: 'It only wanted to multiply.' }, + { a: 'ai', b: 'ai', result: 'glitch', msg: 'The machine dreamed of a machine.', secret: true, fx: 'spooky' }, + { a: 'robot', b: 'ghost', result: 'glitch', msg: 'Haunted machinery is still machinery.', secret: true, fx: 'spooky' }, + + /* ---- occult ---- */ + { a: 'human', b: 'time', result: 'skull', msg: 'Everyone\u2019s final form.' }, + { a: 'dinosaur', b: 'time', result: 'skull', msg: 'Extinction, personalized.' }, + { a: 'skull', b: 'skull', result: 'ghost', msg: 'Death, but it left the light on.', secret: true, fx: 'spooky' }, + { a: 'skull', b: 'air', result: 'ghost', msg: 'The skull let go of the heavy parts.', secret: true, fx: 'spooky' }, + { a: 'skull', b: 'love', result: 'undead', msg: 'Love, unfortunately, finds a way.' }, + { a: 'ghost', b: 'love', result: 'undead', msg: 'It came back for the company.' }, + { a: 'moon', b: 'key', result: 'secret', msg: 'Some doors only open at night.', secret: true, fx: 'spooky' }, + { a: 'star', b: 'key', result: 'secret', msg: 'The key was always pointing up.', secret: true }, + { a: 'secret', b: 'galaxy', result: 'blackHole', msg: 'The universe\u2019s locked drawer.', secret: true, fx: 'cosmic' }, + { a: 'sun', b: 'time', result: 'blackHole', msg: 'Even light gets tired.', secret: true, fx: 'cosmic' }, + { a: 'forbiddenBook', b: 'fire', result: 'phoenix', msg: 'It read the whole thing and came back louder.', secret: true, ordered: true, fx: 'flare' }, + + /* ---- cosmos ---- */ + { a: 'time', b: 'time', result: 'night', msg: 'Time, left alone, becomes night.' }, + { a: 'time', b: 'moon', result: 'night', msg: 'The moon supervises the dark.' }, + { a: 'night', b: 'earth', result: 'moon', msg: 'The sky\u2019s oldest rock.' }, + { a: 'night', b: 'mountain', result: 'moon', msg: 'The mountain\u2019s reflection on the sky.' }, + { a: 'fire', b: 'night', result: 'star', msg: 'A light that outlived its fire.' }, + { a: 'night', b: 'night', result: 'star', msg: 'Night, squared, starts to shine.' }, + { a: 'fire', b: 'fire', result: 'sun', msg: 'Fire, squared and glorified.', secret: true, fx: 'flare' }, + { a: 'telescope', b: 'star', result: 'galaxy', msg: 'A city of suns.', fx: 'cosmic' }, + { a: 'star', b: 'star', result: 'galaxy', msg: 'Stars discovered crowds.', fx: 'cosmic' }, + { a: 'sun', b: 'moon', result: 'eclipse', msg: 'The sky\u2019s private joke.', secret: true, fx: 'cosmic' }, + { a: 'moon', b: 'star', result: 'owl', msg: 'It only interviews at night.', secret: true, nightOnly: true }, + { a: 'moon', b: 'telescope', result: 'ufo', msg: 'It blinked back.', secret: true, nightOnly: true, fx: 'spooky' }, + { a: 'ufo', b: 'life', result: 'alien', msg: 'Life, from a different draft.' }, + { a: 'life', b: 'galaxy', result: 'alien', msg: 'Somewhere, life filed a copy.' }, + { a: 'alien', b: 'rocket', result: 'ufo', msg: 'Return flight.' }, + { a: 'star', b: 'secret', result: 'netuno', msg: 'The outer dark has a name, and it\u2019s this one.', secret: true, fx: 'cosmic' }, + { a: 'telescope', b: 'galaxy', result: 'netuno', msg: 'You found it by looking too long.', secret: true, fx: 'cosmic' }, + { a: 'star', b: 'fire', result: 'supernova', msg: 'An ending so bright it counts as a beginning.', fx: 'flare' }, + + /* ---- absurd ---- */ + { a: 'internet', b: 'philosophy', result: 'hotTake', msg: 'Everyone has one. No one wants yours.' }, + { a: 'philosophy', b: 'fire', result: 'hotTake', msg: 'Someone is wrong somewhere, urgently.', fx: 'flare' }, + { a: 'statue', b: 'phone', result: 'ancientInfluencer', msg: 'Two million followers. Zero posts.', secret: true }, + { a: 'statue', b: 'internet', result: 'ancientInfluencer', msg: 'The followers were always there. Now there\u2019s a count.', secret: true }, + { a: 'developer', b: 'developer', result: 'meeting', msg: 'Two developers produced a third meeting.' } + ]; + + var DATA = { + ELEMENTS: ELEMENTS, + RECIPES: RECIPES, + CATEGORIES: CATEGORIES, + STARTERS: STARTERS + }; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = DATA; + } + globalThis.EMOJESIS_DATA = DATA; +})();
A
sw.js
@@ -0,0 +1,44 @@
+/* EMOJESIS — minimal offline cache. Bump CACHE on every release. */ +'use strict'; + +var CACHE = 'emojesis-v1'; +var ASSETS = [ + './', + './index.html', + './css/style.css', + './js/data.js', + './js/app.js', + './favicon.svg' +]; + +self.addEventListener('install', function (ev) { + ev.waitUntil( + caches.open(CACHE).then(function (c) { return c.addAll(ASSETS); }) + .then(function () { return self.skipWaiting(); }) + ); +}); + +self.addEventListener('activate', function (ev) { + ev.waitUntil( + caches.keys().then(function (keys) { + return Promise.all(keys.map(function (k) { + if (k !== CACHE) return caches.delete(k); + })); + }).then(function () { return self.clients.claim(); }) + ); +}); + +self.addEventListener('fetch', function (ev) { + if (ev.request.method !== 'GET' || !ev.request.url.startsWith(self.location.origin)) return; + ev.respondWith( + caches.match(ev.request).then(function (hit) { + return hit || fetch(ev.request).then(function (res) { + if (res && res.ok) { + var copy = res.clone(); + caches.open(CACHE).then(function (c) { c.put(ev.request, copy); }); + } + return res; + }); + }) + ); +});
A
validate.js
@@ -0,0 +1,189 @@
+/* EMOJESIS data validator + teaching-chain simulation. + Run: node validate.js */ +'use strict'; + +const { ELEMENTS, RECIPES, CATEGORIES, STARTERS } = require('./js/data.js'); + +let failures = 0; +const fail = (msg) => { failures++; console.error(' FAIL: ' + msg); }; +const ok = (msg) => console.log(' ok: ' + msg); + +const pairKey = (a, b, ordered) => + ordered ? a + '>' + b : [a, b].sort().join('|'); + +/* 1. Referential integrity + element fields */ +console.log('\n[1] Referential integrity & element fields'); +for (const id of Object.keys(ELEMENTS)) { + const e = ELEMENTS[id]; + if (e.id !== id) fail(`element key "${id}" mismatches e.id "${e.id}"`); + for (const f of ['id', 'emoji', 'name', 'category', 'description']) { + if (!e[f] || typeof e[f] !== 'string') fail(`element "${id}" missing field "${f}"`); + } + if (e.category && !CATEGORIES.includes(e.category)) { + fail(`element "${id}" has unknown category "${e.category}"`); + } +} +for (const id of STARTERS) { + if (!ELEMENTS[id]) fail(`starter "${id}" missing from ELEMENTS`); +} +RECIPES.forEach((r, i) => { + const tag = `recipe #${i} (${r.a}+${r.b}=${r.result})`; + if (!ELEMENTS[r.a]) fail(`${tag}: input "${r.a}" does not exist`); + if (!ELEMENTS[r.b]) fail(`${tag}: input "${r.b}" does not exist`); + if (!ELEMENTS[r.result]) fail(`${tag}: result "${r.result}" does not exist`); + if (!r.msg || typeof r.msg !== 'string') fail(`${tag}: missing msg`); +}); +if (failures === 0) ok('all references and fields valid'); + +/* 2. Duplicate recipes */ +console.log('\n[2] Duplicate recipes'); +{ + const seen = new Map(); + RECIPES.forEach((r, i) => { + const k = pairKey(r.a, r.b, r.ordered); + if (seen.has(k)) { + fail(`duplicate recipe pair "${k}" at #${seen.get(k)} and #${i}`); + } else { + seen.set(k, i); + } + }); + if (failures === 0) ok('no duplicate pairs'); +} + +/* 3. Reachability BFS to fixpoint (nightOnly ignored — craftable at some hour) */ +console.log('\n[3] Reachability from starters'); +{ + const reachable = new Set(STARTERS); + let changed = true; + while (changed) { + changed = false; + for (const r of RECIPES) { + if (!reachable.has(r.result) && reachable.has(r.a) && reachable.has(r.b)) { + reachable.add(r.result); + changed = true; + } + } + } + const unreachable = Object.keys(ELEMENTS).filter((id) => !reachable.has(id)); + for (const id of unreachable) fail(`element "${id}" is unreachable from starters`); + if (unreachable.length === 0) ok(`all ${Object.keys(ELEMENTS).length} elements reachable`); +} + +/* 4. Counts */ +console.log('\n[4] Content counts'); +{ + const nEl = Object.keys(ELEMENTS).length; + const nRe = RECIPES.length; + const cats = new Set(Object.values(ELEMENTS).map((e) => e.category)); + const secrets = new Set(); + for (const e of Object.values(ELEMENTS)) if (e.secret) secrets.add(e.id); + for (const r of RECIPES) if (r.secret) secrets.add(r.result); + console.log(` elements: ${nEl}`); + console.log(` recipes: ${nRe}`); + console.log(` categories: ${cats.size} (${[...cats].join(', ')})`); + console.log(` secrets: ${secrets.size}`); + if (nEl < 100) fail(`only ${nEl} elements (need >= 100)`); + if (nRe < 160 || nRe > 220) fail(`recipe count ${nRe} outside 160-220`); + if (cats.size < 8) fail(`only ${cats.size} categories (need >= 8)`); + if (secrets.size < 6) fail(`only ${secrets.size} secrets (need >= 6)`); +} + +/* 5. Teaching chain — exact combos */ +console.log('\n[5] Teaching chain (exact combos)'); +{ + const chain = [ + ['fire', 'water', 'steam'], + ['water', 'earth', 'mud'], + ['air', 'water', 'cloud'], + ['cloud', 'water', 'rain'], + ['rain', 'earth', 'life'], + ['life', 'water', 'plant'], + ['plant', 'time', 'tree'], + ['tree', 'fire', 'wood'], + ['earth', 'earth', 'mountain'], + ['human', 'earth', 'tool'] + ]; + for (const [a, b, result] of chain) { + const hit = RECIPES.find( + (r) => !r.ordered && r.result === result && + ((r.a === a && r.b === b) || (r.a === b && r.b === a)) + ); + if (!hit) fail(`teaching chain combo ${a}+${b}=${result} not found`); + } + /* Signature combos */ + const sig = [ + ['brain', 'lightning', 'idea'], + ['book', 'fire', 'forbiddenBook'], + ['computer', 'coffee', 'developer'], + ['internet', 'house', 'personalWebsite'], + ['skull', 'love', 'undead'], + ['cat', 'computer', 'internetCat'], + ['robot', 'love', 'syntheticHeart'], + ['moon', 'key', 'secret'], + ['statue', 'phone', 'ancientInfluencer'], + ['personalWebsite', 'writing', 'blog'], + ['blog', 'antenna', 'rss'] + ]; + for (const [a, b, result] of sig) { + const hit = RECIPES.find( + (r) => r.result === result && + ((r.a === a && r.b === b) || (!r.ordered && r.a === b && r.b === a)) + ); + if (!hit) fail(`signature combo ${a}+${b}=${result} not found`); + } + if (failures === 0) ok('teaching chain + signature combos present'); +} + +/* 6. Daily-challenge target pool for a fresh save */ +console.log('\n[6] Daily challenge pool (fresh save)'); +{ + const nonStarters = Object.keys(ELEMENTS).filter((id) => !STARTERS.includes(id)); + if (nonStarters.length === 0) fail('no non-starter elements available as daily goals'); + else ok(`${nonStarters.length} candidate goals for a fresh save`); +} + +/* 7. Simulation: craft the teaching chain step by step from a fresh save */ +console.log('\n[7] Simulated playthrough of the teaching chain'); +{ + const owned = new Set(STARTERS); + const combine = (a, b) => { + const r = RECIPES.find( + (x) => !x.ordered && + ((x.a === a && x.b === b) || (x.a === b && x.b === a)) + ); + return r ? r.result : null; + }; + const steps = [ + ['fire', 'water', 'steam'], + ['water', 'earth', 'mud'], + ['air', 'water', 'cloud'], + ['cloud', 'water', 'rain'], + ['rain', 'earth', 'life'], + ['life', 'water', 'plant'], + ['steam', 'earth', 'time'], + ['plant', 'time', 'tree'], + ['tree', 'fire', 'wood'], + ['earth', 'earth', 'mountain'], + ['life', 'mud', 'human'], + ['human', 'earth', 'tool'] + ]; + for (const [a, b, expect] of steps) { + if (!owned.has(a) || !owned.has(b)) { + fail(`sim: "${a}" or "${b}" not yet discovered when needed`); + continue; + } + const got = combine(a, b); + if (got !== expect) fail(`sim: ${a}+${b} gave "${got}", expected "${expect}"`); + else owned.add(got); + } + if (failures === 0) ok(`simulation complete — ${owned.size} elements discovered`); +} + +/* Summary */ +console.log(''); +if (failures > 0) { + console.error(`VALIDATION FAILED: ${failures} problem(s).`); + process.exit(1); +} else { + console.log('VALIDATION PASSED.'); +}