feat: the room decides — physical entropy, real astronomy, unprompted speech
Three changes that together replace "deterministic hash decides everything"
with "the physical world genuinely participates".
PHYSICAL ENTROPY (app/entropy.py, lib/entropy.ts)
Contact was a database lookup: signature_from_anomalies() hashed the
anomaly pattern, so identical conditions always produced an identical
spirit. Now the client harvests real thermal/acoustic/RF noise from the
microphone and receiver noise floors — Von Neumann debiased, SHA-256
conditioned — and contributes it to every summon.
The client is untrusted by construction. A contribution is never a seed:
every draw is HMAC-SHA256(fresh server secret, client bytes || context).
Because fresh CSPRNG server bytes are always present, the output is
unpredictable and uniform no matter what the client sends — all-zeros, a
replayed value, or one chosen adversarially. The room can only ever ADD
unpredictability, never steer the result. Tests assert this directly:
400 replays of one contribution stay uniformly distributed.
A signature now identifies a *channel*, not a spirit. Whether the familiar
presence answers or something else picks up is a real draw
(RETURN_CHANCE). The Codex stays collectable; it is just no longer
guaranteed. test_same_signature_recontacts_same_entity became two tests —
one pinning the probability to prove re-contact works, one pinning it to
zero to prove something else can answer — because at 0.72 the original
would have passed ~72% of the time, which is worse than failing.
REAL ASTRONOMY (app/celestial.py)
Moon phase from the standard mean-synodic approximation, and true solar
midnight from the seeker's own longitude — the real witching hour for
where they are standing, not clock 3am. Computed, never fetched: an API
that can fail would mean the veil silently changes behaviour during
someone else's outage. Validated against published ephemeris dates (2024
full moons, 2025 new moons) rather than against its own output. A thinner
veil erodes the familiar presence's claim on a channel, so a full moon at
solar midnight makes strangers likelier. Only longitude is kept, never a
full coordinate; a denied location degrades to moon-only, silently.
GENERATION FROM NOTHING (SpiritService.manifest)
Not chat_stream with an empty question. The prompt contains no seeker
input at all — only measured room state, rendered as measurements
("deviation above the floor: 31.4") rather than interpretations
("terrifying spike"), so the horror comes from the entity instead of from
us. And the Ollama `seed` is derived from the physical entropy harvested
in that room, which fixes the token-sampling path: the room genuinely
selects the words. Change the noise, get different speech. Two rooms
cannot produce the same utterance.
Rendered as an intrusion rather than a reply — violet edge, full opacity
against the faded ambient murmurs, brief blur-in. The unsettling part is
that it is perfectly clear and completely unbidden.
Also fixes a hang I introduced: the two new summon tests consumed the
shared module-level per-IP budget, so test_summon_rate_limited_* blocked
forever on an entity frame that had been rate-limited away. They now scope
their own limiters.
264 backend + 355 frontend tests pass; i18n parity gate passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
159
backend/app/celestial.py
Normal file
159
backend/app/celestial.py
Normal file
@@ -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)),
|
||||
}
|
||||
Reference in New Issue
Block a user