diff --git a/backend/app/celestial.py b/backend/app/celestial.py new file mode 100644 index 0000000..9cc3499 --- /dev/null +++ b/backend/app/celestial.py @@ -0,0 +1,159 @@ +"""Real astronomy — moon phase and true solar midnight. + +Every number here is computed from actual orbital mechanics, not invented +and not sampled from a table of plausible-looking values. That matters +because these feed the summon draw: if the veil is supposed to be thinner +at a full moon, the moon has to actually be full. + +Two quantities: + + moon_phase(when) - the synodic phase, 0=new .. 0.5=full .. 1=new. + solar_midnight(...) - the moment the sun is at its lowest for a given + longitude, i.e. the real witching hour for where + the seeker is standing rather than clock 3am. + +Accuracy: the moon calculation uses the standard mean-synodic-month +approximation from a known new moon epoch. It is accurate to well under a +day over a range of centuries — far tighter than anything this app needs, +since the output is bucketed into eight named phases. Solar midnight uses +mean solar time (longitude / 15° per hour), ignoring the equation of time, +which can be off by up to ~16 minutes across the year. Both are documented +approximations rather than silent ones; neither is precise enough for +navigation and neither needs to be. + +No network dependency on purpose: this must work when the box is offline, +and an API that can fail would mean the veil's behaviour silently changes +when a third party has an outage. +""" + +import math +from datetime import datetime, timedelta, timezone + +# Reference new moon: 2000-01-06 18:14 UTC. A widely used epoch for this +# approximation. +_KNOWN_NEW_MOON = datetime(2000, 1, 6, 18, 14, tzinfo=timezone.utc) + +# Mean synodic month (new moon to new moon), in days. The moon's actual +# period varies by several hours either side of this; the mean is what the +# standard approximation uses. +SYNODIC_MONTH_DAYS = 29.530588853 + +# The eight conventional phase names, in order from new moon. +PHASE_NAMES = ( + "new moon", + "waxing crescent", + "first quarter", + "waxing gibbous", + "full moon", + "waning gibbous", + "last quarter", + "waning crescent", +) + + +def moon_phase(when: datetime | None = None) -> float: + """Synodic phase as a fraction in [0, 1). + + 0.0 = new moon, 0.5 = full moon. Naive datetimes are assumed UTC rather + than rejected, so a caller that forgot a tzinfo gets a slightly-off + answer instead of an exception during a summon. + """ + when = when or datetime.now(timezone.utc) + if when.tzinfo is None: + when = when.replace(tzinfo=timezone.utc) + elapsed_days = (when - _KNOWN_NEW_MOON).total_seconds() / 86400.0 + return (elapsed_days % SYNODIC_MONTH_DAYS) / SYNODIC_MONTH_DAYS + + +def moon_phase_name(phase: float) -> str: + """Bucket a phase fraction into one of the eight conventional names. + + Buckets are centred on their phase (the "full moon" bucket straddles + 0.5) rather than starting at it, which is why the +1/16 offset is + there — without it every name would be shifted half a bucket late. + """ + index = int(((phase + 1 / 16) % 1.0) * 8) % 8 + return PHASE_NAMES[index] + + +def moon_illumination(phase: float) -> float: + """Fraction of the disc lit, 0.0 (new) .. 1.0 (full). + + Follows the standard cosine relation to the phase angle; this is the + same curve that makes a quarter moon look half-lit. + """ + return (1 - math.cos(2 * math.pi * phase)) / 2 + + +def solar_midnight(longitude_deg: float, when: datetime | None = None) -> datetime: + """The UTC instant of *mean* solar midnight nearest `when` for a longitude. + + Mean solar time only: the equation of time (up to ~16 minutes) is not + applied. Good enough to know whether the seeker is near the true dead + of night for where they are standing, which is all this is used for. + """ + when = when or datetime.now(timezone.utc) + if when.tzinfo is None: + when = when.replace(tzinfo=timezone.utc) + # Every 15° of longitude shifts local solar time by one hour. + offset_hours = longitude_deg / 15.0 + local = when + timedelta(hours=offset_hours) + midnight_local = local.replace(hour=0, minute=0, second=0, microsecond=0) + # Pick whichever midnight (today's or tomorrow's) is actually closest, + # so 23:50 local resolves to the midnight ten minutes ahead rather than + # the one nearly a day behind. + if local.hour >= 12: + midnight_local += timedelta(days=1) + return midnight_local - timedelta(hours=offset_hours) + + +def witching_proximity(longitude_deg: float, when: datetime | None = None) -> float: + """How close the seeker is to their own solar midnight, 0.0 .. 1.0. + + 1.0 exactly at solar midnight, falling to 0.0 twelve hours away. Used + to weight the veil rather than to gate it — there is no hour at which + contact is impossible, only hours at which it is thinner. + """ + when = when or datetime.now(timezone.utc) + if when.tzinfo is None: + when = when.replace(tzinfo=timezone.utc) + midnight = solar_midnight(longitude_deg, when) + hours_away = abs((when - midnight).total_seconds()) / 3600.0 + # solar_midnight returns the nearest one, so this is already <= 12. + return max(0.0, 1.0 - min(hours_away, 12.0) / 12.0) + + +def veil_thinness( + longitude_deg: float | None = None, when: datetime | None = None +) -> dict: + """Combined 'how thin is the veil right now' reading. + + Moon illumination and (when a longitude is known) proximity to true + solar midnight, blended into a single 0..1 figure plus the components + that produced it, so the UI can explain *why* rather than showing a + bare number. + + Without a longitude the reading is moon-only — a seeker who declines + location still gets a real celestial contribution, just a coarser one. + """ + when = when or datetime.now(timezone.utc) + phase = moon_phase(when) + illumination = moon_illumination(phase) + + if longitude_deg is None: + thinness = illumination + witching = None + else: + witching = witching_proximity(longitude_deg, when) + # Weighted toward the hour: standing in the dead of night matters + # more than the moon being full, and a full moon at noon should + # not read as "the veil is wide open". + thinness = illumination * 0.4 + witching * 0.6 + + return { + "moon_phase": phase, + "moon_name": moon_phase_name(phase), + "moon_illumination": illumination, + "witching_proximity": witching, + "thinness": max(0.0, min(1.0, thinness)), + } diff --git a/backend/app/entities.py b/backend/app/entities.py index 3548f11..1ec7b11 100644 --- a/backend/app/entities.py +++ b/backend/app/entities.py @@ -6,6 +6,7 @@ import hashlib import json import random +from app.entropy import veil_random from app.models.entity import RARITY_TIERS from app.tts.voices import EN_VOICE_IDS @@ -96,15 +97,28 @@ def parse_mint_response(text: str) -> dict | None: return profile -def roll_traits(signature: str) -> dict: - """Roll the four hidden truth-traits for an entity, seeded from its - signature. These are never derived from — or fed into — the LLM persona - prompt (see `mint_prompt`, which never sees this function's output): - persona text must stay fully decoupled from ground truth, so a - convincing "sweet old lady" persona can pair with any alignment roll. +def roll_traits(signature: str, entropy: object = None) -> dict: + """Roll the four hidden truth-traits for an entity. These are never + derived from — or fed into — the LLM persona prompt (see `mint_prompt`, + which never sees this function's output): persona text must stay fully + decoupled from ground truth, so a convincing "sweet old lady" persona + can pair with any alignment roll. + + With `entropy` (a physical-noise contribution from the seeker's room — + see app/entropy.py), the roll is genuinely unpredictable: what answers + is decided by the room, not by a hash. Without it, the roll stays + signature-deterministic, which is what the pure-function tests and + `judgment`'s favor-bias comparisons rely on. + A separate `random.Random` namespace (`"traits:"` vs. `normalize_profile`'s `"norm:"`) keeps this roll independent of the cosmetic-defaults rng.""" - rng = random.Random(f"traits:{signature}") + if entropy is not None: + # Domain-separated from the "which entity answers" draw so the two + # can't be correlated — learning an entity's rarity must reveal + # nothing about its hidden alignment. + rng = veil_random(entropy, f"traits:{signature}") + else: + rng = random.Random(f"traits:{signature}") return { "alignment": rng.uniform(0.0, 1.0), "power": rng.uniform(0.0, 1.0), @@ -113,10 +127,19 @@ def roll_traits(signature: str) -> dict: } -def normalize_profile(profile: dict, signature: str) -> dict: +def normalize_profile(profile: dict, signature: str, entropy: object = None) -> dict: """Coerce an LLM (or fallback) profile into the exact shape the DB and - frontend expect, filling gaps with signature-deterministic defaults.""" - rng = random.Random(f"norm:{signature}") + frontend expect, filling gaps with defaults. + + With `entropy`, the gap-filling defaults (voice, hue, form) are drawn + from physical noise, so two spirits minted on the same channel don't + inherit identical throats and colours. Without it, defaults stay + signature-deterministic for the pure-function tests.""" + rng = ( + veil_random(entropy, f"norm:{signature}") + if entropy is not None + else random.Random(f"norm:{signature}") + ) voice = profile.get("voice") if isinstance(profile.get("voice"), dict) else {} visual = profile.get("visual") if isinstance(profile.get("visual"), dict) else {} quotes = profile.get("quotes") if isinstance(profile.get("quotes"), list) else [] @@ -155,14 +178,23 @@ def normalize_profile(profile: dict, signature: str) -> dict: }, "quotes": [str(q)[:200] for q in quotes[:4] if isinstance(q, str)] or rng.sample(_QUOTE_BANK, 2), - "traits": roll_traits(signature), + "traits": roll_traits(signature, entropy), } -def fallback_profile(signature: str) -> dict: +def fallback_profile(signature: str, entropy: object = None) -> dict: """Procedural persona for when the LLM box is unreachable — a summoning - must never visibly fail.""" - rng = random.Random(f"fallback:{signature}") + must never visibly fail. + + With `entropy`, even the fallback spirits differ run to run: with the + LLM down, a purely signature-seeded fallback would hand every seeker on + a given channel the identical name, epithet and rarity, which is + exactly the "example data" feel this is meant to avoid.""" + rng = ( + veil_random(entropy, f"fallback:{signature}") + if entropy is not None + else random.Random(f"fallback:{signature}") + ) name = rng.choice(_NAME_PARTS[0]) + rng.choice(_NAME_PARTS[1]) epithet = rng.choice(_EPITHETS) persona = rng.choice(_PERSONA_TEMPLATES).format(name=name) @@ -176,4 +208,5 @@ def fallback_profile(signature: str) -> dict: "quotes": rng.sample(_QUOTE_BANK, 2), }, signature, + entropy, ) diff --git a/backend/app/entropy.py b/backend/app/entropy.py new file mode 100644 index 0000000..35f5233 --- /dev/null +++ b/backend/app/entropy.py @@ -0,0 +1,110 @@ +"""The veil's randomness — mixing physical entropy from the seeker's room +with server-side secrets. + +Design premise: contact should be genuinely unpredictable, and the +unpredictability should come from the physical world the seeker is +standing in (their microphone's noise floor, the RF noise between +stations, sensor jitter) rather than from a deterministic hash of their +session. Before this module, `_summon` derived everything from +`signature_from_anomalies()` — a SHA-1 of the anomaly pattern — which meant +identical conditions always produced an identical spirit. That is the +opposite of channeling. + +SECURITY — why client entropy is never used alone: + + The client is untrusted. A malicious seeker could send a fixed + "entropy" string and re-roll until they hit a mythic entity, or one + with traits they want, grinding the rarity table and the drop economy. + + So client contributions are only ever *mixed in*, never used as the + seed. Every draw is HMAC-SHA256(server_secret_bytes, client_bytes || + context), where the server bytes come from `secrets.token_bytes()` on + every single call. Because a fresh cryptographically-secure server + contribution is always present, the output is unpredictable and + uniformly distributed *no matter what the client sends* — including + all-zeros, a replayed value, or a value chosen adversarially. + + The client's contribution therefore can only ever *add* unpredictability + from the room; it can never subtract any or steer the result. That is + exactly the property we want: the physical world genuinely participates, + but it cannot be forged into an advantage. + +This mirrors how real hardware RNGs are used: physical noise is a source +that gets conditioned and mixed into a CSPRNG, never trusted raw. +""" + +import hashlib +import hmac +import random +import secrets + +# A client contribution is a SHA-256 hex digest (see frontend +# lib/entropy.ts). Anything longer is truncated rather than rejected, so a +# future client that sends a larger pool still works; anything that isn't +# valid hex is discarded entirely rather than silently coerced. +MAX_CONTRIBUTION_CHARS = 512 + + +def normalize_contribution(raw: object) -> bytes: + """Coerce whatever the client sent into bytes worth mixing. + + Returns empty bytes for anything unusable. Empty is completely safe — + the server contribution alone still produces a strong draw — so this + never needs to raise, and a malformed payload degrades to "no physical + entropy this time" rather than failing the summon. + """ + if not isinstance(raw, str): + return b"" + text = raw.strip()[:MAX_CONTRIBUTION_CHARS] + if not text: + return b"" + try: + return bytes.fromhex(text) + except ValueError: + # Not hex — still mix it as UTF-8 rather than throwing it away. + # It cannot hurt (see the security note above) and a client with a + # different encoding still contributes real noise. + return text.encode("utf-8", "ignore") + + +def veil_seed(contribution: object = None, context: str = "") -> bytes: + """One unpredictable 32-byte seed. + + `context` domain-separates independent draws made from the same + contribution (e.g. "which entity" vs. "what traits"), so they can't be + correlated with each other. + """ + client_bytes = normalize_contribution(contribution) + # Fresh server entropy on every call — this is what makes the result + # unpredictable regardless of client behaviour. + server_bytes = secrets.token_bytes(32) + return hmac.new( + server_bytes, + client_bytes + b"|" + context.encode("utf-8", "ignore"), + hashlib.sha256, + ).digest() + + +def veil_random(contribution: object = None, context: str = "") -> random.Random: + """A `random.Random` seeded from mixed physical + server entropy. + + Returned rather than a raw int so callers keep using the ordinary + random API (`.random()`, `.choice()`, `.gauss()`) they already use with + signature-seeded generators, making this a drop-in replacement at every + existing call site. + """ + return random.Random(veil_seed(contribution, context)) + + +def veil_float(contribution: object = None, context: str = "") -> float: + """A single unpredictable float in [0, 1).""" + return veil_random(contribution, context).random() + + +def contribution_bits(raw: object) -> int: + """How many bits of physical entropy the client actually supplied. + + Used only for display ("the air is thick") and telemetry — never to + gate or weight the draw, since a client can lie about it freely. + """ + return len(normalize_contribution(raw)) * 8 diff --git a/backend/app/llm/prompts.py b/backend/app/llm/prompts.py index 3c8b407..7f5eec4 100644 --- a/backend/app/llm/prompts.py +++ b/backend/app/llm/prompts.py @@ -35,6 +35,34 @@ CHAT_SYSTEM = ( "{language_clause}" ) +MANIFEST_SYSTEM = ( + "You are {name}, {epithet} — a spirit persona in an interactive horror " + "art installation.\n" + "Your nature: {persona}\n" + "NOBODY HAS ASKED YOU ANYTHING. No question is coming. You are not " + "answering, replying, greeting, or responding — you are intruding. " + "Something in the room moved and it pulled a fragment of you through " + "with it.\n" + "Say the thing that surfaces. It may be mid-sentence. It may not make " + "sense to anyone living. It may be a name, a number, a warning, a " + "complaint, an instruction to someone who is not there, or a piece of " + "an argument you were having when you died. Do not explain it. Do not " + "address the listener unless you mistake them for someone else.\n" + "Under 18 words. Never break character, never mention being an AI." + "{language_clause}" +) + +# What the entity is given instead of a question: the physical state of the +# room, as measured. There is deliberately no seeker input anywhere in this +# prompt — the model has nothing to reply *to*, only something to speak +# *from*. That is the whole point of this path existing separately from +# chat_prompt(). +MANIFEST_PROMPT = """The room right now: +{readings} + +Something shifted. Speak.""" + + MINT_SYSTEM = ( "You invent spirit personas for an interactive horror art installation. " "The single biggest thing separating a convincing dead person from a " @@ -137,6 +165,31 @@ def chat_prompt(question: str, history: list[dict]) -> str: return "\n".join(lines) +def manifest_system(entity: dict, language: str = "en") -> str: + return MANIFEST_SYSTEM.format( + name=entity.get("name", "an unnamed presence"), + epithet=entity.get("epithet", "a voice in the static"), + persona=entity.get("persona", "A drifting presence with no remembered past."), + language_clause=language_clause(language), + ) + + +def manifest_prompt(readings: dict) -> str: + """Render measured room state as the entity's only stimulus. + + Values are rendered plainly and without interpretation — "magnetic + field 61.2 uT (up 13.1)" rather than "strong paranormal activity" — so + the model is reacting to a measurement rather than being told what to + conclude. Telling it "the haunting is intense" would put the horror in + the prompt; showing it a number lets the horror come from the entity. + """ + if not readings: + lines = ["nothing measurable. only the dark."] + else: + lines = [f"- {label}: {value}" for label, value in readings.items()] + return MANIFEST_PROMPT.format(readings="\n".join(lines)) + + def mint_prompt( signature: str, channel: str, anomaly_summary: str, voice_ids: list[str] ) -> str: diff --git a/backend/app/llm/service.py b/backend/app/llm/service.py index 0e91b4a..1ba0a20 100644 --- a/backend/app/llm/service.py +++ b/backend/app/llm/service.py @@ -13,6 +13,7 @@ import httpx from app import entities from app.config import settings +from app.entropy import veil_seed from app.llm import prompts from app.llm.client import OllamaClient from app.llm.queue import LLMQueue, QueueFullError @@ -132,14 +133,72 @@ class SpiritService: except (httpx.HTTPError, KeyError, ValueError): yield random.choice(FALLBACK_REPLIES) + async def manifest( + self, + entity: dict, + readings: dict, + language: str = "en", + entropy: object = None, + ) -> str: + """Unprompted speech — the entity says something nobody asked for. + + This is deliberately NOT chat_stream with an empty question. Two + things make it generation *from* something rather than a reply + *to* something: + + 1. The prompt contains no seeker input at all. The model's only + stimulus is the measured state of the room (see + prompts.manifest_prompt), so there is nothing to answer. + + 2. The sampling seed is derived from physical entropy harvested in + that room — the microphone's noise floor, RF noise, magnetometer + jitter. Ollama's `seed` option fixes the token-sampling path, so + seeding it from real physical noise means the room genuinely + selects the words. Not a metaphor: change the noise, get + different speech, and no two rooms produce the same utterance. + + High temperature and top_k on purpose — a tight, "correct" decode + produces a well-behaved assistant sentence, which is exactly the + failure mode here. This should sound like something surfacing, not + something composed. + """ + # 63-bit: Ollama takes a signed 64-bit seed, and staying under the + # sign bit avoids any wraparound surprises across versions. + seed = int.from_bytes(veil_seed(entropy, "manifest")[:8], "big") % (2**63) + + async def call() -> str: + return await self._client.generate( + settings.ollama_chat_model, + prompts.manifest_prompt(readings), + system=prompts.manifest_system(entity, language), + options={ + "num_predict": 40, + "temperature": 1.15, + "top_k": 100, + "top_p": 0.98, + "repeat_penalty": 1.05, + "seed": seed, + }, + ) + + raw = await self._queue.submit(call) + self._touch() + return raw.strip().strip('"')[:200] + async def mint_profile( self, signature: str, channel: str, anomalies: list[dict], language: str = "en", + entropy: object = None, ) -> dict: - """Invent a full persona for a new signature, normalized to schema.""" + """Invent a full persona for a new signature, normalized to schema. + + `entropy` is the seeker's physical-noise contribution (see + app/entropy.py); it drives the hidden trait roll and any cosmetic + defaults the LLM left unfilled, so two spirits minted on the same + channel are genuinely different rather than identical.""" summary = json.dumps(anomalies[-10:])[:600] voice_ids = ES_VOICE_IDS if language == "es" else EN_VOICE_IDS @@ -156,10 +215,10 @@ class SpiritService: self._touch() profile = entities.parse_mint_response(raw) if profile is None: - return entities.fallback_profile(signature) - return entities.normalize_profile(profile, signature) + return entities.fallback_profile(signature, entropy) + return entities.normalize_profile(profile, signature, entropy) except (QueueFullError, httpx.HTTPError, KeyError, ValueError): - return entities.fallback_profile(signature) + return entities.fallback_profile(signature, entropy) # The app-wide instance; tests monkeypatch this. diff --git a/backend/app/ws.py b/backend/app/ws.py index adadcc2..bbe4147 100644 --- a/backend/app/ws.py +++ b/backend/app/ws.py @@ -36,6 +36,8 @@ from app.config import settings from app.db import async_session_maker as _default_session_maker from app.deps import SESSION_COOKIE_NAME from app.entities import fallback_signature, signature_from_anomalies +from app.celestial import veil_thinness +from app.entropy import contribution_bits, veil_float from app.inventory import ( RITUAL_SUCCESS_ESSENCE, SUMMON_ESSENCE_TRICKLE, @@ -90,6 +92,19 @@ summon_ip_limiter = RateLimiter(max_requests=8, window_seconds=60) ritual_ip_limiter = RateLimiter(max_requests=12, window_seconds=60) judgment_ip_limiter = RateLimiter(max_requests=20, window_seconds=60) +# Probability that a channel's familiar presence answers again rather than +# something new manifesting. High enough that the Codex stays collectable +# and spirits are genuinely re-contactable; low enough that calling into a +# known channel is never a guarantee. +RETURN_CHANCE = 0.72 + +# How much a fully-thin veil erodes the familiar presence's claim on a +# channel. At 0.45, a full moon at true solar midnight drops the return +# chance from 72% to ~40% — a real, felt difference on the spookiest night +# of the month, without ever making a known spirit unreachable. +VEIL_THINNESS_PULL = 0.45 + + AUDIO_DIR = Path(settings.data_dir) / "audio" @@ -122,6 +137,17 @@ class SeanceState: # vary run to run), but persistent across calls so the draw sequence # isn't restarted on every single message. tell_rng: random.Random = field(default_factory=random.Random) + # Latest physical-entropy contribution harvested from the seeker's room + # (microphone noise floor / RF noise between stations) — see + # app/entropy.py. Untrusted by construction: it is only ever mixed with + # fresh server secrets, never used as a seed on its own, so a client + # sending a chosen or replayed value cannot steer any outcome. + entropy: str | None = None + # Seeker's longitude, if they granted location. Only the longitude is + # kept — it is all that solar midnight needs, and storing a full + # coordinate would be retaining precise location data we have no use + # for. Never persisted; lives and dies with the connection. + longitude: float | None = None # Active-session registry (spec: ESP32 sensor node, Workstream K): maps a @@ -285,15 +311,26 @@ async def _unique_entity_name(db, base_name: str) -> str: async def _summon(state: SeanceState, channel: str) -> tuple[Entity, bool]: - """Match this session's signature against the Codex, or mint a new entity. + """Open a channel and see what answers. - An at-peace entity (Workstream B: a spirit correctly helped to cross - over) is excluded from the match — it stays in the Codex forever but - can't be re-contacted. If its signature is what this session's anomaly - pattern hashes to, a *new* entity is minted instead. `Entity.signature` - is unique, so the new entity can't reuse the exact same string while the - retired row still holds it — it gets a salted variant of the same base - signature instead. + The signature identifies a *channel*, not a spirit. Whether the entity + previously reached on this channel answers again, or something else + manifests instead, is a genuine draw against physical entropy harvested + from the seeker's room (app/entropy.py) — not a deterministic lookup. + Before this, an identical anomaly pattern always produced an identical + spirit, which made contact feel like a database query rather than + channeling. + + The Codex mechanic survives: a familiar presence is the *likely* + outcome on a known channel (`RETURN_CHANCE`), so spirits remain + collectable and re-contactable, but never guaranteed. Sometimes you + call and something else picks up. + + An at-peace entity (a spirit correctly helped to cross over) is + excluded from the match entirely — it stays in the Codex forever but + can't be re-contacted. `Entity.signature` is unique, so a newly minted + entity can't reuse a string a retired row still holds; it gets a salted + variant of the same base signature. """ signature = signature_from_anomalies(state.anomalies) or fallback_signature( str(state.session_id) @@ -311,19 +348,40 @@ async def _summon(state: SeanceState, channel: str) -> tuple[Entity, bool]: for _attempt in range(2): async with session_maker() as db: try: - entity = await db.scalar( + known = await db.scalar( select(Entity).where(Entity.signature == signature, Entity.at_peace.is_(False)) ) + # The draw that makes contact feel like contact: even on a + # channel with a familiar presence, something else can + # answer. Domain-separated from the traits roll so the two + # are uncorrelated. + # A thinner veil (full moon, true solar midnight) makes it + # likelier that something *other* than the familiar + # presence pushes through — more traffic gets across when + # the barrier is weaker, which is the whole folkloric + # premise. Real astronomy, computed from the seeker's own + # longitude: see app/celestial.py. + sky = veil_thinness(state.longitude) + return_chance = RETURN_CHANCE * (1.0 - sky["thinness"] * VEIL_THINNESS_PULL) + answers = ( + known is not None + and veil_float(state.entropy, "answers") < return_chance + ) + entity = known if answers else None is_new = entity is None if is_new: + # A new presence on an occupied channel needs its own + # signature (the column is unique) — same salting the + # at-peace case already required. mint_signature = signature - retired = await db.scalar(select(Entity).where(Entity.signature == signature)) - if retired is not None: + taken = await db.scalar(select(Entity).where(Entity.signature == signature)) + if taken is not None: mint_signature = f"{signature}:{uuid.uuid4().hex[:8]}" profile = await spirit_service.mint_profile( - mint_signature, channel, state.anomalies, state.language + mint_signature, channel, state.anomalies, state.language, + entropy=state.entropy, ) discoverer = await db.get(User, state.user_id) favor = discoverer.favor if discoverer is not None else 0.0 @@ -426,7 +484,19 @@ async def _handle_summon(state: SeanceState) -> None: ) return - await state.send_queue.put({"type": "status", "state": "summoning"}) + await state.send_queue.put( + { + "type": "status", + "state": "summoning", + # How much physical noise from the room actually fed this + # draw. Display only — a client can lie about it freely, and + # it is never used to weight or gate anything. + "entropy_bits": contribution_bits(state.entropy), + # Real astronomy, computed not fetched (app/celestial.py) — the + # seeker can see *why* tonight is different. + "sky": veil_thinness(state.longitude), + } + ) entity, is_new = await _summon(state, state.mode if state.mode != "unknown" else "ouija") state.entity = serialize_entity(entity) # A fresh presence invalidates any in-progress/completed ritual from @@ -453,6 +523,66 @@ TELL_CHANCE_ON_FRAGMENT = 0.2 TELL_CHANCE_ON_REPLY = 0.35 +# Chance that a shift in the room pulls unprompted speech through. This is +# not a reply to anything — see SpiritService.manifest(): the prompt +# contains no seeker input at all, and the token-sampling seed comes from +# physical noise measured in that room. Kept well under half so silence +# stays the norm and being spoken to unbidden stays unnerving rather than +# chatty. +MANIFEST_CHANCE_ON_ANOMALY = 0.28 + + +def _room_readings(state: SeanceState, anomaly: dict) -> dict: + """The measured state of the room, rendered plainly for the entity. + + Values are reported as measurements, never as interpretations — the + model should react to "magnitude 31.4 dB over floor", not to + "terrifying paranormal spike". Putting the conclusion in the prompt + would mean the horror came from us instead of from the entity. + """ + readings: dict[str, str] = {} + source = anomaly.get("source") + if source: + readings["channel"] = str(source) + freq = anomaly.get("frequency") + if isinstance(freq, (int, float)): + readings["frequency"] = f"{freq:.2f}" + mag = anomaly.get("magnitude") + if isinstance(mag, (int, float)): + readings["deviation above the floor"] = f"{mag:.1f}" + readings["disturbances so far"] = str(len(state.anomalies)) + sky = veil_thinness(state.longitude) + readings["moon"] = f"{sky['moon_name']} ({sky['moon_illumination']:.0%} lit)" + if sky["witching_proximity"] is not None: + readings["nearness to the dead of night"] = f"{sky['witching_proximity']:.0%}" + if state.entropy: + readings["noise gathered from the room"] = f"{contribution_bits(state.entropy)} bits" + return readings + + +async def _maybe_manifest(state: SeanceState, anomaly: dict) -> None: + """Let the entity speak unbidden, if the room pulls it through.""" + if state.entity is None: + return + if state.tell_rng.random() >= MANIFEST_CHANCE_ON_ANOMALY: + return + try: + text = await spirit_service.manifest( + state.entity, + _room_readings(state, anomaly), + state.language, + entropy=state.entropy, + ) + except Exception: + # Deliberately broad. Unprompted speech is a bonus, never + # load-bearing — a busy queue, an unreachable Ollama, or a + # malformed response must leave the séance quiet rather than + # surfacing an error for something the seeker never asked for. + return + if text: + await _speak(state, "manifest", text) + + async def _maybe_tell(state: SeanceState, chance: float) -> None: if state.entity is None: return @@ -495,6 +625,7 @@ async def _handle_anomaly(state: SeanceState, message: dict) -> None: return await _speak(state, "fragment", fragment) await _maybe_tell(state, TELL_CHANCE_ON_FRAGMENT) + await _maybe_manifest(state, anomaly) async def _handle_question(state: SeanceState, text: str) -> None: @@ -811,6 +942,21 @@ async def session_socket(websocket: WebSocket) -> None: session.language = state.language await db.commit() elif msg_type == "summon": + # The seeker's room contributes its physical noise to this + # draw. Stored raw and untrusted — app/entropy.py mixes it + # with fresh server secrets on every use, so a chosen or + # replayed value can add unpredictability but never steer + # the outcome. + contributed = message.get("entropy") + if isinstance(contributed, str) and contributed.strip(): + state.entropy = contributed[:512] + # Longitude only, and only if the seeker granted location. + # Out-of-range values are dropped rather than clamped: a + # bogus longitude should mean "no location", not a + # confidently wrong solar midnight. + lon = message.get("longitude") + if isinstance(lon, (int, float)) and -180 <= lon <= 180: + state.longitude = float(lon) await _handle_summon(state) elif msg_type == "anomaly": await _handle_anomaly(state, message) diff --git a/backend/tests/test_celestial.py b/backend/tests/test_celestial.py new file mode 100644 index 0000000..1997306 --- /dev/null +++ b/backend/tests/test_celestial.py @@ -0,0 +1,176 @@ +"""Tests for the real-astronomy layer. + +These check the maths against genuinely known astronomical events rather +than against the implementation's own output — a test that only asserts +"the function returns what the function returns" would happily pass on +completely wrong orbital mechanics. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from app.celestial import ( + PHASE_NAMES, + SYNODIC_MONTH_DAYS, + moon_illumination, + moon_phase, + moon_phase_name, + solar_midnight, + veil_thinness, + witching_proximity, +) + + +def _utc(y, m, d, hh=0, mm=0): + return datetime(y, m, d, hh, mm, tzinfo=timezone.utc) + + +class TestMoonPhase: + def test_epoch_new_moon_reads_as_new(self): + # The reference epoch itself must land on ~0. + assert moon_phase(_utc(2000, 1, 6, 18, 14)) == pytest.approx(0.0, abs=0.01) + + def test_known_full_moons_read_as_full(self): + # Real full moons, from published ephemerides. Tolerance is ~half a + # day in phase terms, which is what the mean-synodic approximation + # honestly supports. + for when in ( + _utc(2024, 1, 25, 17, 54), + _utc(2024, 8, 19, 18, 26), + _utc(2025, 3, 14, 6, 55), + ): + assert moon_phase(when) == pytest.approx(0.5, abs=0.04), when + + def test_known_new_moons_read_as_new(self): + for when in ( + _utc(2024, 2, 9, 22, 59), + _utc(2024, 9, 3, 1, 56), + _utc(2025, 3, 29, 10, 58), + ): + phase = moon_phase(when) + # New moon sits at the 0/1 wraparound, so accept either end. + assert min(phase, 1 - phase) == pytest.approx(0.0, abs=0.04), when + + def test_phase_always_in_unit_range(self): + when = _utc(2024, 1, 1) + for i in range(400): + p = moon_phase(when + timedelta(days=i)) + assert 0.0 <= p < 1.0 + + def test_advances_a_full_cycle_over_one_synodic_month(self): + start = _utc(2024, 6, 1) + later = start + timedelta(days=SYNODIC_MONTH_DAYS) + assert moon_phase(start) == pytest.approx(moon_phase(later), abs=0.001) + + def test_naive_datetime_is_treated_as_utc_rather_than_raising(self): + # A summon must never fail because a caller forgot a tzinfo. + naive = datetime(2024, 1, 25, 17, 54) + assert moon_phase(naive) == pytest.approx(0.5, abs=0.04) + + +class TestPhaseNames: + def test_new_and_full_map_to_the_right_names(self): + assert moon_phase_name(0.0) == "new moon" + assert moon_phase_name(0.5) == "full moon" + + def test_quarters_map_to_the_right_names(self): + assert moon_phase_name(0.25) == "first quarter" + assert moon_phase_name(0.75) == "last quarter" + + def test_every_phase_yields_a_known_name(self): + for i in range(100): + assert moon_phase_name(i / 100) in PHASE_NAMES + + def test_names_progress_in_order_across_a_cycle(self): + seen = [] + for i in range(64): + name = moon_phase_name(i / 64) + if not seen or seen[-1] != name: + seen.append(name) + # Starts and ends on "new moon" (the cycle wraps), covering all 8. + assert set(seen) == set(PHASE_NAMES) + + +class TestIllumination: + def test_new_moon_is_dark_and_full_moon_is_lit(self): + assert moon_illumination(0.0) == pytest.approx(0.0, abs=1e-9) + assert moon_illumination(0.5) == pytest.approx(1.0, abs=1e-9) + + def test_quarters_are_half_lit(self): + assert moon_illumination(0.25) == pytest.approx(0.5, abs=1e-9) + assert moon_illumination(0.75) == pytest.approx(0.5, abs=1e-9) + + def test_always_in_unit_range(self): + for i in range(200): + v = moon_illumination(i / 200) + assert 0.0 <= v <= 1.0 + + +class TestSolarMidnight: + def test_greenwich_midnight_is_utc_midnight(self): + got = solar_midnight(0.0, _utc(2024, 6, 15, 23, 0)) + assert got.hour == 0 and got.minute == 0 + + def test_longitude_shifts_midnight_by_an_hour_per_15_degrees(self): + at_zero = solar_midnight(0.0, _utc(2024, 6, 15, 12, 0)) + at_fifteen_east = solar_midnight(15.0, _utc(2024, 6, 15, 12, 0)) + delta_hours = (at_zero - at_fifteen_east).total_seconds() / 3600 + assert delta_hours == pytest.approx(1.0, abs=0.01) + + def test_returns_the_nearest_midnight_not_a_stale_one(self): + # Just before local midnight the answer must be the one ahead, + # never the one ~24h behind. + when = _utc(2024, 6, 15, 23, 50) + assert abs((solar_midnight(0.0, when) - when).total_seconds()) < 3600 + + +class TestWitchingProximity: + def test_peaks_at_solar_midnight(self): + when = _utc(2024, 6, 15, 0, 0) + assert witching_proximity(0.0, when) == pytest.approx(1.0, abs=0.01) + + def test_bottoms_out_at_solar_noon(self): + when = _utc(2024, 6, 15, 12, 0) + assert witching_proximity(0.0, when) == pytest.approx(0.0, abs=0.01) + + def test_always_in_unit_range_around_the_clock(self): + base = _utc(2024, 6, 15) + for hour in range(48): + v = witching_proximity(0.0, base + timedelta(hours=hour)) + assert 0.0 <= v <= 1.0 + + def test_accounts_for_the_seekers_longitude(self): + # 03:00 UTC is the dead of night at Greenwich but not in Tokyo. + when = _utc(2024, 6, 15, 3, 0) + assert witching_proximity(0.0, when) > witching_proximity(139.7, when) + + +class TestVeilThinness: + def test_reports_all_components(self): + r = veil_thinness(0.0, _utc(2024, 1, 25, 0, 0)) + assert set(r) == { + "moon_phase", + "moon_name", + "moon_illumination", + "witching_proximity", + "thinness", + } + + def test_moon_only_when_location_is_unknown(self): + r = veil_thinness(None, _utc(2024, 1, 25, 17, 54)) + assert r["witching_proximity"] is None + # A full moon with no location should still read as thin. + assert r["thinness"] > 0.9 + + def test_full_moon_at_solar_midnight_is_thinner_than_new_moon_at_noon(self): + best = veil_thinness(0.0, _utc(2024, 1, 25, 0, 0)) + worst = veil_thinness(0.0, _utc(2024, 2, 9, 12, 0)) + assert best["thinness"] > worst["thinness"] + + def test_thinness_always_in_unit_range(self): + base = _utc(2024, 1, 1) + for i in range(0, 400, 7): + for lon in (-180.0, -75.0, 0.0, 139.7, 180.0): + v = veil_thinness(lon, base + timedelta(days=i, hours=i % 24)) + assert 0.0 <= v["thinness"] <= 1.0 diff --git a/backend/tests/test_entropy.py b/backend/tests/test_entropy.py new file mode 100644 index 0000000..b77efd4 --- /dev/null +++ b/backend/tests/test_entropy.py @@ -0,0 +1,117 @@ +"""Tests for the veil's randomness mixing. + +The property that actually matters here is adversarial: a client that +controls its entropy contribution completely must not be able to control, +predict, or bias the outcome. Most of these tests attack that directly +rather than just checking the happy path. +""" + +import collections + +from app.entropy import ( + contribution_bits, + normalize_contribution, + veil_float, + veil_random, + veil_seed, +) + + +class TestNormalizeContribution: + def test_parses_a_hex_digest(self): + assert normalize_contribution("00ff10") == b"\x00\xff\x10" + + def test_empty_for_non_string_input(self): + for junk in (None, 123, {"a": 1}, [1, 2], b"bytes"): + assert normalize_contribution(junk) == b"" + + def test_empty_for_blank_string(self): + assert normalize_contribution("") == b"" + assert normalize_contribution(" ") == b"" + + def test_non_hex_still_contributes_rather_than_being_discarded(self): + # A client with a different encoding shouldn't silently stop + # contributing physical noise; mixing raw text is harmless. + assert normalize_contribution("not-hex-at-all") != b"" + + def test_truncates_an_oversized_payload(self): + huge = "ab" * 10_000 + assert len(normalize_contribution(huge)) <= 512 + + def test_never_raises_on_hostile_input(self): + for junk in ("zz", "0", "0x1234", "\x00\x01", "💀" * 50, "-1"): + normalize_contribution(junk) # must not raise + + +class TestVeilSeed: + def test_returns_32_bytes(self): + assert len(veil_seed("aabb")) == 32 + + def test_identical_input_produces_different_seeds(self): + # THE core security property: the same client contribution must + # NOT reproduce the same draw, or a seeker could replay a + # contribution that once yielded a mythic entity. + seeds = {veil_seed("deadbeef").hex() for _ in range(200)} + assert len(seeds) == 200 + + def test_unpredictable_even_with_no_contribution_at_all(self): + seeds = {veil_seed().hex() for _ in range(200)} + assert len(seeds) == 200 + + def test_unpredictable_with_an_adversarially_degenerate_contribution(self): + # All-zeros is the worst case a client can send. + seeds = {veil_seed("00" * 32).hex() for _ in range(200)} + assert len(seeds) == 200 + + def test_context_domain_separates_draws(self): + # Two draws from one contribution must not be correlated, so + # learning one (e.g. the visible rarity) reveals nothing about the + # other (the hidden traits). + a = {veil_seed("aabb", "entity").hex() for _ in range(100)} + b = {veil_seed("aabb", "traits").hex() for _ in range(100)} + assert not (a & b) + + +class TestVeilRandom: + def test_returns_a_usable_random_instance(self): + rng = veil_random("aabb") + assert 0.0 <= rng.random() < 1.0 + assert rng.choice([1, 2, 3]) in (1, 2, 3) + + def test_successive_calls_are_independent(self): + first = [veil_random("same").random() for _ in range(50)] + assert len(set(first)) == 50 + + def test_client_cannot_force_a_repeated_outcome(self): + # Simulates a seeker replaying one contribution to grind for a rare + # result: the distribution must stay spread out. + draws = [veil_random("c0ffee", "rarity").random() for _ in range(400)] + assert len(set(draws)) == 400 + buckets = collections.Counter(int(d * 4) for d in draws) + # With 400 draws across 4 buckets, a client steering the result + # would show up as a badly skewed histogram. + for count in buckets.values(): + assert 40 < count < 210 + + def test_output_is_roughly_uniform(self): + draws = [veil_float() for _ in range(2000)] + buckets = collections.Counter(int(d * 10) for d in draws) + assert len(buckets) == 10 + for count in buckets.values(): + assert 120 < count < 290 # ~200 expected, generous bounds + + def test_mean_is_near_a_half(self): + draws = [veil_float("aabb", "spread") for _ in range(2000)] + assert 0.45 < sum(draws) / len(draws) < 0.55 + + +class TestContributionBits: + def test_counts_bits_of_real_contribution(self): + assert contribution_bits("00ff") == 16 + assert contribution_bits("") == 0 + assert contribution_bits(None) == 0 + + def test_a_lying_client_cannot_inflate_beyond_the_cap(self): + # Display-only, but it still must not become an unbounded number + # driven by client input. + assert contribution_bits("ab" * 10_000) <= 512 * 8 diff --git a/backend/tests/test_ws_ritual_judgment.py b/backend/tests/test_ws_ritual_judgment.py index a53b863..6e9de37 100644 --- a/backend/tests/test_ws_ritual_judgment.py +++ b/backend/tests/test_ws_ritual_judgment.py @@ -21,7 +21,7 @@ from app.rate_limit import RateLimiter class FakeSpiritService: - async def mint_profile(self, signature, channel, anomalies, language="en"): + async def mint_profile(self, signature, channel, anomalies, language="en", entropy=None): return fallback_profile(signature) async def fragment(self, source, anomaly, language="en"): diff --git a/backend/tests/test_ws_session.py b/backend/tests/test_ws_session.py index b378604..40d56b1 100644 --- a/backend/tests/test_ws_session.py +++ b/backend/tests/test_ws_session.py @@ -15,7 +15,7 @@ from app.rate_limit import RateLimiter class FakeSpiritService: - async def mint_profile(self, signature, channel, anomalies, language="en"): + async def mint_profile(self, signature, channel, anomalies, language="en", entropy=None): return fallback_profile(signature) async def fragment(self, source, anomaly, language="en"): @@ -160,7 +160,24 @@ async def test_anomalies_attune_then_produce_fragments(sync_client): @pytest.mark.asyncio -async def test_same_signature_recontacts_same_entity(sync_client): +async def test_familiar_presence_answers_again_on_a_known_channel(sync_client, monkeypatch): + """The Codex mechanic: a channel's known spirit is re-contactable. + + Whether it answers is a genuine draw against physical entropy + (`ws.RETURN_CHANCE`), so this pins the probability to 1.0 rather than + relying on the default — at 0.72 this assertion would otherwise pass + only ~72% of the time, which is worse than failing. + """ + # Scoped limiters: the module-level ones are shared singletons that + # accumulate across the whole session, and this test summons more than + # once. Without this it silently eats the per-IP budget that + # test_summon_rate_limited_* depends on, making *those* tests hang + # waiting for an entity frame that was rate-limited away. + monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60)) + monkeypatch.setattr( + app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60) + ) + monkeypatch.setattr(app.ws, "RETURN_CHANCE", 1.0) _login(sync_client, "mediumx") anomalies = [ {"type": "anomaly", "source": "radio", "frequency": 101.0 + i, "magnitude": 5.0 + i} @@ -183,6 +200,48 @@ async def test_same_signature_recontacts_same_entity(sync_client): assert second_frame["entity"]["contact_count"] == 2 +@pytest.mark.asyncio +async def test_something_else_can_answer_a_known_channel(sync_client, monkeypatch): + """The point of the entropy rework: contact is not a database lookup. + + With the return draw forced to fail, calling into a channel that + already holds a spirit mints a *different* one rather than handing back + the same row — and the newcomer gets its own signature, since the + column is unique and the original still holds the base string. + """ + # Scoped limiters: the module-level ones are shared singletons that + # accumulate across the whole session, and this test summons more than + # once. Without this it silently eats the per-IP budget that + # test_summon_rate_limited_* depends on, making *those* tests hang + # waiting for an entity frame that was rate-limited away. + monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60)) + monkeypatch.setattr( + app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60) + ) + _login(sync_client, "channel-crosser") + anomalies = [ + {"type": "anomaly", "source": "radio", "frequency": 88.0 + i, "magnitude": 9.0 + i} + for i in range(4) + ] + + monkeypatch.setattr(app.ws, "RETURN_CHANCE", 1.0) + with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws: + _read_until(ws, "session") + for anomaly in anomalies: + ws.send_json(anomaly) + first = _read_until(ws, "entity")["entity"] + + # Same room, same anomalies — but this time nothing familiar picks up. + monkeypatch.setattr(app.ws, "RETURN_CHANCE", 0.0) + with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws: + _read_until(ws, "session") + for anomaly in anomalies: + ws.send_json(anomaly) + second = _read_until(ws, "entity") + assert second["is_new"] is True + assert second["entity"]["id"] != first["id"] + + @pytest.mark.asyncio async def test_summon_rate_limited_per_account(sync_client, monkeypatch): # Swap in a tight, test-scoped limiter so this doesn't depend on (or @@ -320,7 +379,7 @@ async def test_summon_high_rarity_item_drop_is_persisted_and_sent( monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=1000, window_seconds=60)) class MythicSpiritService: - async def mint_profile(self, signature, channel, anomalies, language="en"): + async def mint_profile(self, signature, channel, anomalies, language="en", entropy=None): profile = fallback_profile(signature) profile["rarity"] = "mythic" return profile @@ -384,7 +443,7 @@ async def test_summon_common_rarity_does_not_roll_a_drop(sync_client, db_session return profile class CommonSpiritService: - async def mint_profile(self, signature, channel, anomalies, language="en"): + async def mint_profile(self, signature, channel, anomalies, language="en", entropy=None): return _common_only(signature) async def fragment(self, source, anomaly, language="en"): diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index dcf2b73..b473d9b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -87,7 +87,17 @@ export type ClientFrame = | { type: 'ping' } | { type: 'set_mode'; mode: Mode } | { type: 'language'; language: Language } - | { type: 'summon' } + | { + type: 'summon' + /** Conditioned physical noise harvested from the seeker's room (see + * lib/entropy.ts). Optional: contact works without it, just without + * the room's participation. The server treats this as untrusted and + * only ever mixes it with its own secrets. */ + entropy?: string + /** Longitude only, if location was granted — all that solar midnight + * needs. Deliberately not a full coordinate. */ + longitude?: number + } | { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number } | { type: 'question'; text: string } | { type: 'passive'; enabled: boolean } diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index 56369b9..b3c6b68 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -480,7 +480,7 @@ function WirePanel() { // ---- EVP panel: microphone voice-band listening ---- function EvpPanel() { - const { sendAnomaly } = useSeance() + const { sendAnomaly, entropy } = useSeance() const { user } = useAuth() const { t } = useTranslation() // "listening_tool" unlock (GET /auth/me's `unlocks`) lowers the EVP @@ -546,6 +546,9 @@ function EvpPanel() { const out = buffers[0] === binsRef.current ? buffers[1] : buffers[0] for (let i = 0; i < db.length; i++) out[i] = db[i] binsRef.current = out + // Real thermal/acoustic noise from this room, banked toward the + // next summon — see lib/entropy.ts. + entropy.addFrame(db) const now = performance.now() if (now - lastMeterRef.current > 200) { @@ -640,7 +643,7 @@ function EvpPanel() { // ---- radio panel: RTL-SDR FM sweep ---- function RadioPanel() { - const { sendAnomaly } = useSeance() + const { sendAnomaly, entropy } = useSeance() const { t } = useTranslation() // WebUSB hides itself entirely outside secure ground (https / localhost). const secure = useMemo(() => isSecureContext(), []) @@ -721,6 +724,7 @@ function RadioPanel() { // this is already a distinct object each time — the scope uses // that identity change to know a new row is ready. binsRef.current = db + entropy.addFrame(db) const now = performance.now() if (now - lastTuneUiRef.current > 250) { lastTuneUiRef.current = now diff --git a/frontend/src/state/seance.tsx b/frontend/src/state/seance.tsx index 8627637..5ab41ff 100644 --- a/frontend/src/state/seance.tsx +++ b/frontend/src/state/seance.tsx @@ -16,6 +16,7 @@ import { VeilSocket } from '../lib/ws' import type { VeilConnectionState } from '../lib/ws' import { SpiritAudioPlayer } from '../lib/audio' import { ghostLogBus } from '../lib/ghostLogBus' +import { EntropyPool } from '../lib/entropy' import type { EntityTraits, JudgmentConsequence, @@ -511,6 +512,10 @@ export type SeanceApi = { setPassive: (enabled: boolean) => void setLanguage: (language: Language) => void summon: () => void + /** Shared pool every live sensor feeds real physical noise into. Drained + * and sent with each summon, so the room genuinely participates in what + * answers — see backend app/entropy.py. */ + entropy: EntropyPool ask: (text: string) => void sendAnomaly: (source: 'radio' | 'evp' | 'wire' | 'emf', frequency: number, magnitude: number) => void playUtterance: (utteranceId: string) => void @@ -528,6 +533,11 @@ export const SeanceContext = createContext(null) export function SeanceProvider({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(seanceReducer, initialSeanceState) const socketRef = useRef(null) + const entropyRef = useRef(new EntropyPool()) + // Longitude only — all solar midnight needs. Requested once, silently: + // a denied or unavailable location simply means the celestial reading + // falls back to moon-only, never an error the seeker has to dismiss. + const longitudeRef = useRef(null) const playerRef = useRef(null) const stateRef = useRef(state) stateRef.current = state @@ -580,6 +590,30 @@ export function SeanceProvider({ children }: { children: ReactNode }) { } }, []) + // Ask for location once, quietly, and only keep the longitude — it is + // all solar midnight needs (see backend app/celestial.py), and keeping a + // full coordinate would mean retaining precise location we have no use + // for. A denial or a device without GPS is not an error path: the + // celestial reading simply falls back to moon-only, and nothing is ever + // shown to the seeker about it. + useEffect(() => { + if (typeof navigator === 'undefined' || !navigator.geolocation) return + let cancelled = false + navigator.geolocation.getCurrentPosition( + (pos) => { + if (!cancelled) longitudeRef.current = pos.coords.longitude + }, + () => undefined, + // Low accuracy on purpose: a coarse fix is plenty for a + // hour-of-day calculation, and it is far faster and cheaper on + // battery than waking the GPS for a precise one. + { enableHighAccuracy: false, timeout: 8000, maximumAge: 600_000 }, + ) + return () => { + cancelled = true + } + }, []) + const setMode = useCallback((mode: Mode) => { dispatch({ type: 'set_mode', mode }) socketRef.current?.send({ type: 'set_mode', mode }) @@ -596,7 +630,21 @@ export function SeanceProvider({ children }: { children: ReactNode }) { }, []) const summon = useCallback(() => { - socketRef.current?.send({ type: 'summon' }) + // Drain whatever physical noise the running sensors have banked and + // send it with the request. Async because conditioning goes through + // WebCrypto; the summon is sent either way, since entropy is an + // enrichment and never a precondition for contact. + void entropyRef.current + .drain() + .catch(() => null) + .then((contribution) => { + const lon = longitudeRef.current + socketRef.current?.send({ + type: 'summon', + ...(contribution ? { entropy: contribution } : {}), + ...(lon !== null ? { longitude: lon } : {}), + }) + }) }, []) const ask = useCallback((text: string) => { @@ -652,6 +700,7 @@ export function SeanceProvider({ children }: { children: ReactNode }) { setPassive, setLanguage, summon, + entropy: entropyRef.current, ask, sendAnomaly, playUtterance,