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:
@@ -166,7 +166,8 @@
|
||||
"unsupportedTitle": "this vessel carries no field senses",
|
||||
"unsupported": "a phone in your hand becomes the meter — this vessel has no motion senses to lend. open the veil on a phone, or let the other modes answer.",
|
||||
"deniedTitle": "the field senses stay shut",
|
||||
"denied": "this vessel refused its motion senses. grant motion access in your browser settings, then raise the meter again."
|
||||
"denied": "this vessel refused its motion senses. grant motion access in your browser settings, then raise the meter again.",
|
||||
"trueField": "magnetic field"
|
||||
},
|
||||
"entity": {
|
||||
"title": "DOSSIER OF THE PRESENT",
|
||||
|
||||
@@ -166,7 +166,8 @@
|
||||
"unsupportedTitle": "este recipiente no porta sentidos de campo",
|
||||
"unsupported": "un teléfono en tu mano se vuelve el medidor — este recipiente no tiene sentidos de movimiento que prestar. abre el velo en un teléfono, o deja que los otros modos respondan.",
|
||||
"deniedTitle": "los sentidos de campo siguen cerrados",
|
||||
"denied": "Este recipiente rechazó sus sentidos de movimiento. Concede el acceso al movimiento en los ajustes de tu navegador, y luego alza el medidor de nuevo."
|
||||
"denied": "Este recipiente rechazó sus sentidos de movimiento. Concede el acceso al movimiento en los ajustes de tu navegador, y luego alza el medidor de nuevo.",
|
||||
"trueField": "campo magnético"
|
||||
},
|
||||
"entity": {
|
||||
"title": "DOSIER DEL PRESENTE",
|
||||
|
||||
@@ -756,28 +756,18 @@
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.evp-scope,
|
||||
.radio-waterfall,
|
||||
/* .evp-scope and .radio-waterfall lived here until both panels moved to the
|
||||
shared SpectrumScope, which owns its own frame (SpectrumScope.css). Only
|
||||
the EMF gauge still draws its own canvas. */
|
||||
.emf-gauge {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 4px;
|
||||
background: rgba(7, 7, 13, 0.75);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.evp-scope {
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
.radio-waterfall {
|
||||
height: 110px;
|
||||
}
|
||||
|
||||
.emf-gauge {
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.evp-readout,
|
||||
.radio-readout,
|
||||
.emf-readout {
|
||||
|
||||
@@ -34,6 +34,10 @@ import type { ScopeAnomaly } from '../components/SpectrumScope'
|
||||
import { ListenToggle } from '../components/ListenToggle'
|
||||
import { SpectrumSonifier } from '../lib/spectrumSonify'
|
||||
import { EmfSensorListener } from '../lib/emf'
|
||||
import {
|
||||
MagnetometerListener,
|
||||
isSupported as magIsSupported,
|
||||
} from '../lib/magnetometer'
|
||||
import {
|
||||
isSecureContext,
|
||||
isSupported,
|
||||
@@ -915,9 +919,26 @@ function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
|
||||
const EMF_GAUGE_MAX = 6
|
||||
|
||||
function EmfPanel() {
|
||||
const { sendAnomaly } = useSeance()
|
||||
const { sendAnomaly, entropy } = useSeance()
|
||||
const { t } = useTranslation()
|
||||
const supported = useMemo(() => EmfSensorListener.isSupported(), [])
|
||||
// A true magnetometer measures the magnetic field itself; the motion
|
||||
// listener only infers disturbance from movement, so a phone sitting
|
||||
// still beside a running motor reads nothing on it. Where the hardware
|
||||
// exists (Chromium/Android) we run both — this is additive, and the
|
||||
// motion path stays the only option on iOS.
|
||||
const magSupported = useMemo(() => magIsSupported(), [])
|
||||
const [magField, setMagField] = useState<number | null>(null)
|
||||
const magRef = useRef<MagnetometerListener | null>(null)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
const m = magRef.current
|
||||
magRef.current = null
|
||||
if (m) m.stop()
|
||||
},
|
||||
[],
|
||||
)
|
||||
const [listening, setListening] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [denied, setDenied] = useState(false)
|
||||
@@ -936,6 +957,34 @@ function EmfPanel() {
|
||||
[],
|
||||
)
|
||||
|
||||
const startMagnetometer = () => {
|
||||
if (!magSupported || magRef.current) return
|
||||
const mag = new MagnetometerListener()
|
||||
magRef.current = mag
|
||||
void mag
|
||||
.start({
|
||||
onReading: (r) => {
|
||||
setMagField(r.magnitude)
|
||||
// Real magnetic-field jitter is genuine physical noise — bank it
|
||||
// toward the next summon like every other live sensor.
|
||||
entropy.addFrame([r.x, r.y, r.z])
|
||||
},
|
||||
onAnomaly: (a) => sendAnomaly('emf', a.magnitude, Math.abs(a.deviation)),
|
||||
})
|
||||
.catch(() => {
|
||||
// No magnetometer permission or no hardware: the motion listener
|
||||
// still carries the mode, so this stays silent.
|
||||
magRef.current = null
|
||||
})
|
||||
}
|
||||
|
||||
const stopMagnetometer = () => {
|
||||
const m = magRef.current
|
||||
magRef.current = null
|
||||
if (m) m.stop()
|
||||
setMagField(null)
|
||||
}
|
||||
|
||||
const drawGauge = (strength: number) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
@@ -1024,6 +1073,7 @@ function EmfPanel() {
|
||||
},
|
||||
})
|
||||
setListening(true)
|
||||
startMagnetometer()
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
@@ -1033,6 +1083,7 @@ function EmfPanel() {
|
||||
const l = listenerRef.current
|
||||
if (l) l.stop()
|
||||
setListening(false)
|
||||
stopMagnetometer()
|
||||
setField(null)
|
||||
setBaseline(null)
|
||||
drawGauge(0)
|
||||
@@ -1064,6 +1115,14 @@ function EmfPanel() {
|
||||
return (
|
||||
<div className="emf-panel">
|
||||
<canvas ref={canvasRef} width={260} height={140} className="emf-gauge" aria-hidden />
|
||||
{magField !== null && (
|
||||
<div className="emf-readout">
|
||||
<span className="dim">
|
||||
{t('seance.emf.trueField', { defaultValue: 'magnetic field' })}
|
||||
</span>
|
||||
<span className="evp-level">{magField.toFixed(1)} µT</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="emf-readout">
|
||||
<span className="emf-level flicker">{field !== null ? field.toFixed(2) : '—'}</span>
|
||||
<span className="emf-baseline dim">
|
||||
|
||||
Reference in New Issue
Block a user