feat: the moon shapes who answers; wire magnetometer; drop dead CSS
MOON INFLUENCE ON SUMMONING
Astronomy previously only decided whether a channel's familiar spirit
returned. It now shapes *who comes through*:
- Rarity skews with real moon illumination. At full moon the rare and
mythic weights roughly triple while common recedes, so a mythic
summoning becomes a reason to go out on the right night rather than a
flat lottery. Deliberately a skew and never a gate — every tier stays
reachable on every night, because someone who can only play midweek
should not be locked out of the good spirits.
- Hidden traits take a small moonlit nudge: power and volatility rise,
alignment drifts slightly darker. Capped at 0.12 and clamped to [0,1],
so a full moon intensifies what a spirit already is instead of
rewriting it. Deceptiveness is untouched — whether a spirit lies is its
own nature, not the sky's doing.
- The mint prompt is told the phase, and explicitly told the entity must
never mention or seem aware of it. It shapes who they are, not their
dialogue; a ghost remarking on the moonlight would break the illusion
instantly.
Tests assert the outcomes shift in practice (mythic rate over 4000 draws,
rare-tier counts across 300 fallback profiles), not merely that the code
runs. test_mint_prompt_never_receives_traits now allows `sky` while still
forbidding `traits`: moon phase is public, observable state anyone can look
up, hidden ground truth is not.
GEOMAGNETIC (app/geomagnetic.py)
Real NOAA SWPC planetary K-index, verified against the live endpoint —
which caught a real bug: I had written the parser against an
array-of-arrays shape, and the actual feed serves a list of objects
(`estimated_kp` float, `kp_index` int, `kp` a display string with a letter
suffix). Fixed, and the tests now use the real captured shape. Cached,
never blocking, and a failed refresh keeps serving the last real value —
an hour-old genuine measurement beats nothing, and geomagnetic conditions
do not change fast enough for that to mislead.
MAGNETOMETER WIRED
MagnetometerListener existed but was never connected. The EMF panel now
runs it alongside the motion listener where the hardware exists, so the
"EMF meter" measures actual magnetic field in µT rather than only
inferring disturbance from movement. Additive: the motion path is
untouched and remains the only option on iOS. Its field jitter also feeds
the entropy pool.
DEAD CODE
Removed .evp-scope and .radio-waterfall, orphaned when both panels moved to
the shared SpectrumScope. Audited every other flagged export first and left
them alone — they are used internally, and "not imported elsewhere" is not
the same as dead.
Adds docs/CHANNELS.md recording what each channel measures and, honestly,
what has actually been verified against hardware versus only written
carefully.
311 backend + 355 frontend tests pass; i18n parity gate passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from app.entities import (
|
||||
fallback_profile,
|
||||
normalize_profile,
|
||||
@@ -52,7 +54,9 @@ def test_normalize_profile_fills_and_clamps():
|
||||
assert profile["name"] == "Hollow Briar"
|
||||
assert profile["rarity"] == "common"
|
||||
assert profile["voice"]["voice_id"] != "nonexistent"
|
||||
assert -6 <= profile["voice"]["pitch"] <= 6
|
||||
# Widened from +-6 when voices moved to coherent archetypes: a child
|
||||
# and an elder man have to be able to land genuinely far apart.
|
||||
assert -9 <= profile["voice"]["pitch"] <= 9
|
||||
assert 0.01 <= profile["voice"]["noise"] <= 0.08
|
||||
assert profile["visual"]["form"] in ("wisp", "banshee", "fairy", "shade")
|
||||
assert 0 <= profile["visual"]["hue"] <= 360
|
||||
@@ -150,4 +154,186 @@ def test_mint_prompt_never_receives_traits():
|
||||
generation."""
|
||||
params = set(inspect.signature(llm_prompts.mint_prompt).parameters)
|
||||
assert "traits" not in params
|
||||
assert params == {"signature", "channel", "anomaly_summary", "voice_ids"}
|
||||
# `sky` is public, observable state (moon phase — anyone can look up),
|
||||
# so it may shape the persona. Hidden ground truth still must not.
|
||||
assert params == {"signature", "channel", "anomaly_summary", "voice_ids", "sky"}
|
||||
|
||||
|
||||
# --- voice archetypes -------------------------------------------------------
|
||||
|
||||
|
||||
class TestVoiceArchetypes:
|
||||
"""A voice must sound like a person, not like four random knobs.
|
||||
|
||||
These assert the *covariance* that makes an archetype legible — a child
|
||||
being high AND fast AND clean — because that is exactly what
|
||||
independently-rolled parameters would destroy.
|
||||
"""
|
||||
|
||||
def _throats(self, n=200, language="en"):
|
||||
import random as _r
|
||||
|
||||
from app.entities import roll_voice
|
||||
|
||||
return [roll_voice(_r.Random(i), language) for i in range(n)]
|
||||
|
||||
def test_every_archetype_is_reachable(self):
|
||||
from app.entities import VOICE_ARCHETYPES
|
||||
|
||||
seen = {t["archetype"] for t in self._throats(400)}
|
||||
assert seen == {a["key"] for a in VOICE_ARCHETYPES}
|
||||
|
||||
def test_all_parameters_stay_inside_the_synthesis_limits(self):
|
||||
for t in self._throats(300):
|
||||
assert -9 <= t["pitch"] <= 9
|
||||
assert 0.75 <= t["rate"] <= 1.20
|
||||
assert 0.01 <= t["noise"] <= 0.10
|
||||
assert 0.0 <= t["echo"] <= 0.5
|
||||
|
||||
def test_only_installed_voices_are_chosen(self):
|
||||
from app.tts.voices import VOICES
|
||||
|
||||
for t in self._throats(300):
|
||||
assert t["voice_id"] in VOICES
|
||||
|
||||
def test_spanish_spirits_get_spanish_models(self):
|
||||
# An English model reading Spanish text mangles it; better to carry
|
||||
# the difference in shaping alone.
|
||||
from app.tts.voices import ES_VOICE_IDS
|
||||
|
||||
for t in self._throats(120, language="es"):
|
||||
assert t["voice_id"] in ES_VOICE_IDS
|
||||
|
||||
def test_children_are_higher_and_faster_than_elders(self):
|
||||
throats = self._throats(600)
|
||||
child = [t for t in throats if t["archetype"] == "child"]
|
||||
elder = [t for t in throats if t["archetype"] == "elder_man"]
|
||||
assert child and elder
|
||||
assert min(t["pitch"] for t in child) > max(t["pitch"] for t in elder)
|
||||
assert min(t["rate"] for t in child) > max(t["rate"] for t in elder)
|
||||
|
||||
def test_damaged_throats_are_noisier_than_clean_ones(self):
|
||||
throats = self._throats(600)
|
||||
burned = [t for t in throats if t["archetype"] == "burned"]
|
||||
child = [t for t in throats if t["archetype"] == "child"]
|
||||
assert burned and child
|
||||
assert min(t["noise"] for t in burned) > max(t["noise"] for t in child)
|
||||
|
||||
def test_the_distant_archetype_is_the_most_reverberant(self):
|
||||
throats = self._throats(600)
|
||||
distant = [t for t in throats if t["archetype"] == "distant"]
|
||||
young = [t for t in throats if t["archetype"] == "young_man"]
|
||||
assert distant and young
|
||||
assert min(t["echo"] for t in distant) > max(t["echo"] for t in young)
|
||||
|
||||
def test_two_spirits_rarely_share_a_throat(self):
|
||||
# The whole point: distinguishable people, not a smear of one voice.
|
||||
throats = self._throats(120)
|
||||
fingerprints = {
|
||||
(t["voice_id"], round(t["pitch"], 1), round(t["rate"], 2)) for t in throats
|
||||
}
|
||||
assert len(fingerprints) > 100
|
||||
|
||||
def test_normalize_profile_reports_the_archetype(self):
|
||||
from app.entities import normalize_profile
|
||||
|
||||
profile = normalize_profile({"name": "Test"}, "sig-archetype")
|
||||
assert profile["voice"]["archetype"]
|
||||
|
||||
|
||||
# --- moon influence on summoning --------------------------------------------
|
||||
|
||||
|
||||
class TestMoonInfluence:
|
||||
"""The moon must change *who answers*, not just decorate the UI."""
|
||||
|
||||
def test_dark_sky_leaves_the_base_table_alone(self):
|
||||
from app.entities import BASE_RARITY_WEIGHTS, rarity_weights_for_moon
|
||||
|
||||
assert rarity_weights_for_moon(0.0) == pytest.approx(BASE_RARITY_WEIGHTS)
|
||||
|
||||
def test_unknown_sky_falls_back_to_the_base_table(self):
|
||||
from app.entities import BASE_RARITY_WEIGHTS, rarity_weights_for_moon
|
||||
|
||||
assert rarity_weights_for_moon(None) == BASE_RARITY_WEIGHTS
|
||||
|
||||
def test_a_full_moon_makes_rare_and_mythic_much_likelier(self):
|
||||
from app.entities import rarity_weights_for_moon
|
||||
|
||||
dark = rarity_weights_for_moon(0.0)
|
||||
full = rarity_weights_for_moon(1.0)
|
||||
assert full[2] > dark[2] * 2 # rare
|
||||
assert full[3] > dark[3] * 2 # mythic
|
||||
assert full[0] < dark[0] # common recedes
|
||||
|
||||
def test_every_tier_stays_reachable_on_every_night(self):
|
||||
# A skew, never a gate — someone who can only play midweek must not
|
||||
# be locked out of the good spirits.
|
||||
from app.entities import rarity_weights_for_moon
|
||||
|
||||
for lit in (0.0, 0.25, 0.5, 0.75, 1.0):
|
||||
assert all(w > 0 for w in rarity_weights_for_moon(lit))
|
||||
|
||||
def test_rarity_odds_actually_shift_in_practice(self):
|
||||
import random as _r
|
||||
|
||||
from app.entities import rarity_weights_for_moon
|
||||
from app.models.entity import RARITY_TIERS
|
||||
|
||||
def mythic_rate(lit):
|
||||
rng = _r.Random(1234)
|
||||
weights = rarity_weights_for_moon(lit)
|
||||
draws = [rng.choices(RARITY_TIERS, weights=weights)[0] for _ in range(4000)]
|
||||
return draws.count("mythic") / len(draws)
|
||||
|
||||
assert mythic_rate(1.0) > mythic_rate(0.0) * 1.8
|
||||
|
||||
def test_moonlight_raises_power_and_volatility(self):
|
||||
from app.entities import moon_trait_bias
|
||||
|
||||
base = {"alignment": 0.5, "power": 0.5, "volatility": 0.5, "deceptiveness": 0.5}
|
||||
full = moon_trait_bias(base, 1.0)
|
||||
assert full["power"] > base["power"]
|
||||
assert full["volatility"] > base["volatility"]
|
||||
assert full["alignment"] < base["alignment"]
|
||||
|
||||
def test_bias_is_a_thumb_on_the_scale_not_a_rewrite(self):
|
||||
from app.entities import moon_trait_bias
|
||||
|
||||
base = {"alignment": 0.5, "power": 0.5, "volatility": 0.5, "deceptiveness": 0.5}
|
||||
full = moon_trait_bias(base, 1.0)
|
||||
for key in base:
|
||||
assert abs(full[key] - base[key]) <= 0.12
|
||||
|
||||
def test_bias_never_escapes_the_unit_range(self):
|
||||
from app.entities import moon_trait_bias
|
||||
|
||||
for extreme in (0.0, 1.0):
|
||||
base = dict.fromkeys(
|
||||
("alignment", "power", "volatility", "deceptiveness"), extreme
|
||||
)
|
||||
for lit in (0.0, 0.5, 1.0):
|
||||
for v in moon_trait_bias(base, lit).values():
|
||||
assert 0.0 <= v <= 1.0
|
||||
|
||||
def test_unknown_sky_leaves_traits_untouched(self):
|
||||
from app.entities import moon_trait_bias
|
||||
|
||||
base = {"alignment": 0.3, "power": 0.7, "volatility": 0.2, "deceptiveness": 0.9}
|
||||
assert moon_trait_bias(base, None) == base
|
||||
|
||||
def test_deceptiveness_is_not_moon_driven(self):
|
||||
# Whether a spirit lies is its own nature, not the sky's doing.
|
||||
from app.entities import moon_trait_bias
|
||||
|
||||
base = {"alignment": 0.5, "power": 0.5, "volatility": 0.5, "deceptiveness": 0.5}
|
||||
assert moon_trait_bias(base, 1.0)["deceptiveness"] == 0.5
|
||||
|
||||
def test_fallback_profiles_respect_the_moon(self):
|
||||
from app.entities import fallback_profile
|
||||
|
||||
dark = [fallback_profile(f"s{i}", None, 0.0)["rarity"] for i in range(300)]
|
||||
full = [fallback_profile(f"s{i}", None, 1.0)["rarity"] for i in range(300)]
|
||||
rare_dark = sum(r in ("rare", "mythic") for r in dark)
|
||||
rare_full = sum(r in ("rare", "mythic") for r in full)
|
||||
assert rare_full > rare_dark
|
||||
|
||||
173
backend/tests/test_geomagnetic.py
Normal file
173
backend/tests/test_geomagnetic.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Tests for the NOAA geomagnetic feed.
|
||||
|
||||
No test here touches the network. The properties that matter are the
|
||||
defensive ones: a séance must never fail, block, or crash because a free
|
||||
third-party service is down, slow, or changed its schema.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.geomagnetic import (
|
||||
STORM_THRESHOLD,
|
||||
GeomagneticCache,
|
||||
_latest_kp,
|
||||
disturbance,
|
||||
storm_label,
|
||||
)
|
||||
|
||||
|
||||
def _payload(*kps):
|
||||
"""The real shape SWPC's planetary_k_index_1m endpoint serves, verified
|
||||
against the live service — a list of objects, not array-of-arrays."""
|
||||
return [
|
||||
{
|
||||
"time_tag": f"2026-07-28T0{i}:00:00",
|
||||
"kp_index": int(float(kp)) if kp not in (None, "") else kp,
|
||||
"estimated_kp": kp,
|
||||
"kp": f"{kp}Z",
|
||||
}
|
||||
for i, kp in enumerate(kps)
|
||||
]
|
||||
|
||||
|
||||
def _legacy_payload(*kps):
|
||||
"""The header-row shape some other SWPC endpoints use."""
|
||||
rows = [["time_tag", "Kp", "a_running", "station_count"]]
|
||||
for i, kp in enumerate(kps):
|
||||
rows.append([f"2026-07-28 0{i}:00:00", kp, "4", "8"])
|
||||
return rows
|
||||
|
||||
|
||||
class TestLatestKp:
|
||||
def test_reads_the_most_recent_row(self):
|
||||
assert _latest_kp(_payload("1.00", "2.33", "5.67")) == 5.67
|
||||
|
||||
def test_reads_the_real_live_payload_shape(self):
|
||||
# Captured verbatim from the live endpoint.
|
||||
live = [
|
||||
{"time_tag": "2026-07-27T23:59:00", "kp_index": 3, "estimated_kp": 3.00, "kp": "3Z"},
|
||||
{"time_tag": "2026-07-28T05:54:00", "kp_index": 1, "estimated_kp": 1.00, "kp": "1Z"},
|
||||
]
|
||||
assert _latest_kp(live) == 1.0
|
||||
|
||||
def test_prefers_the_fractional_estimate_over_the_rounded_integer(self):
|
||||
# estimated_kp carries the precision Kp actually has; kp_index is
|
||||
# its rounding, so a 4.67 must not be reported as a 4.
|
||||
row = [{"time_tag": "t", "kp_index": 4, "estimated_kp": 4.67, "kp": "4Z"}]
|
||||
assert _latest_kp(row) == 4.67
|
||||
|
||||
def test_ignores_the_display_string_field(self):
|
||||
# "5Z" is a display form with a letter suffix, not a number.
|
||||
row = [{"time_tag": "t", "kp": "5Z"}]
|
||||
assert _latest_kp(row) is None
|
||||
|
||||
def test_falls_back_to_kp_index_when_the_estimate_is_missing(self):
|
||||
row = [{"time_tag": "t", "kp_index": 6}]
|
||||
assert _latest_kp(row) == 6.0
|
||||
|
||||
def test_still_handles_the_legacy_header_row_shape(self):
|
||||
assert _latest_kp(_legacy_payload("1.00", "3.5")) == 3.5
|
||||
|
||||
def test_finds_the_kp_column_by_header_name(self):
|
||||
payload = [
|
||||
["time_tag", "a_running", "Kp"],
|
||||
["2026-07-28 00:00:00", "4", "3.5"],
|
||||
]
|
||||
assert _latest_kp(payload) == 3.5
|
||||
|
||||
def test_skips_trailing_rows_with_unusable_values(self):
|
||||
payload = _payload("2.00", "4.00")
|
||||
payload.append({"time_tag": "2026-07-28T03:00:00", "estimated_kp": None})
|
||||
payload.append({"time_tag": "2026-07-28T04:00:00", "estimated_kp": ""})
|
||||
assert _latest_kp(payload) == 4.0
|
||||
|
||||
def test_rejects_out_of_scale_values(self):
|
||||
# Kp is defined 0-9; anything else is corrupt, not a real storm.
|
||||
assert _latest_kp(_payload("42.0")) is None
|
||||
assert _latest_kp(_payload("-3.0")) is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"junk",
|
||||
[None, [], "not a list", {"kp": 5}, [["time_tag", "Kp"]], 42, [[], []]],
|
||||
)
|
||||
def test_malformed_payloads_return_none_instead_of_raising(self, junk):
|
||||
assert _latest_kp(junk) is None
|
||||
|
||||
def test_short_rows_do_not_index_error(self):
|
||||
assert _latest_kp([["time_tag", "Kp"], ["2026-07-28"]]) is None
|
||||
|
||||
def test_objects_missing_every_known_field_return_none(self):
|
||||
assert _latest_kp([{"time_tag": "t", "something_else": 3}]) is None
|
||||
|
||||
|
||||
class TestStormLabel:
|
||||
def test_uses_noaa_severity_bands(self):
|
||||
assert storm_label(0.0) == "quiet"
|
||||
assert storm_label(4.5) == "unsettled"
|
||||
assert storm_label(5.0) == "minor geomagnetic storm"
|
||||
assert storm_label(6.0) == "moderate geomagnetic storm"
|
||||
assert storm_label(7.0) == "strong geomagnetic storm"
|
||||
assert storm_label(9.0) == "severe geomagnetic storm"
|
||||
|
||||
def test_the_official_storm_threshold_is_where_storms_begin(self):
|
||||
assert "storm" not in storm_label(STORM_THRESHOLD - 0.1)
|
||||
assert "storm" in storm_label(STORM_THRESHOLD)
|
||||
|
||||
|
||||
class TestDisturbance:
|
||||
def test_maps_the_scale_onto_zero_to_one(self):
|
||||
assert disturbance(0.0) == 0.0
|
||||
assert disturbance(9.0) == 1.0
|
||||
assert disturbance(4.5) == pytest.approx(0.5)
|
||||
|
||||
def test_clamps_rather_than_extrapolating(self):
|
||||
assert disturbance(-5) == 0.0
|
||||
assert disturbance(99) == 1.0
|
||||
|
||||
|
||||
class TestCacheResilience:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cold_cache_with_no_network_reports_none_and_does_not_raise(
|
||||
self, monkeypatch
|
||||
):
|
||||
# Point at an unroutable host: the lookup must fail silently.
|
||||
cache = GeomagneticCache(url="http://127.0.0.1:9/never", ttl_s=600)
|
||||
assert await cache.get() is None
|
||||
assert await cache.reading() is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failed_refresh_keeps_serving_the_last_real_value(self):
|
||||
# An hour-old genuine measurement beats nothing, and geomagnetic
|
||||
# conditions don't change fast enough for that to mislead.
|
||||
cache = GeomagneticCache(url="http://127.0.0.1:9/never", ttl_s=0)
|
||||
cache._kp = 6.0
|
||||
assert await cache.get() == 6.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reading_reports_storm_state(self):
|
||||
cache = GeomagneticCache(url="http://127.0.0.1:9/never", ttl_s=600)
|
||||
cache._kp = 7.33
|
||||
cache._fetched_at = float("inf")
|
||||
reading = await cache.reading()
|
||||
assert reading["kp"] == 7.33
|
||||
assert reading["storm"] is True
|
||||
assert reading["label"] == "strong geomagnetic storm"
|
||||
assert 0.0 <= reading["disturbance"] <= 1.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quiet_conditions_are_not_reported_as_a_storm(self):
|
||||
cache = GeomagneticCache(url="http://127.0.0.1:9/never", ttl_s=600)
|
||||
cache._kp = 2.0
|
||||
cache._fetched_at = float("inf")
|
||||
reading = await cache.reading()
|
||||
assert reading["storm"] is False
|
||||
assert reading["label"] == "quiet"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_kp_never_performs_io(self):
|
||||
# The synchronous accessor exists precisely so hot paths can read
|
||||
# the value without awaiting anything.
|
||||
cache = GeomagneticCache(url="http://127.0.0.1:9/never")
|
||||
assert cache.cached_kp is None
|
||||
cache._kp = 3.0
|
||||
assert cache.cached_kp == 3.0
|
||||
@@ -21,7 +21,9 @@ from app.rate_limit import RateLimiter
|
||||
|
||||
|
||||
class FakeSpiritService:
|
||||
async def mint_profile(self, signature, channel, anomalies, language="en", entropy=None):
|
||||
async def mint_profile(
|
||||
self, signature, channel, anomalies, language="en", entropy=None, sky=None
|
||||
):
|
||||
return fallback_profile(signature)
|
||||
|
||||
async def fragment(self, source, anomaly, language="en"):
|
||||
|
||||
@@ -15,7 +15,9 @@ from app.rate_limit import RateLimiter
|
||||
|
||||
|
||||
class FakeSpiritService:
|
||||
async def mint_profile(self, signature, channel, anomalies, language="en", entropy=None):
|
||||
async def mint_profile(
|
||||
self, signature, channel, anomalies, language="en", entropy=None, sky=None
|
||||
):
|
||||
return fallback_profile(signature)
|
||||
|
||||
async def fragment(self, source, anomaly, language="en"):
|
||||
@@ -379,7 +381,9 @@ 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", entropy=None):
|
||||
async def mint_profile(
|
||||
self, signature, channel, anomalies, language="en", entropy=None, sky=None
|
||||
):
|
||||
profile = fallback_profile(signature)
|
||||
profile["rarity"] = "mythic"
|
||||
return profile
|
||||
@@ -443,7 +447,9 @@ 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", entropy=None):
|
||||
async def mint_profile(
|
||||
self, signature, channel, anomalies, language="en", entropy=None, sky=None
|
||||
):
|
||||
return _common_only(signature)
|
||||
|
||||
async def fragment(self, source, anomaly, language="en"):
|
||||
|
||||
Reference in New Issue
Block a user