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>
621 lines
22 KiB
Python
621 lines
22 KiB
Python
"""Workstream B WS integration tests: ritual_start/ritual_step/judgment
|
|
handlers in app.ws, plus the favor/essence/at_peace side effects and the
|
|
favor read-back bias on trait rolls at mint time."""
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
import app.judgment as judgment_module
|
|
import app.ws
|
|
from app.entities import fallback_profile
|
|
from app.inventory import (
|
|
CORRECT_JUDGMENT_ESSENCE,
|
|
CROSS_OVER_ESSENCE,
|
|
RITUAL_SUCCESS_ESSENCE,
|
|
)
|
|
from app.models.entity import Entity
|
|
from app.models.user import User
|
|
from app.rate_limit import RateLimiter
|
|
|
|
|
|
class FakeSpiritService:
|
|
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"):
|
|
return "listen"
|
|
|
|
async def wire_whisper(self, telemetry, language="en"):
|
|
return "the wire hums"
|
|
|
|
def chat_stream(self, entity, question, history, language="en"):
|
|
async def gen():
|
|
for token in ["I ", "am ", "here."]:
|
|
yield token
|
|
|
|
return gen()
|
|
|
|
def ambient_ready(self):
|
|
return False
|
|
|
|
|
|
async def _fake_synth(text, voice, profile, instability=0.0):
|
|
return b"RIFFfake wav bytes"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fake_spirits(monkeypatch):
|
|
monkeypatch.setattr(app.ws, "spirit_service", FakeSpiritService())
|
|
monkeypatch.setattr(app.ws, "synthesize_spirit_voice", _fake_synth)
|
|
# Generous, test-scoped limiters — the module-level ones are shared
|
|
# singletons that accumulate real hit counts across the whole test
|
|
# session (see backend/tests/test_ws_session.py's precedent), and this
|
|
# file summons repeatedly.
|
|
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=1000, window_seconds=60))
|
|
monkeypatch.setattr(app.ws, "summon_ip_limiter", RateLimiter(max_requests=1000, window_seconds=60))
|
|
monkeypatch.setattr(app.ws, "question_limiter", RateLimiter(max_requests=1000, window_seconds=60))
|
|
monkeypatch.setattr(app.ws, "question_ip_limiter", RateLimiter(max_requests=1000, window_seconds=60))
|
|
monkeypatch.setattr(app.ws, "fragment_limiter", RateLimiter(max_requests=1000, window_seconds=60))
|
|
monkeypatch.setattr(app.ws, "fragment_ip_limiter", RateLimiter(max_requests=1000, window_seconds=60))
|
|
monkeypatch.setattr(app.ws, "ritual_limiter", RateLimiter(max_requests=1000, window_seconds=60))
|
|
monkeypatch.setattr(app.ws, "ritual_ip_limiter", RateLimiter(max_requests=1000, window_seconds=60))
|
|
monkeypatch.setattr(app.ws, "judgment_limiter", RateLimiter(max_requests=1000, window_seconds=60))
|
|
monkeypatch.setattr(app.ws, "judgment_ip_limiter", RateLimiter(max_requests=1000, window_seconds=60))
|
|
|
|
|
|
def _read_until(ws, msg_type, max_frames=60, **match):
|
|
for _ in range(max_frames):
|
|
frame = ws.receive_json()
|
|
if frame.get("type") != msg_type:
|
|
continue
|
|
if all(frame.get(key) == value for key, value in match.items()):
|
|
return frame
|
|
raise AssertionError(f"never saw frame of type {msg_type!r} matching {match!r}")
|
|
|
|
|
|
def _login(sync_client, username):
|
|
sync_client.post("/auth/register", json={"username": username, "password": "spookyspooky"})
|
|
sync_client.post("/auth/login", json={"username": username, "password": "spookyspooky"})
|
|
return sync_client.cookies.get("qm_session")
|
|
|
|
|
|
def _ws_connect(sync_client, token):
|
|
return sync_client.websocket_connect(
|
|
"/ws/session", headers={"cookie": f"qm_session={token}"}
|
|
)
|
|
|
|
|
|
def _summon(ws):
|
|
ws.send_json({"type": "summon"})
|
|
entity_frame = _read_until(ws, "entity")
|
|
_read_until(ws, "utterance", kind="greeting")
|
|
return entity_frame
|
|
|
|
|
|
def _run_ritual(ws, steps=4):
|
|
ws.send_json({"type": "ritual_start"})
|
|
for i in range(1, steps + 1):
|
|
ws.send_json({"type": "ritual_step", "step": i})
|
|
result = _read_until(ws, "ritual_complete")
|
|
# `_handle_ritual_step`'s reward call (essence credit / item roll) runs
|
|
# *after* the `ritual_complete` frame is queued for send, so receiving
|
|
# that frame doesn't by itself guarantee the reward has landed yet (the
|
|
# sender task drains the queue concurrently with the handler's own
|
|
# in-flight awaits). A trailing ping/pong forces a full round trip
|
|
# through the single connection's sequential message loop, which can't
|
|
# read the next message until the ritual_step handler (reward included)
|
|
# has fully returned — so seeing the pong is a hard guarantee, not a
|
|
# poll-and-hope.
|
|
ws.send_json({"type": "ping"})
|
|
_read_until(ws, "pong")
|
|
return result
|
|
|
|
|
|
# --- entity frame never leaks traits ---------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_entity_frame_never_includes_traits(sync_client):
|
|
_login(sync_client, "no-leak")
|
|
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
|
|
_read_until(ws, "session")
|
|
entity_frame = _summon(ws)
|
|
assert "traits" not in entity_frame["entity"]
|
|
|
|
|
|
# --- ritual ------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ritual_success_reveals_true_traits_and_credits_essence(
|
|
sync_client, db_session, monkeypatch
|
|
):
|
|
monkeypatch.setattr(app.ws.judgment, "roll_ritual_success", lambda traits, rng=None: True)
|
|
|
|
token = _login(sync_client, "ritual-winner")
|
|
user_id = uuid.UUID(
|
|
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
|
|
)
|
|
|
|
with _ws_connect(sync_client, token) as ws:
|
|
_read_until(ws, "session")
|
|
_summon(ws)
|
|
result = _run_ritual(ws)
|
|
|
|
assert result["success"] is True
|
|
assert result["revealed"] is not None
|
|
assert set(result["revealed"].keys()) == {
|
|
"alignment",
|
|
"power",
|
|
"volatility",
|
|
"deceptiveness",
|
|
}
|
|
for v in result["revealed"].values():
|
|
assert 0.0 <= v <= 1.0
|
|
|
|
# trickle (summon) + ritual success milestone. `_run_ritual`'s trailing
|
|
# ping/pong (see its docstring comment) guarantees the reward has fully
|
|
# landed before we get here.
|
|
from app.inventory import SUMMON_ESSENCE_TRICKLE
|
|
|
|
db_session.expire_all()
|
|
user = await db_session.get(User, user_id)
|
|
assert user.essence == SUMMON_ESSENCE_TRICKLE + RITUAL_SUCCESS_ESSENCE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ritual_failure_reveals_nothing_and_grants_no_essence(
|
|
sync_client, db_session, monkeypatch
|
|
):
|
|
monkeypatch.setattr(app.ws.judgment, "roll_ritual_success", lambda traits, rng=None: False)
|
|
|
|
token = _login(sync_client, "ritual-loser")
|
|
user_id = uuid.UUID(
|
|
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
|
|
)
|
|
|
|
with _ws_connect(sync_client, token) as ws:
|
|
_read_until(ws, "session")
|
|
_summon(ws)
|
|
result = _run_ritual(ws)
|
|
|
|
assert result["success"] is False
|
|
assert result["revealed"] is None
|
|
|
|
db_session.expire_all()
|
|
user = await db_session.get(User, user_id)
|
|
from app.inventory import SUMMON_ESSENCE_TRICKLE
|
|
|
|
assert user.essence == SUMMON_ESSENCE_TRICKLE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ritual_complete_only_fires_after_all_steps(sync_client, monkeypatch):
|
|
monkeypatch.setattr(app.ws.judgment, "roll_ritual_success", lambda traits, rng=None: True)
|
|
_login(sync_client, "ritual-partial")
|
|
|
|
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
|
|
_read_until(ws, "session")
|
|
_summon(ws)
|
|
ws.send_json({"type": "ritual_start"})
|
|
ws.send_json({"type": "ritual_step", "step": 1})
|
|
ws.send_json({"type": "ritual_step", "step": 2})
|
|
ws.send_json({"type": "ping"})
|
|
pong = _read_until(ws, "pong")
|
|
assert pong == {"type": "pong"}
|
|
# No ritual_complete should have arrived yet (only 2/4 steps done) —
|
|
# if it had, it'd have been consumed as the "pong" read above
|
|
# skipped it via _read_until's scan, so assert explicitly by
|
|
# finishing the remaining steps and confirming exactly one
|
|
# ritual_complete follows.
|
|
ws.send_json({"type": "ritual_step", "step": 3})
|
|
ws.send_json({"type": "ritual_step", "step": 4})
|
|
result = _read_until(ws, "ritual_complete")
|
|
assert result["success"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fresh_summon_resets_ritual_progress(sync_client, monkeypatch):
|
|
monkeypatch.setattr(app.ws.judgment, "roll_ritual_success", lambda traits, rng=None: True)
|
|
_login(sync_client, "ritual-reset")
|
|
|
|
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
|
|
_read_until(ws, "session")
|
|
_summon(ws)
|
|
ws.send_json({"type": "ritual_start"})
|
|
ws.send_json({"type": "ritual_step", "step": 1})
|
|
ws.send_json({"type": "ritual_step", "step": 2})
|
|
# A brand-new summon should discard the in-progress ritual — the
|
|
# next 4 steps on the new entity shouldn't complete after only 2
|
|
# more (i.e. carry over the old count).
|
|
_summon(ws)
|
|
ws.send_json({"type": "ritual_start"})
|
|
ws.send_json({"type": "ritual_step", "step": 1})
|
|
ws.send_json({"type": "ritual_step", "step": 2})
|
|
ws.send_json({"type": "ping"})
|
|
pong = _read_until(ws, "pong")
|
|
assert pong == {"type": "pong"}
|
|
|
|
|
|
# --- judgment: trust / banish -----------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_judgment_trust_correct_on_benevolent_entity(sync_client, db_session, monkeypatch):
|
|
monkeypatch.setattr(
|
|
app.ws.judgment,
|
|
"judge_verdict",
|
|
lambda verdict, traits, **kw: judgment_module.JudgmentOutcome(
|
|
True, 0.05, CORRECT_JUDGMENT_ESSENCE, False, "reward"
|
|
),
|
|
)
|
|
token = _login(sync_client, "truster")
|
|
user_id = uuid.UUID(
|
|
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
|
|
)
|
|
|
|
with _ws_connect(sync_client, token) as ws:
|
|
_read_until(ws, "session")
|
|
_summon(ws)
|
|
ws.send_json({"type": "judgment", "verdict": "trust"})
|
|
result = _read_until(ws, "judgment_result")
|
|
|
|
assert result == {
|
|
"type": "judgment_result",
|
|
"correct": True,
|
|
"favor_delta": 0.05,
|
|
"essence_delta": CORRECT_JUDGMENT_ESSENCE,
|
|
"at_peace": False,
|
|
"consequence": "reward",
|
|
}
|
|
|
|
db_session.expire_all()
|
|
user = await db_session.get(User, user_id)
|
|
assert user.favor == pytest.approx(0.05)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_judgment_wrong_trust_emits_escalation_consequence(sync_client, db_session, monkeypatch):
|
|
monkeypatch.setattr(
|
|
app.ws.judgment,
|
|
"judge_verdict",
|
|
lambda verdict, traits, **kw: judgment_module.JudgmentOutcome(
|
|
False, -0.10, 0, False, "escalation"
|
|
),
|
|
)
|
|
token = _login(sync_client, "wrong-truster")
|
|
user_id = uuid.UUID(
|
|
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
|
|
)
|
|
|
|
with _ws_connect(sync_client, token) as ws:
|
|
_read_until(ws, "session")
|
|
_summon(ws)
|
|
ws.send_json({"type": "judgment", "verdict": "trust"})
|
|
result = _read_until(ws, "judgment_result")
|
|
|
|
assert result["consequence"] == "escalation"
|
|
assert result["correct"] is False
|
|
assert result["favor_delta"] == -0.10
|
|
|
|
db_session.expire_all()
|
|
user = await db_session.get(User, user_id)
|
|
assert user.favor == pytest.approx(-0.10)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_judgment_favor_clamped_at_negative_one(sync_client, db_session, monkeypatch):
|
|
monkeypatch.setattr(
|
|
app.ws.judgment,
|
|
"judge_verdict",
|
|
lambda verdict, traits, **kw: judgment_module.JudgmentOutcome(
|
|
False, -0.10, 0, False, "escalation"
|
|
),
|
|
)
|
|
token = _login(sync_client, "favor-floor")
|
|
user_id = uuid.UUID(
|
|
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
|
|
)
|
|
|
|
async with app.ws.session_maker() as db:
|
|
user = await db.get(User, user_id)
|
|
user.favor = -0.95
|
|
await db.commit()
|
|
|
|
with _ws_connect(sync_client, token) as ws:
|
|
_read_until(ws, "session")
|
|
_summon(ws)
|
|
ws.send_json({"type": "judgment", "verdict": "trust"})
|
|
result = _read_until(ws, "judgment_result")
|
|
|
|
assert result["favor_delta"] == -0.10 # the raw per-event delta, unclamped
|
|
db_session.expire_all()
|
|
user = await db_session.get(User, user_id)
|
|
assert user.favor == pytest.approx(-1.0) # but the stored balance is clamped
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_judgment_favor_clamped_at_positive_one(sync_client, db_session, monkeypatch):
|
|
monkeypatch.setattr(
|
|
app.ws.judgment,
|
|
"judge_verdict",
|
|
lambda verdict, traits, **kw: judgment_module.JudgmentOutcome(
|
|
True, 0.08, CROSS_OVER_ESSENCE, True, "crossed_over"
|
|
),
|
|
)
|
|
token = _login(sync_client, "favor-ceiling")
|
|
user_id = uuid.UUID(
|
|
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
|
|
)
|
|
|
|
async with app.ws.session_maker() as db:
|
|
user = await db.get(User, user_id)
|
|
user.favor = 0.97
|
|
await db.commit()
|
|
|
|
with _ws_connect(sync_client, token) as ws:
|
|
_read_until(ws, "session")
|
|
_summon(ws)
|
|
ws.send_json({"type": "judgment", "verdict": "cross_over"})
|
|
_read_until(ws, "judgment_result")
|
|
|
|
db_session.expire_all()
|
|
user = await db_session.get(User, user_id)
|
|
assert user.favor == pytest.approx(1.0)
|
|
|
|
|
|
# --- judgment: cross_over / at_peace ----------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_judgment_cross_over_correct_sets_at_peace_and_pays_most_essence(
|
|
sync_client, db_session, monkeypatch
|
|
):
|
|
monkeypatch.setattr(
|
|
app.ws.judgment,
|
|
"judge_verdict",
|
|
lambda verdict, traits, **kw: judgment_module.JudgmentOutcome(
|
|
True, 0.08, CROSS_OVER_ESSENCE, True, "crossed_over"
|
|
),
|
|
)
|
|
token = _login(sync_client, "crosser")
|
|
user_id = uuid.UUID(
|
|
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
|
|
)
|
|
|
|
with _ws_connect(sync_client, token) as ws:
|
|
_read_until(ws, "session")
|
|
entity_frame = _summon(ws)
|
|
entity_id = uuid.UUID(entity_frame["entity"]["id"])
|
|
ws.send_json({"type": "judgment", "verdict": "cross_over"})
|
|
result = _read_until(ws, "judgment_result")
|
|
|
|
assert result["consequence"] == "crossed_over"
|
|
assert result["at_peace"] is True
|
|
assert result["essence_delta"] == CROSS_OVER_ESSENCE
|
|
|
|
db_session.expire_all()
|
|
entity = await db_session.get(Entity, entity_id)
|
|
assert entity.at_peace is True
|
|
|
|
from app.inventory import SUMMON_ESSENCE_TRICKLE
|
|
|
|
user = await db_session.get(User, user_id)
|
|
assert user.essence == SUMMON_ESSENCE_TRICKLE + CROSS_OVER_ESSENCE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_judgment_cross_over_resisted_on_demon_has_no_effect(sync_client, db_session, monkeypatch):
|
|
monkeypatch.setattr(
|
|
app.ws.judgment,
|
|
"judge_verdict",
|
|
lambda verdict, traits, **kw: judgment_module.JudgmentOutcome(
|
|
False, 0.0, 0, False, "resisted"
|
|
),
|
|
)
|
|
token = _login(sync_client, "resisted-demon")
|
|
user_id = uuid.UUID(
|
|
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
|
|
)
|
|
|
|
with _ws_connect(sync_client, token) as ws:
|
|
_read_until(ws, "session")
|
|
entity_frame = _summon(ws)
|
|
entity_id = uuid.UUID(entity_frame["entity"]["id"])
|
|
ws.send_json({"type": "judgment", "verdict": "cross_over"})
|
|
result = _read_until(ws, "judgment_result")
|
|
|
|
assert result == {
|
|
"type": "judgment_result",
|
|
"correct": False,
|
|
"favor_delta": 0.0,
|
|
"essence_delta": 0,
|
|
"at_peace": False,
|
|
"consequence": "resisted",
|
|
}
|
|
|
|
db_session.expire_all()
|
|
entity = await db_session.get(Entity, entity_id)
|
|
assert entity.at_peace is False
|
|
from app.inventory import SUMMON_ESSENCE_TRICKLE
|
|
|
|
user = await db_session.get(User, user_id)
|
|
assert user.essence == SUMMON_ESSENCE_TRICKLE
|
|
assert user.favor == 0.0
|
|
|
|
|
|
# --- judgment: test ----------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_judgment_test_verdict_is_always_allowed_and_neutral(sync_client, db_session):
|
|
token = _login(sync_client, "tester")
|
|
user_id = uuid.UUID(
|
|
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
|
|
)
|
|
|
|
with _ws_connect(sync_client, token) as ws:
|
|
_read_until(ws, "session")
|
|
_summon(ws)
|
|
# No ritual attempted at all — should not crash, just no effect.
|
|
ws.send_json({"type": "judgment", "verdict": "test"})
|
|
result = _read_until(ws, "judgment_result")
|
|
|
|
assert result == {
|
|
"type": "judgment_result",
|
|
"correct": False,
|
|
"favor_delta": 0,
|
|
"essence_delta": 0,
|
|
"at_peace": False,
|
|
"consequence": "neutral",
|
|
}
|
|
|
|
db_session.expire_all()
|
|
user = await db_session.get(User, user_id)
|
|
assert user.favor == 0.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_judgment_test_verdict_correct_after_successful_ritual(sync_client, monkeypatch):
|
|
monkeypatch.setattr(app.ws.judgment, "roll_ritual_success", lambda traits, rng=None: True)
|
|
_login(sync_client, "tester-after-ritual")
|
|
|
|
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
|
|
_read_until(ws, "session")
|
|
_summon(ws)
|
|
_run_ritual(ws)
|
|
ws.send_json({"type": "judgment", "verdict": "test"})
|
|
result = _read_until(ws, "judgment_result")
|
|
|
|
assert result["correct"] is True
|
|
assert result["consequence"] == "neutral"
|
|
assert result["favor_delta"] == 0
|
|
assert result["essence_delta"] == 0
|
|
|
|
|
|
# --- at_peace + re-contact ----------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_at_peace_entity_is_not_recontacted_a_fresh_one_mints_instead(
|
|
sync_client, db_session
|
|
):
|
|
anomalies = [
|
|
{"type": "anomaly", "source": "radio", "frequency": 101.0 + i, "magnitude": 5.0 + i}
|
|
for i in range(4)
|
|
]
|
|
|
|
token = _login(sync_client, "peace-seeker")
|
|
with _ws_connect(sync_client, token) as ws:
|
|
_read_until(ws, "session")
|
|
for anomaly in anomalies:
|
|
ws.send_json(anomaly)
|
|
first_frame = _read_until(ws, "entity")
|
|
first_id = uuid.UUID(first_frame["entity"]["id"])
|
|
first_signature = None # not exposed to the client; fetched below
|
|
|
|
# Manually mark the entity at_peace, as a completed cross_over would.
|
|
async with app.ws.session_maker() as db:
|
|
entity = await db.get(Entity, first_id)
|
|
entity.at_peace = True
|
|
first_signature = entity.signature
|
|
await db.commit()
|
|
|
|
with _ws_connect(sync_client, token) as ws:
|
|
_read_until(ws, "session")
|
|
for anomaly in anomalies:
|
|
ws.send_json(anomaly)
|
|
second_frame = _read_until(ws, "entity")
|
|
|
|
assert second_frame["is_new"] is True
|
|
second_id = uuid.UUID(second_frame["entity"]["id"])
|
|
assert second_id != first_id
|
|
|
|
db_session.expire_all()
|
|
second_entity = await db_session.get(Entity, second_id)
|
|
assert second_entity.at_peace is False
|
|
# Salted variant of the same base signature, not a raw collision.
|
|
assert second_entity.signature != first_signature
|
|
assert second_entity.signature.startswith(first_signature + ":")
|
|
|
|
first_entity = await db_session.get(Entity, first_id)
|
|
assert first_entity is not None # memorialized, never deleted
|
|
assert first_entity.at_peace is True
|
|
|
|
|
|
# --- favor read-back bias on trait rolls at mint time -----------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_high_favor_user_mints_less_volatile_deceptive_entities(sync_client, db_session):
|
|
token = _login(sync_client, "high-favor-summoner")
|
|
user_id = uuid.UUID(
|
|
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
|
|
)
|
|
async with app.ws.session_maker() as db:
|
|
user = await db.get(User, user_id)
|
|
user.favor = 1.0
|
|
await db.commit()
|
|
|
|
anomalies = [
|
|
{"type": "anomaly", "source": "radio", "frequency": 201.0 + i, "magnitude": 5.0 + i}
|
|
for i in range(4)
|
|
]
|
|
with _ws_connect(sync_client, token) as ws:
|
|
_read_until(ws, "session")
|
|
for anomaly in anomalies:
|
|
ws.send_json(anomaly)
|
|
entity_frame = _read_until(ws, "entity")
|
|
|
|
entity_id = uuid.UUID(entity_frame["entity"]["id"])
|
|
db_session.expire_all()
|
|
entity = await db_session.get(Entity, entity_id)
|
|
|
|
# Recompute what the unbiased roll would have been for this signature
|
|
# and confirm the stored traits were nudged toward more legible
|
|
# (lower volatility/deceptiveness), never the other direction.
|
|
from app.entities import roll_traits
|
|
|
|
unbiased = roll_traits(entity.signature)
|
|
assert entity.traits["volatility"] <= unbiased["volatility"]
|
|
assert entity.traits["deceptiveness"] <= unbiased["deceptiveness"]
|
|
# alignment/power are untouched by the bias.
|
|
assert entity.traits["alignment"] == pytest.approx(unbiased["alignment"])
|
|
assert entity.traits["power"] == pytest.approx(unbiased["power"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_low_favor_user_mints_more_volatile_deceptive_entities(sync_client, db_session):
|
|
token = _login(sync_client, "low-favor-summoner")
|
|
user_id = uuid.UUID(
|
|
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
|
|
)
|
|
async with app.ws.session_maker() as db:
|
|
user = await db.get(User, user_id)
|
|
user.favor = -1.0
|
|
await db.commit()
|
|
|
|
anomalies = [
|
|
{"type": "anomaly", "source": "radio", "frequency": 301.0 + i, "magnitude": 5.0 + i}
|
|
for i in range(4)
|
|
]
|
|
with _ws_connect(sync_client, token) as ws:
|
|
_read_until(ws, "session")
|
|
for anomaly in anomalies:
|
|
ws.send_json(anomaly)
|
|
entity_frame = _read_until(ws, "entity")
|
|
|
|
entity_id = uuid.UUID(entity_frame["entity"]["id"])
|
|
db_session.expire_all()
|
|
entity = await db_session.get(Entity, entity_id)
|
|
|
|
from app.entities import roll_traits
|
|
|
|
unbiased = roll_traits(entity.signature)
|
|
assert entity.traits["volatility"] >= unbiased["volatility"]
|
|
assert entity.traits["deceptiveness"] >= unbiased["deceptiveness"]
|