Merge Workstream B: ritual + judgment + favor + cross-over
This commit is contained in:
315
backend/app/judgment.py
Normal file
315
backend/app/judgment.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
"""Ritual + judgment: pure logic for Workstream B of the Character Depth /
|
||||||
|
Ghost Log spec
|
||||||
|
(docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md).
|
||||||
|
|
||||||
|
No I/O, no DB, no network — every function here takes plain values (and an
|
||||||
|
optional injectable `random.Random`) and returns plain values, so the whole
|
||||||
|
module is trivially unit-testable in isolation. `backend/app/ws.py`'s
|
||||||
|
`ritual_start`/`ritual_step`/`judgment` WS handlers are the only place these
|
||||||
|
outputs get persisted or sent over the wire.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.inventory import CORRECT_JUDGMENT_ESSENCE, CROSS_OVER_ESSENCE
|
||||||
|
|
||||||
|
# --- favor -------------------------------------------------------------
|
||||||
|
|
||||||
|
FAVOR_MIN = -1.0
|
||||||
|
FAVOR_MAX = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_favor(value: float) -> float:
|
||||||
|
"""Clamps a `User.favor` value to [-1.0, 1.0] — call this at every write
|
||||||
|
site, per the spec's Contract section."""
|
||||||
|
return max(FAVOR_MIN, min(FAVOR_MAX, value))
|
||||||
|
|
||||||
|
|
||||||
|
# --- ritual --------------------------------------------------------------
|
||||||
|
|
||||||
|
# Rituals should feel doable most of the time — this is a short rite in
|
||||||
|
# front of judgment, not a grindy gate. A highly resistant entity (high
|
||||||
|
# `power` *and* high `deceptiveness` — strong and cagey) drags the odds
|
||||||
|
# down, floored so even the worst-case entity stays beatable on a retry
|
||||||
|
# rather than a hard wall.
|
||||||
|
RITUAL_BASE_SUCCESS_CHANCE = 0.65
|
||||||
|
RITUAL_MIN_SUCCESS_CHANCE = 0.30
|
||||||
|
RITUAL_DIFFICULTY_SWING = RITUAL_BASE_SUCCESS_CHANCE - RITUAL_MIN_SUCCESS_CHANCE
|
||||||
|
|
||||||
|
|
||||||
|
def roll_ritual_success(traits: dict, rng: random.Random | None = None) -> bool:
|
||||||
|
"""One ritual attempt's pass/fail roll.
|
||||||
|
|
||||||
|
`traits` is the entity's hidden truth dict (`alignment`/`power`/
|
||||||
|
`volatility`/`deceptiveness`). Only `power` and `deceptiveness` affect
|
||||||
|
the odds — a strong, cagey spirit is the hardest to get an accurate
|
||||||
|
read on. `volatility` is deliberately left out: it's about how *noisy*
|
||||||
|
the entity's ambient tells are, a separate axis from whether a focused
|
||||||
|
ritual can pin it down. `alignment` is also left out — letting it
|
||||||
|
influence pass/fail would telegraph ground truth (easier ritual =
|
||||||
|
probably benevolent) before `ritual_complete` is supposed to reveal
|
||||||
|
anything, undermining the "judge blind or judge informed" choice the
|
||||||
|
contract is built around.
|
||||||
|
|
||||||
|
`rng` defaults to a fresh, unseeded `random.Random()` — deliberately
|
||||||
|
*not* signature-seeded like `entities.roll_traits`: a seeker can retry
|
||||||
|
the same entity's ritual repeatedly (`RitualPanel`'s "attempt again"),
|
||||||
|
and each attempt needs genuine variance, not a fixed pass/fail baked
|
||||||
|
into the entity's identity.
|
||||||
|
"""
|
||||||
|
rng = rng if rng is not None else random.Random()
|
||||||
|
power = float(traits.get("power", 0.5))
|
||||||
|
deceptiveness = float(traits.get("deceptiveness", 0.5))
|
||||||
|
difficulty = (power + deceptiveness) / 2.0 # 0..1
|
||||||
|
chance = RITUAL_BASE_SUCCESS_CHANCE - RITUAL_DIFFICULTY_SWING * difficulty
|
||||||
|
chance = max(RITUAL_MIN_SUCCESS_CHANCE, min(RITUAL_BASE_SUCCESS_CHANCE, chance))
|
||||||
|
return rng.random() < chance
|
||||||
|
|
||||||
|
|
||||||
|
# --- "stuck spirit" rule for cross_over -----------------------------------
|
||||||
|
|
||||||
|
# "Genuinely benevolent" mirrors the correct-trust threshold used below.
|
||||||
|
# "Reads as stuck" is modeled as above-average volatility: the existing
|
||||||
|
# fallback personas already lean on "died with something unfinished" themes
|
||||||
|
# (see entities.py's _PERSONA_TEMPLATES), and an unresolved spirit is
|
||||||
|
# exactly the kind whose behavioral tells would plausibly be noisy/
|
||||||
|
# inconsistent rather than settled, rather than tying "stuck" to a whole
|
||||||
|
# separate hidden dimension. 0.6 (vs. the 0.5 midpoint) keeps "stuck" a
|
||||||
|
# meaningfully above-average band rather than a coinflip: with both
|
||||||
|
# `alignment` and `volatility` rolled uniform(0, 1) independently,
|
||||||
|
# P(alignment >= 0.5) * P(volatility > 0.6) = 0.5 * 0.4 = 20% of all
|
||||||
|
# entities qualify — special enough to justify the single largest reward
|
||||||
|
# in the game (see CROSS_OVER_ESSENCE / FAVOR_CORRECT_CROSS_OVER) without
|
||||||
|
# being so rare it never comes up.
|
||||||
|
BENEVOLENT_ALIGNMENT_THRESHOLD = 0.5
|
||||||
|
STUCK_VOLATILITY_THRESHOLD = 0.6
|
||||||
|
|
||||||
|
|
||||||
|
def is_stuck_spirit(traits: dict) -> bool:
|
||||||
|
alignment = float(traits.get("alignment", 0.5))
|
||||||
|
volatility = float(traits.get("volatility", 0.5))
|
||||||
|
return (
|
||||||
|
alignment >= BENEVOLENT_ALIGNMENT_THRESHOLD
|
||||||
|
and volatility > STUCK_VOLATILITY_THRESHOLD
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- judgment --------------------------------------------------------------
|
||||||
|
|
||||||
|
# Favor deltas: small, single-digit-percent nudges of the [-1, 1] range (a
|
||||||
|
# 2.0-wide band) — the caller always re-clamps via `clamp_favor`.
|
||||||
|
#
|
||||||
|
# Wrong-trust is punished harder than wrong-banish: trusting something that
|
||||||
|
# turns out malevolent is the reckless failure mode with real in-fiction
|
||||||
|
# consequences (the haunting escalates), while a wrong banish is merely
|
||||||
|
# over-cautious — you turned away something harmless, nothing lashes back.
|
||||||
|
# Correct cross_over pays the best favor *and* essence of any outcome,
|
||||||
|
# matching the contract's "largest essence reward of any outcome" language
|
||||||
|
# with an equivalently generous favor nudge: it's the hardest-to-spot,
|
||||||
|
# most compassionate correct call a seeker can make.
|
||||||
|
FAVOR_CORRECT_TRUST = 0.05
|
||||||
|
FAVOR_CORRECT_BANISH = 0.05
|
||||||
|
FAVOR_WRONG_TRUST = -0.10
|
||||||
|
FAVOR_WRONG_BANISH = -0.05
|
||||||
|
FAVOR_CORRECT_CROSS_OVER = 0.08
|
||||||
|
FAVOR_CROSS_OVER_FAIL = 0.0 # a naive read, not a reckless one — no penalty
|
||||||
|
FAVOR_TEST = 0.0 # a diagnostic pulse only — nothing risked, nothing gained
|
||||||
|
|
||||||
|
VERDICTS = ("trust", "banish", "test", "cross_over")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class JudgmentOutcome:
|
||||||
|
correct: bool
|
||||||
|
favor_delta: float
|
||||||
|
essence_delta: int
|
||||||
|
at_peace: bool
|
||||||
|
consequence: str # "reward" | "escalation" | "withdrawal" | "crossed_over" | "resisted" | "neutral"
|
||||||
|
|
||||||
|
|
||||||
|
def judge_verdict(
|
||||||
|
verdict: str,
|
||||||
|
traits: dict,
|
||||||
|
*,
|
||||||
|
ritual_completed: bool = False,
|
||||||
|
ritual_success: bool = False,
|
||||||
|
) -> JudgmentOutcome:
|
||||||
|
"""Resolves one `judgment` frame against the entity's hidden truth.
|
||||||
|
|
||||||
|
`correct` per the contract: trust called on real alignment >= 0.5, or
|
||||||
|
banish called on alignment < 0.5, or cross_over called on a genuinely
|
||||||
|
"stuck" spirit (see `is_stuck_spirit`). `cross_over` on anything else
|
||||||
|
(a demon, or a benevolent-but-not-stuck spirit that simply isn't ready
|
||||||
|
to move on) resists — "resisted" covers both: neither is a reckless
|
||||||
|
misread the way a wrong trust/banish is, so neither costs favor.
|
||||||
|
`test` never touches favor/essence; its `correct` reflects whether a
|
||||||
|
completed-this-session ritual actually surfaced true information (no
|
||||||
|
completed ritual, or a failed one, means the diagnostic has nothing
|
||||||
|
real to go on).
|
||||||
|
"""
|
||||||
|
if verdict not in VERDICTS:
|
||||||
|
raise ValueError(f"unknown verdict: {verdict!r}")
|
||||||
|
|
||||||
|
alignment = float(traits.get("alignment", 0.5))
|
||||||
|
benevolent = alignment >= BENEVOLENT_ALIGNMENT_THRESHOLD
|
||||||
|
|
||||||
|
if verdict == "trust":
|
||||||
|
if benevolent:
|
||||||
|
return JudgmentOutcome(
|
||||||
|
True, FAVOR_CORRECT_TRUST, CORRECT_JUDGMENT_ESSENCE, False, "reward"
|
||||||
|
)
|
||||||
|
return JudgmentOutcome(False, FAVOR_WRONG_TRUST, 0, False, "escalation")
|
||||||
|
|
||||||
|
if verdict == "banish":
|
||||||
|
if not benevolent:
|
||||||
|
return JudgmentOutcome(
|
||||||
|
True, FAVOR_CORRECT_BANISH, CORRECT_JUDGMENT_ESSENCE, False, "reward"
|
||||||
|
)
|
||||||
|
return JudgmentOutcome(False, FAVOR_WRONG_BANISH, 0, False, "withdrawal")
|
||||||
|
|
||||||
|
if verdict == "cross_over":
|
||||||
|
if is_stuck_spirit(traits):
|
||||||
|
return JudgmentOutcome(
|
||||||
|
True, FAVOR_CORRECT_CROSS_OVER, CROSS_OVER_ESSENCE, True, "crossed_over"
|
||||||
|
)
|
||||||
|
return JudgmentOutcome(False, FAVOR_CROSS_OVER_FAIL, 0, False, "resisted")
|
||||||
|
|
||||||
|
# verdict == "test"
|
||||||
|
correct = bool(ritual_completed and ritual_success)
|
||||||
|
return JudgmentOutcome(correct, FAVOR_TEST, 0, False, "neutral")
|
||||||
|
|
||||||
|
|
||||||
|
# --- favor read-back bias on trait rolls ------------------------------------
|
||||||
|
|
||||||
|
# `entities.roll_traits` stays a pure, signature-seeded function with no
|
||||||
|
# session/user awareness (Workstream A's design — see the "roll_traits
|
||||||
|
# doesn't take a bias/rng parameter" check in ws.py's minting path). This is
|
||||||
|
# applied instead as a post-processing nudge, called from `app.ws._summon`
|
||||||
|
# right after a *new* entity's profile is minted, using the discovering
|
||||||
|
# user's current favor.
|
||||||
|
#
|
||||||
|
# It's applied once, at mint time, not per-viewing-session: the nudged
|
||||||
|
# traits become that entity's permanent, shared ground truth in the Codex
|
||||||
|
# (the same spirit reads the same way to every future seeker who contacts
|
||||||
|
# it). That matches the contract's "read back to mildly bias future summon
|
||||||
|
# trait rolls" framing — favor shapes what a seeker tends to *conjure*,
|
||||||
|
# not a private lens they view existing spirits through.
|
||||||
|
#
|
||||||
|
# Effect size is deliberately small and verifiable: at most a 0.12 shift at
|
||||||
|
# |favor| == 1.0, linear in between, applied only to volatility/
|
||||||
|
# deceptiveness (the two "how legible is this entity" axes) — alignment and
|
||||||
|
# power are left untouched so favor nudges readability, not who a spirit
|
||||||
|
# fundamentally is.
|
||||||
|
FAVOR_TRAIT_BIAS_MAX = 0.12
|
||||||
|
_BIASED_TRAIT_KEYS = ("volatility", "deceptiveness")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_favor_bias(traits: dict, favor: float) -> dict:
|
||||||
|
"""Higher favor nudges volatility/deceptiveness down (more legible
|
||||||
|
entities); lower favor nudges them up. Returns a new dict; clamps each
|
||||||
|
nudged value back into [0.0, 1.0]."""
|
||||||
|
favor = clamp_favor(favor)
|
||||||
|
shift = -FAVOR_TRAIT_BIAS_MAX * favor
|
||||||
|
biased = dict(traits)
|
||||||
|
for key in _BIASED_TRAIT_KEYS:
|
||||||
|
if key in biased:
|
||||||
|
biased[key] = max(0.0, min(1.0, float(biased[key]) + shift))
|
||||||
|
return biased
|
||||||
|
|
||||||
|
|
||||||
|
# --- tells -----------------------------------------------------------------
|
||||||
|
|
||||||
|
# Flavor text describing *behavior*, never a stat number (per the contract
|
||||||
|
# and frontend/src/lib/evilMeter.ts's comments on how tells are consumed) —
|
||||||
|
# each line hints at one trait dimension without naming it. Picking one
|
||||||
|
# trait per call (rather than always the most extreme) keeps tells varied
|
||||||
|
# session to session even for the same entity; the high/low/neutral banding
|
||||||
|
# means a middling trait still produces a plausible, non-committal line
|
||||||
|
# instead of always screaming its most extreme dimension.
|
||||||
|
_TELL_LINES: dict[str, dict[str, tuple[str, ...]]] = {
|
||||||
|
"alignment": {
|
||||||
|
"high": (
|
||||||
|
"a warmth threads through the static, unmistakably kind.",
|
||||||
|
"it seems to want nothing more than to be heard.",
|
||||||
|
"the presence feels gentle, almost grateful for the company.",
|
||||||
|
),
|
||||||
|
"low": (
|
||||||
|
"something cold coils under the words.",
|
||||||
|
"you feel watched, not accompanied.",
|
||||||
|
"the air around the signal turns unfriendly.",
|
||||||
|
),
|
||||||
|
"neutral": (
|
||||||
|
"hard to say if it means well.",
|
||||||
|
"the intent behind it stays unreadable.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"power": {
|
||||||
|
"high": (
|
||||||
|
"the signal surges, straining the line.",
|
||||||
|
"it pushes back against the questions, strong-willed.",
|
||||||
|
),
|
||||||
|
"low": (
|
||||||
|
"the presence feels thin, easily startled.",
|
||||||
|
"it flickers at the edge of hearing.",
|
||||||
|
),
|
||||||
|
"neutral": (
|
||||||
|
"steady, unremarkable strength.",
|
||||||
|
"neither weak nor overwhelming.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"volatility": {
|
||||||
|
"high": (
|
||||||
|
"the tone lurches without warning.",
|
||||||
|
"it contradicts itself within the same breath.",
|
||||||
|
"the signal keeps slipping out from under itself.",
|
||||||
|
),
|
||||||
|
"low": (
|
||||||
|
"calm, consistent, almost rehearsed.",
|
||||||
|
"every answer lands the same measured way.",
|
||||||
|
),
|
||||||
|
"neutral": (
|
||||||
|
"mostly steady, with the odd hitch.",
|
||||||
|
"a little uneven, nothing alarming.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"deceptiveness": {
|
||||||
|
"high": (
|
||||||
|
"the entity avoided a direct question.",
|
||||||
|
"an answer arrives that doesn't quite fit what was asked.",
|
||||||
|
"something about the reply feels rehearsed, not remembered.",
|
||||||
|
),
|
||||||
|
"low": (
|
||||||
|
"it answers plainly, almost bluntly.",
|
||||||
|
"nothing about the reply feels rehearsed.",
|
||||||
|
),
|
||||||
|
"neutral": (
|
||||||
|
"the reply seems straightforward enough.",
|
||||||
|
"no obvious dodge, no obvious tell.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_TELL_TRAIT_KEYS = tuple(_TELL_LINES.keys())
|
||||||
|
TELL_HIGH_THRESHOLD = 0.6
|
||||||
|
TELL_LOW_THRESHOLD = 0.4
|
||||||
|
|
||||||
|
|
||||||
|
def generate_tell(traits: dict, rng: random.Random) -> str:
|
||||||
|
"""Picks one trait dimension at random and returns a short, opaque
|
||||||
|
flavor line hinting at it (never the raw number). `rng` should be a
|
||||||
|
per-session `random.Random` so a given session's tell sequence is
|
||||||
|
varied but the choice of *which* call sites emit a tell stays the
|
||||||
|
caller's (ws.py's) responsibility."""
|
||||||
|
trait_key = rng.choice(_TELL_TRAIT_KEYS)
|
||||||
|
value = float(traits.get(trait_key, 0.5))
|
||||||
|
bank = _TELL_LINES[trait_key]
|
||||||
|
if value >= TELL_HIGH_THRESHOLD:
|
||||||
|
pool = bank["high"]
|
||||||
|
elif value <= TELL_LOW_THRESHOLD:
|
||||||
|
pool = bank["low"]
|
||||||
|
else:
|
||||||
|
pool = bank["neutral"]
|
||||||
|
return rng.choice(pool)
|
||||||
@@ -54,6 +54,13 @@ async def lifespan(app: FastAPI):
|
|||||||
await conn.execute(text(
|
await conn.execute(text(
|
||||||
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits JSONB NOT NULL DEFAULT '{}'::jsonb"
|
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits JSONB NOT NULL DEFAULT '{}'::jsonb"
|
||||||
))
|
))
|
||||||
|
# Workstream B (character-depth-ghost-log spec).
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS favor DOUBLE PRECISION NOT NULL DEFAULT 0.0"
|
||||||
|
))
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false"
|
||||||
|
))
|
||||||
cleanup_task = asyncio.create_task(_session_cleanup_loop())
|
cleanup_task = asyncio.create_task(_session_cleanup_loop())
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -27,6 +27,12 @@ class Entity(Base):
|
|||||||
traits: Mapped[dict] = mapped_column(JSONB, default=dict)
|
traits: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
sample_quotes: Mapped[list] = mapped_column(JSONB, default=list)
|
sample_quotes: Mapped[list] = mapped_column(JSONB, default=list)
|
||||||
contact_count: Mapped[int] = mapped_column(Integer, default=0)
|
contact_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
# Workstream B (character-depth-ghost-log spec): set true when a seeker
|
||||||
|
# correctly helps a genuinely benevolent, "stuck" spirit cross over. The
|
||||||
|
# row is never deleted (memorialized in the Codex permanently) but
|
||||||
|
# `signature_from_anomalies` re-contact in app.ws._summon skips it and
|
||||||
|
# mints a fresh entity instead.
|
||||||
|
at_peace: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
discovered_by: Mapped[uuid.UUID | None] = mapped_column(
|
discovered_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
ForeignKey("users.id"), nullable=True
|
ForeignKey("users.id"), nullable=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Integer, String
|
from sqlalchemy import DateTime, Float, Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
@@ -14,13 +14,12 @@ class User(Base):
|
|||||||
username: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
username: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||||
password_hash: Mapped[str] = mapped_column(String(255))
|
password_hash: Mapped[str] = mapped_column(String(255))
|
||||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
# NOTE: owned by Workstream B in the character-depth-ghost-log spec
|
|
||||||
# (docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md).
|
|
||||||
# Added here so Workstream C (unlocks/items/sigils/drops) can build and
|
|
||||||
# test against it in isolation; the merge controller reconciles this
|
|
||||||
# against Workstream B's own edit to this file (which will also add
|
|
||||||
# `favor: float` per the spec's Contract section).
|
|
||||||
essence: Mapped[int] = mapped_column(Integer, default=0)
|
essence: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
# Workstream B (character-depth-ghost-log spec): hidden per-user score,
|
||||||
|
# nudged by judgment correctness, clamped to [-1.0, 1.0] everywhere it's
|
||||||
|
# written (see app.judgment.clamp_favor). Read back as a small bias on
|
||||||
|
# new entities' trait rolls at mint time (app.judgment.apply_favor_bias).
|
||||||
|
favor: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ Protocol (client → server):
|
|||||||
{"type": "anomaly", "source": SRC, ...} → {"type": "utterance", ...}
|
{"type": "anomaly", "source": SRC, ...} → {"type": "utterance", ...}
|
||||||
{"type": "question", "text": "..."} → reply_start / reply_token* / reply_end
|
{"type": "question", "text": "..."} → reply_start / reply_token* / reply_end
|
||||||
{"type": "passive", "enabled": bool} → ambient wire loop on/off
|
{"type": "passive", "enabled": bool} → ambient wire loop on/off
|
||||||
|
{"type": "ritual_start"} → (begins a ritual attempt)
|
||||||
|
{"type": "ritual_step", "step": <int>} → (on the final step) ritual_complete
|
||||||
|
{"type": "judgment", "verdict": "trust" | "banish" | "test" | "cross_over"}
|
||||||
|
→ judgment_result
|
||||||
|
|
||||||
All server → client frames flow through a single sender task so concurrent
|
All server → client frames flow through a single sender task so concurrent
|
||||||
producers (ambient loop, reply streaming, TTS callbacks) never interleave on
|
producers (ambient loop, reply streaming, TTS callbacks) never interleave on
|
||||||
@@ -26,11 +30,18 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app import judgment
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import async_session_maker as _default_session_maker
|
from app.db import async_session_maker as _default_session_maker
|
||||||
from app.deps import SESSION_COOKIE_NAME
|
from app.deps import SESSION_COOKIE_NAME
|
||||||
from app.entities import fallback_signature, signature_from_anomalies
|
from app.entities import fallback_signature, signature_from_anomalies
|
||||||
from app.inventory import SUMMON_ESSENCE_TRICKLE, credit_essence, roll_item_drop, summon_drop_trigger
|
from app.inventory import (
|
||||||
|
RITUAL_SUCCESS_ESSENCE,
|
||||||
|
SUMMON_ESSENCE_TRICKLE,
|
||||||
|
credit_essence,
|
||||||
|
roll_item_drop,
|
||||||
|
summon_drop_trigger,
|
||||||
|
)
|
||||||
from app.llm.service import SpiritBusyError, spirit_service
|
from app.llm.service import SpiritBusyError, spirit_service
|
||||||
from app.models.auth_session import AuthSession, hash_token
|
from app.models.auth_session import AuthSession, hash_token
|
||||||
from app.models.contact_session import ContactSession
|
from app.models.contact_session import ContactSession
|
||||||
@@ -89,6 +100,16 @@ class SeanceState:
|
|||||||
ambient_task: asyncio.Task | None = None
|
ambient_task: asyncio.Task | None = None
|
||||||
wire_jitter_history: list[float] = field(default_factory=list)
|
wire_jitter_history: list[float] = field(default_factory=list)
|
||||||
last_wire_anomaly_at: float = 0.0
|
last_wire_anomaly_at: float = 0.0
|
||||||
|
# Workstream B (character-depth-ghost-log spec): ritual progress for the
|
||||||
|
# *current* entity — reset whenever a fresh presence is summoned (see
|
||||||
|
# _handle_summon) or a new ritual_start arrives.
|
||||||
|
ritual_steps: int = 0
|
||||||
|
ritual_completed: bool = False
|
||||||
|
ritual_success: bool = False
|
||||||
|
# Per-session RNG for `tell` frames — unseeded (a session's tells should
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
# Active-session registry (spec: ESP32 sensor node, Workstream K): maps a
|
# Active-session registry (spec: ESP32 sensor node, Workstream K): maps a
|
||||||
@@ -133,9 +154,24 @@ def serialize_entity(entity: Entity) -> dict:
|
|||||||
"quotes": entity.sample_quotes,
|
"quotes": entity.sample_quotes,
|
||||||
"contact_count": entity.contact_count,
|
"contact_count": entity.contact_count,
|
||||||
"discovered_at": entity.discovered_at.isoformat(),
|
"discovered_at": entity.discovered_at.isoformat(),
|
||||||
|
# Workstream B: hidden ground truth, kept on the server-side
|
||||||
|
# SeanceState.entity dict for the ritual/judgment/tell handlers to
|
||||||
|
# read (state.entity["traits"]) — see `_public_entity` below for why
|
||||||
|
# this never reaches the wire directly.
|
||||||
|
"traits": entity.traits,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _public_entity(entity: dict) -> dict:
|
||||||
|
"""The entity payload actually sent to the client in the `entity`
|
||||||
|
frame — everything `serialize_entity` produces *except* `traits`.
|
||||||
|
Hidden traits must never leak outside `ritual_complete` on success (the
|
||||||
|
contract's decoupling requirement, echoed in
|
||||||
|
frontend/src/lib/evilMeter.ts's comments); `frontend/src/lib/types.ts`'s
|
||||||
|
`SpiritEntity` type correspondingly has no `traits` field."""
|
||||||
|
return {key: value for key, value in entity.items() if key != "traits"}
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(websocket: WebSocket) -> str:
|
def _client_ip(websocket: WebSocket) -> str:
|
||||||
host = websocket.client.host if websocket.client else None
|
host = websocket.client.host if websocket.client else None
|
||||||
return resolve_client_ip(websocket.headers, host)
|
return resolve_client_ip(websocket.headers, host)
|
||||||
@@ -237,28 +273,50 @@ async def _unique_entity_name(db, base_name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def _summon(state: SeanceState, channel: str) -> tuple[Entity, bool]:
|
async def _summon(state: SeanceState, channel: str) -> tuple[Entity, bool]:
|
||||||
"""Match this session's signature against the Codex, or mint a new entity."""
|
"""Match this session's signature against the Codex, or mint a new entity.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
signature = signature_from_anomalies(state.anomalies) or fallback_signature(
|
signature = signature_from_anomalies(state.anomalies) or fallback_signature(
|
||||||
str(state.session_id)
|
str(state.session_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
async with session_maker() as db:
|
async with session_maker() as db:
|
||||||
entity = await db.scalar(select(Entity).where(Entity.signature == signature))
|
entity = await db.scalar(
|
||||||
|
select(Entity).where(Entity.signature == signature, Entity.at_peace.is_(False))
|
||||||
|
)
|
||||||
is_new = entity is None
|
is_new = entity is None
|
||||||
|
|
||||||
if is_new:
|
if is_new:
|
||||||
|
mint_signature = signature
|
||||||
|
retired = await db.scalar(select(Entity).where(Entity.signature == signature))
|
||||||
|
if retired is not None:
|
||||||
|
mint_signature = f"{signature}:{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
profile = await spirit_service.mint_profile(
|
profile = await spirit_service.mint_profile(
|
||||||
signature, channel, state.anomalies, state.language
|
mint_signature, channel, state.anomalies, state.language
|
||||||
)
|
)
|
||||||
|
discoverer = await db.get(User, state.user_id)
|
||||||
|
favor = discoverer.favor if discoverer is not None else 0.0
|
||||||
entity = Entity(
|
entity = Entity(
|
||||||
name=await _unique_entity_name(db, profile["name"]),
|
name=await _unique_entity_name(db, profile["name"]),
|
||||||
epithet=profile["epithet"],
|
epithet=profile["epithet"],
|
||||||
persona=profile["persona"],
|
persona=profile["persona"],
|
||||||
rarity_tier=profile["rarity"],
|
rarity_tier=profile["rarity"],
|
||||||
signature=signature,
|
signature=mint_signature,
|
||||||
voice_profile=profile["voice"],
|
voice_profile=profile["voice"],
|
||||||
visual_profile=profile["visual"],
|
visual_profile=profile["visual"],
|
||||||
sample_quotes=profile["quotes"],
|
sample_quotes=profile["quotes"],
|
||||||
|
# Workstream B: signature-seeded traits, nudged by the
|
||||||
|
# discovering user's favor (app.judgment.apply_favor_bias) —
|
||||||
|
# never derived from/fed into the persona above.
|
||||||
|
traits=judgment.apply_favor_bias(profile["traits"], favor),
|
||||||
discovered_by=state.user_id,
|
discovered_by=state.user_id,
|
||||||
contact_count=1,
|
contact_count=1,
|
||||||
)
|
)
|
||||||
@@ -286,11 +344,11 @@ async def _reward_summon(state: SeanceState) -> None:
|
|||||||
summon (any mode), and — only when the summoned entity is high-rarity —
|
summon (any mode), and — only when the summoned entity is high-rarity —
|
||||||
a roll for an item drop. The other two contract trigger points ("after a
|
a roll for an item drop. The other two contract trigger points ("after a
|
||||||
correct judgment, a successful ritual") belong to Workstream B's
|
correct judgment, a successful ritual") belong to Workstream B's
|
||||||
ritual/judgment WS handlers, which don't exist in this codebase yet;
|
ritual/judgment WS handlers (`_reward_ritual_success` / `_handle_judgment`
|
||||||
`app.inventory` exposes the same `roll_item_drop`/`credit_essence`
|
below), which call the same `app.inventory` `roll_item_drop`/
|
||||||
helpers (plus the milestone essence constants) for those handlers to
|
`credit_essence` helpers and milestone essence constants so the drop
|
||||||
call once they land, so the drop table and essence economy stay in one
|
table and essence economy stay in one place instead of being
|
||||||
place instead of being duplicated."""
|
duplicated."""
|
||||||
assert state.entity is not None
|
assert state.entity is not None
|
||||||
rarity = state.entity.get("rarity", "common")
|
rarity = state.entity.get("rarity", "common")
|
||||||
|
|
||||||
@@ -338,14 +396,39 @@ async def _handle_summon(state: SeanceState) -> None:
|
|||||||
await state.send_queue.put({"type": "status", "state": "summoning"})
|
await state.send_queue.put({"type": "status", "state": "summoning"})
|
||||||
entity, is_new = await _summon(state, state.mode if state.mode != "unknown" else "ouija")
|
entity, is_new = await _summon(state, state.mode if state.mode != "unknown" else "ouija")
|
||||||
state.entity = serialize_entity(entity)
|
state.entity = serialize_entity(entity)
|
||||||
|
# A fresh presence invalidates any in-progress/completed ritual from
|
||||||
|
# whatever was previously in this slot (mirrors the frontend reducer's
|
||||||
|
# 'entity' case in state/seance.tsx, which resets its own ritual/
|
||||||
|
# judgment UI state the same way).
|
||||||
|
state.ritual_steps = 0
|
||||||
|
state.ritual_completed = False
|
||||||
|
state.ritual_success = False
|
||||||
await state.send_queue.put(
|
await state.send_queue.put(
|
||||||
{"type": "entity", "entity": state.entity, "is_new": is_new}
|
{"type": "entity", "entity": _public_entity(state.entity), "is_new": is_new}
|
||||||
)
|
)
|
||||||
await _reward_summon(state)
|
await _reward_summon(state)
|
||||||
greeting = random.choice(state.entity["quotes"]) if state.entity["quotes"] else "I am here."
|
greeting = random.choice(state.entity["quotes"]) if state.entity["quotes"] else "I am here."
|
||||||
await _speak(state, "greeting", greeting)
|
await _speak(state, "greeting", greeting)
|
||||||
|
|
||||||
|
|
||||||
|
# Workstream B: `tell` frames piggyback on the existing anomaly/reply
|
||||||
|
# handling rather than running their own timer — a fragment (ambient,
|
||||||
|
# frequent) rolls a lower chance than a direct reply (deliberate, a seeker
|
||||||
|
# just asked something), so tells feel like they're punctuating engagement
|
||||||
|
# rather than firing on a fixed clock.
|
||||||
|
TELL_CHANCE_ON_FRAGMENT = 0.2
|
||||||
|
TELL_CHANCE_ON_REPLY = 0.35
|
||||||
|
|
||||||
|
|
||||||
|
async def _maybe_tell(state: SeanceState, chance: float) -> None:
|
||||||
|
if state.entity is None:
|
||||||
|
return
|
||||||
|
if state.tell_rng.random() >= chance:
|
||||||
|
return
|
||||||
|
text = judgment.generate_tell(state.entity.get("traits", {}), state.tell_rng)
|
||||||
|
await state.send_queue.put({"type": "tell", "text": text})
|
||||||
|
|
||||||
|
|
||||||
async def _handle_anomaly(state: SeanceState, message: dict) -> None:
|
async def _handle_anomaly(state: SeanceState, message: dict) -> None:
|
||||||
anomaly = {
|
anomaly = {
|
||||||
"source": str(message.get("source", "unknown"))[:16],
|
"source": str(message.get("source", "unknown"))[:16],
|
||||||
@@ -378,6 +461,7 @@ async def _handle_anomaly(state: SeanceState, message: dict) -> None:
|
|||||||
except SpiritBusyError:
|
except SpiritBusyError:
|
||||||
return
|
return
|
||||||
await _speak(state, "fragment", fragment)
|
await _speak(state, "fragment", fragment)
|
||||||
|
await _maybe_tell(state, TELL_CHANCE_ON_FRAGMENT)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_question(state: SeanceState, text: str) -> None:
|
async def _handle_question(state: SeanceState, text: str) -> None:
|
||||||
@@ -434,6 +518,7 @@ async def _handle_question(state: SeanceState, text: str) -> None:
|
|||||||
await state.send_queue.put({"type": "reply_end", "id": str(reply_id), "text": reply})
|
await state.send_queue.put({"type": "reply_end", "id": str(reply_id), "text": reply})
|
||||||
if reply:
|
if reply:
|
||||||
await _speak(state, "reply", reply, instability=1 - stability)
|
await _speak(state, "reply", reply, instability=1 - stability)
|
||||||
|
await _maybe_tell(state, TELL_CHANCE_ON_REPLY)
|
||||||
|
|
||||||
|
|
||||||
async def _ambient_loop(state: SeanceState) -> None:
|
async def _ambient_loop(state: SeanceState) -> None:
|
||||||
@@ -485,6 +570,133 @@ async def _handle_passive(state: SeanceState, enabled: bool) -> None:
|
|||||||
await state.send_queue.put({"type": "passive", "enabled": False})
|
await state.send_queue.put({"type": "passive", "enabled": False})
|
||||||
|
|
||||||
|
|
||||||
|
# --- Workstream B: ritual + judgment (character-depth-ghost-log spec) ------
|
||||||
|
|
||||||
|
# How many `ritual_step` frames complete one attempt — matches
|
||||||
|
# frontend/src/lib/ritual.ts's RITUAL_TOTAL_STEPS (the 4-rune "align /
|
||||||
|
# breathe / trace / lock" sequence). The frontend owns the exact step count
|
||||||
|
# per the spec ("implementer's call"); this just has to agree with it.
|
||||||
|
RITUAL_STEPS_REQUIRED = 4
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_ritual_start(state: SeanceState) -> None:
|
||||||
|
if state.entity is None:
|
||||||
|
return # no presence to focus on — frontend already gates the button
|
||||||
|
state.ritual_steps = 0
|
||||||
|
state.ritual_completed = False
|
||||||
|
state.ritual_success = False
|
||||||
|
|
||||||
|
|
||||||
|
async def _reward_ritual_success(state: SeanceState) -> None:
|
||||||
|
"""Mirrors `_reward_summon`'s essence-credit + item-drop pattern for the
|
||||||
|
ritual milestone trigger point."""
|
||||||
|
item = None
|
||||||
|
async with session_maker() as db:
|
||||||
|
user = await db.get(User, state.user_id)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
credit_essence(user, RITUAL_SUCCESS_ESSENCE)
|
||||||
|
item = roll_item_drop("ritual")
|
||||||
|
if item is not None:
|
||||||
|
db.add(
|
||||||
|
InventoryItem(
|
||||||
|
user_id=state.user_id,
|
||||||
|
item_type=item["item_type"],
|
||||||
|
item_key=item["item_key"],
|
||||||
|
payload=item["payload"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if item is not None:
|
||||||
|
await state.send_queue.put({"type": "item_drop", "item": item})
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_ritual_step(state: SeanceState, message: dict) -> None:
|
||||||
|
if state.entity is None or state.ritual_completed:
|
||||||
|
return
|
||||||
|
if not isinstance(message.get("step"), int):
|
||||||
|
return
|
||||||
|
|
||||||
|
state.ritual_steps += 1
|
||||||
|
if state.ritual_steps < RITUAL_STEPS_REQUIRED:
|
||||||
|
return
|
||||||
|
|
||||||
|
traits = state.entity.get("traits", {})
|
||||||
|
success = judgment.roll_ritual_success(traits)
|
||||||
|
state.ritual_completed = True
|
||||||
|
state.ritual_success = success
|
||||||
|
revealed = dict(traits) if success else None
|
||||||
|
await state.send_queue.put(
|
||||||
|
{"type": "ritual_complete", "success": success, "revealed": revealed}
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
await _reward_ritual_success(state)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_judgment(state: SeanceState, message: dict) -> None:
|
||||||
|
if state.entity is None:
|
||||||
|
return
|
||||||
|
verdict = message.get("verdict")
|
||||||
|
if verdict not in judgment.VERDICTS:
|
||||||
|
return
|
||||||
|
|
||||||
|
traits = state.entity.get("traits", {})
|
||||||
|
outcome = judgment.judge_verdict(
|
||||||
|
verdict,
|
||||||
|
traits,
|
||||||
|
ritual_completed=state.ritual_completed,
|
||||||
|
ritual_success=state.ritual_success,
|
||||||
|
)
|
||||||
|
|
||||||
|
item = None
|
||||||
|
# Skip the DB round-trip entirely when there's nothing to persist (e.g.
|
||||||
|
# `test` without a completed ritual, or a resisted cross_over) — the
|
||||||
|
# contract's "no crash, just no effect" for those cases.
|
||||||
|
if outcome.favor_delta or outcome.essence_delta or outcome.consequence in (
|
||||||
|
"reward",
|
||||||
|
"crossed_over",
|
||||||
|
):
|
||||||
|
async with session_maker() as db:
|
||||||
|
user = await db.get(User, state.user_id)
|
||||||
|
if user is not None:
|
||||||
|
if outcome.favor_delta:
|
||||||
|
user.favor = judgment.clamp_favor(user.favor + outcome.favor_delta)
|
||||||
|
if outcome.essence_delta:
|
||||||
|
credit_essence(user, outcome.essence_delta)
|
||||||
|
|
||||||
|
if outcome.consequence == "crossed_over":
|
||||||
|
entity_row = await db.get(Entity, uuid.UUID(state.entity["id"]))
|
||||||
|
if entity_row is not None:
|
||||||
|
entity_row.at_peace = True
|
||||||
|
|
||||||
|
if outcome.consequence in ("reward", "crossed_over"):
|
||||||
|
item = roll_item_drop("judgment")
|
||||||
|
if item is not None:
|
||||||
|
db.add(
|
||||||
|
InventoryItem(
|
||||||
|
user_id=state.user_id,
|
||||||
|
item_type=item["item_type"],
|
||||||
|
item_key=item["item_key"],
|
||||||
|
payload=item["payload"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await state.send_queue.put(
|
||||||
|
{
|
||||||
|
"type": "judgment_result",
|
||||||
|
"correct": outcome.correct,
|
||||||
|
"favor_delta": outcome.favor_delta,
|
||||||
|
"essence_delta": outcome.essence_delta,
|
||||||
|
"at_peace": outcome.at_peace,
|
||||||
|
"consequence": outcome.consequence,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if item is not None:
|
||||||
|
await state.send_queue.put({"type": "item_drop", "item": item})
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/session")
|
@router.websocket("/ws/session")
|
||||||
async def session_socket(websocket: WebSocket) -> None:
|
async def session_socket(websocket: WebSocket) -> None:
|
||||||
user_id = await _authenticate(websocket)
|
user_id = await _authenticate(websocket)
|
||||||
@@ -539,6 +751,12 @@ async def session_socket(websocket: WebSocket) -> None:
|
|||||||
await _handle_question(state, message["text"])
|
await _handle_question(state, message["text"])
|
||||||
elif msg_type == "passive":
|
elif msg_type == "passive":
|
||||||
await _handle_passive(state, bool(message.get("enabled")))
|
await _handle_passive(state, bool(message.get("enabled")))
|
||||||
|
elif msg_type == "ritual_start":
|
||||||
|
await _handle_ritual_start(state)
|
||||||
|
elif msg_type == "ritual_step":
|
||||||
|
await _handle_ritual_step(state, message)
|
||||||
|
elif msg_type == "judgment":
|
||||||
|
await _handle_judgment(state, message)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
363
backend/tests/test_judgment.py
Normal file
363
backend/tests/test_judgment.py
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.judgment import (
|
||||||
|
FAVOR_CORRECT_BANISH,
|
||||||
|
FAVOR_CORRECT_CROSS_OVER,
|
||||||
|
FAVOR_CORRECT_TRUST,
|
||||||
|
FAVOR_CROSS_OVER_FAIL,
|
||||||
|
FAVOR_TEST,
|
||||||
|
FAVOR_WRONG_BANISH,
|
||||||
|
FAVOR_WRONG_TRUST,
|
||||||
|
RITUAL_BASE_SUCCESS_CHANCE,
|
||||||
|
RITUAL_MIN_SUCCESS_CHANCE,
|
||||||
|
STUCK_VOLATILITY_THRESHOLD,
|
||||||
|
apply_favor_bias,
|
||||||
|
clamp_favor,
|
||||||
|
generate_tell,
|
||||||
|
is_stuck_spirit,
|
||||||
|
judge_verdict,
|
||||||
|
roll_ritual_success,
|
||||||
|
)
|
||||||
|
from app.inventory import CORRECT_JUDGMENT_ESSENCE, CROSS_OVER_ESSENCE
|
||||||
|
|
||||||
|
|
||||||
|
def _traits(alignment=0.5, power=0.5, volatility=0.5, deceptiveness=0.5):
|
||||||
|
return {
|
||||||
|
"alignment": alignment,
|
||||||
|
"power": power,
|
||||||
|
"volatility": volatility,
|
||||||
|
"deceptiveness": deceptiveness,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- clamp_favor -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_favor_within_range_unchanged():
|
||||||
|
assert clamp_favor(0.3) == 0.3
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_favor_clamps_above_max():
|
||||||
|
assert clamp_favor(5.0) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_favor_clamps_below_min():
|
||||||
|
assert clamp_favor(-5.0) == -1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_favor_at_exact_bounds():
|
||||||
|
assert clamp_favor(1.0) == 1.0
|
||||||
|
assert clamp_favor(-1.0) == -1.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- roll_ritual_success -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ritual_success_always_true_when_rng_below_chance():
|
||||||
|
rng = random.Random()
|
||||||
|
# An easy entity (low power/deceptiveness) sits at the base chance;
|
||||||
|
# forcing rng.random() to 0 always beats any positive chance.
|
||||||
|
monkey_rng = random.Random(0)
|
||||||
|
monkey_rng.random = lambda: 0.0 # type: ignore[method-assign]
|
||||||
|
assert roll_ritual_success(_traits(power=0.0, deceptiveness=0.0), monkey_rng) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ritual_success_always_false_when_rng_at_one():
|
||||||
|
monkey_rng = random.Random(0)
|
||||||
|
monkey_rng.random = lambda: 0.999999 # type: ignore[method-assign]
|
||||||
|
assert roll_ritual_success(_traits(power=0.0, deceptiveness=0.0), monkey_rng) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ritual_harder_entity_has_lower_success_chance():
|
||||||
|
# Same rng draw, easy vs. hard entity: the hard one should fail where
|
||||||
|
# the easy one succeeds, for a draw threaded between the two chances.
|
||||||
|
easy = _traits(power=0.0, deceptiveness=0.0)
|
||||||
|
hard = _traits(power=1.0, deceptiveness=1.0)
|
||||||
|
|
||||||
|
fixed_draw = (RITUAL_BASE_SUCCESS_CHANCE + RITUAL_MIN_SUCCESS_CHANCE) / 2
|
||||||
|
|
||||||
|
rng_easy = random.Random(0)
|
||||||
|
rng_easy.random = lambda: fixed_draw # type: ignore[method-assign]
|
||||||
|
rng_hard = random.Random(0)
|
||||||
|
rng_hard.random = lambda: fixed_draw # type: ignore[method-assign]
|
||||||
|
|
||||||
|
assert roll_ritual_success(easy, rng_easy) is True
|
||||||
|
assert roll_ritual_success(hard, rng_hard) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ritual_success_chance_never_below_floor():
|
||||||
|
# Even the worst-case entity must be beatable — a low enough draw always
|
||||||
|
# succeeds.
|
||||||
|
rng = random.Random(0)
|
||||||
|
rng.random = lambda: RITUAL_MIN_SUCCESS_CHANCE - 0.01 # type: ignore[method-assign]
|
||||||
|
assert roll_ritual_success(_traits(power=1.0, deceptiveness=1.0), rng) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ritual_success_alignment_and_volatility_dont_affect_odds():
|
||||||
|
fixed_draw = 0.5
|
||||||
|
rng_a = random.Random(0)
|
||||||
|
rng_a.random = lambda: fixed_draw # type: ignore[method-assign]
|
||||||
|
rng_b = random.Random(0)
|
||||||
|
rng_b.random = lambda: fixed_draw # type: ignore[method-assign]
|
||||||
|
|
||||||
|
result_a = roll_ritual_success(_traits(alignment=0.0, volatility=0.0, power=0.4, deceptiveness=0.4), rng_a)
|
||||||
|
result_b = roll_ritual_success(_traits(alignment=1.0, volatility=1.0, power=0.4, deceptiveness=0.4), rng_b)
|
||||||
|
assert result_a == result_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_ritual_success_uses_fresh_rng_by_default_and_varies():
|
||||||
|
# No injected rng: repeated calls against the same traits should not
|
||||||
|
# all agree (proves it isn't signature/deterministically seeded).
|
||||||
|
outcomes = {roll_ritual_success(_traits()) for _ in range(200)}
|
||||||
|
assert outcomes == {True, False}
|
||||||
|
|
||||||
|
|
||||||
|
# --- is_stuck_spirit -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_stuck_spirit_requires_benevolent_and_high_volatility():
|
||||||
|
assert is_stuck_spirit(_traits(alignment=0.7, volatility=0.9)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_stuck_spirit_false_for_demon_even_if_volatile():
|
||||||
|
assert is_stuck_spirit(_traits(alignment=0.2, volatility=0.95)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_stuck_spirit_false_for_calm_benevolent_spirit():
|
||||||
|
assert is_stuck_spirit(_traits(alignment=0.8, volatility=0.3)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_stuck_spirit_boundary_volatility_not_stuck():
|
||||||
|
assert is_stuck_spirit(_traits(alignment=0.9, volatility=STUCK_VOLATILITY_THRESHOLD)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_stuck_spirit_boundary_alignment_is_stuck():
|
||||||
|
assert is_stuck_spirit(_traits(alignment=0.5, volatility=0.99)) is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- judge_verdict: trust -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_correct_on_benevolent_spirit():
|
||||||
|
outcome = judge_verdict("trust", _traits(alignment=0.8))
|
||||||
|
assert outcome.correct is True
|
||||||
|
assert outcome.consequence == "reward"
|
||||||
|
assert outcome.favor_delta == FAVOR_CORRECT_TRUST
|
||||||
|
assert outcome.essence_delta == CORRECT_JUDGMENT_ESSENCE
|
||||||
|
assert outcome.at_peace is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_wrong_on_demon():
|
||||||
|
outcome = judge_verdict("trust", _traits(alignment=0.1))
|
||||||
|
assert outcome.correct is False
|
||||||
|
assert outcome.consequence == "escalation"
|
||||||
|
assert outcome.favor_delta == FAVOR_WRONG_TRUST
|
||||||
|
assert outcome.essence_delta == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_boundary_alignment_counts_as_benevolent():
|
||||||
|
outcome = judge_verdict("trust", _traits(alignment=0.5))
|
||||||
|
assert outcome.correct is True
|
||||||
|
assert outcome.consequence == "reward"
|
||||||
|
|
||||||
|
|
||||||
|
# --- judge_verdict: banish -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_banish_correct_on_demon():
|
||||||
|
outcome = judge_verdict("banish", _traits(alignment=0.1))
|
||||||
|
assert outcome.correct is True
|
||||||
|
assert outcome.consequence == "reward"
|
||||||
|
assert outcome.favor_delta == FAVOR_CORRECT_BANISH
|
||||||
|
assert outcome.essence_delta == CORRECT_JUDGMENT_ESSENCE
|
||||||
|
|
||||||
|
|
||||||
|
def test_banish_wrong_on_real_spirit():
|
||||||
|
outcome = judge_verdict("banish", _traits(alignment=0.9))
|
||||||
|
assert outcome.correct is False
|
||||||
|
assert outcome.consequence == "withdrawal"
|
||||||
|
assert outcome.favor_delta == FAVOR_WRONG_BANISH
|
||||||
|
assert outcome.essence_delta == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_trust_penalty_larger_magnitude_than_wrong_banish():
|
||||||
|
trust_outcome = judge_verdict("trust", _traits(alignment=0.0))
|
||||||
|
banish_outcome = judge_verdict("banish", _traits(alignment=1.0))
|
||||||
|
assert abs(trust_outcome.favor_delta) > abs(banish_outcome.favor_delta)
|
||||||
|
assert trust_outcome.favor_delta < 0
|
||||||
|
assert banish_outcome.favor_delta < 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_favor_deltas_are_small_and_in_range():
|
||||||
|
for delta in (
|
||||||
|
FAVOR_CORRECT_TRUST,
|
||||||
|
FAVOR_CORRECT_BANISH,
|
||||||
|
FAVOR_WRONG_TRUST,
|
||||||
|
FAVOR_WRONG_BANISH,
|
||||||
|
FAVOR_CORRECT_CROSS_OVER,
|
||||||
|
FAVOR_CROSS_OVER_FAIL,
|
||||||
|
FAVOR_TEST,
|
||||||
|
):
|
||||||
|
assert -1.0 <= delta <= 1.0
|
||||||
|
assert abs(delta) <= 0.2 # single-digit percent of the [-1, 1] range
|
||||||
|
|
||||||
|
|
||||||
|
# --- judge_verdict: cross_over ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_over_correct_on_stuck_spirit():
|
||||||
|
outcome = judge_verdict("cross_over", _traits(alignment=0.8, volatility=0.9))
|
||||||
|
assert outcome.correct is True
|
||||||
|
assert outcome.consequence == "crossed_over"
|
||||||
|
assert outcome.at_peace is True
|
||||||
|
assert outcome.favor_delta == FAVOR_CORRECT_CROSS_OVER
|
||||||
|
assert outcome.essence_delta == CROSS_OVER_ESSENCE
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_over_resisted_on_demon():
|
||||||
|
outcome = judge_verdict("cross_over", _traits(alignment=0.1, volatility=0.9))
|
||||||
|
assert outcome.correct is False
|
||||||
|
assert outcome.consequence == "resisted"
|
||||||
|
assert outcome.at_peace is False
|
||||||
|
assert outcome.favor_delta == 0
|
||||||
|
assert outcome.essence_delta == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_over_resisted_on_non_stuck_real_spirit():
|
||||||
|
outcome = judge_verdict("cross_over", _traits(alignment=0.8, volatility=0.2))
|
||||||
|
assert outcome.correct is False
|
||||||
|
assert outcome.consequence == "resisted"
|
||||||
|
assert outcome.at_peace is False
|
||||||
|
assert outcome.favor_delta == 0
|
||||||
|
assert outcome.essence_delta == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_over_essence_is_largest_reward_of_any_outcome():
|
||||||
|
trust = judge_verdict("trust", _traits(alignment=0.9))
|
||||||
|
banish = judge_verdict("banish", _traits(alignment=0.1))
|
||||||
|
crossed = judge_verdict("cross_over", _traits(alignment=0.8, volatility=0.9))
|
||||||
|
assert crossed.essence_delta > trust.essence_delta
|
||||||
|
assert crossed.essence_delta > banish.essence_delta
|
||||||
|
|
||||||
|
|
||||||
|
# --- judge_verdict: test ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_verdict_without_completed_ritual_has_no_effect():
|
||||||
|
outcome = judge_verdict("test", _traits(alignment=0.9), ritual_completed=False)
|
||||||
|
assert outcome.correct is False
|
||||||
|
assert outcome.consequence == "neutral"
|
||||||
|
assert outcome.favor_delta == 0
|
||||||
|
assert outcome.essence_delta == 0
|
||||||
|
assert outcome.at_peace is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_verdict_with_successful_completed_ritual():
|
||||||
|
outcome = judge_verdict(
|
||||||
|
"test", _traits(alignment=0.9), ritual_completed=True, ritual_success=True
|
||||||
|
)
|
||||||
|
assert outcome.correct is True
|
||||||
|
assert outcome.consequence == "neutral"
|
||||||
|
assert outcome.favor_delta == 0
|
||||||
|
assert outcome.essence_delta == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_verdict_with_failed_completed_ritual():
|
||||||
|
outcome = judge_verdict(
|
||||||
|
"test", _traits(alignment=0.9), ritual_completed=True, ritual_success=False
|
||||||
|
)
|
||||||
|
assert outcome.correct is False
|
||||||
|
assert outcome.consequence == "neutral"
|
||||||
|
assert outcome.favor_delta == 0
|
||||||
|
assert outcome.essence_delta == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_judge_verdict_rejects_unknown_verdict():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
judge_verdict("smite", _traits())
|
||||||
|
|
||||||
|
|
||||||
|
# --- apply_favor_bias -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_favor_bias_zero_favor_is_a_no_op():
|
||||||
|
traits = _traits(volatility=0.5, deceptiveness=0.5)
|
||||||
|
biased = apply_favor_bias(traits, 0.0)
|
||||||
|
assert biased["volatility"] == pytest.approx(traits["volatility"])
|
||||||
|
assert biased["deceptiveness"] == pytest.approx(traits["deceptiveness"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_favor_bias_positive_favor_lowers_volatility_and_deceptiveness():
|
||||||
|
traits = _traits(volatility=0.5, deceptiveness=0.5)
|
||||||
|
biased = apply_favor_bias(traits, 1.0)
|
||||||
|
assert biased["volatility"] < traits["volatility"]
|
||||||
|
assert biased["deceptiveness"] < traits["deceptiveness"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_favor_bias_negative_favor_raises_volatility_and_deceptiveness():
|
||||||
|
traits = _traits(volatility=0.5, deceptiveness=0.5)
|
||||||
|
biased = apply_favor_bias(traits, -1.0)
|
||||||
|
assert biased["volatility"] > traits["volatility"]
|
||||||
|
assert biased["deceptiveness"] > traits["deceptiveness"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_favor_bias_leaves_alignment_and_power_untouched():
|
||||||
|
traits = _traits(alignment=0.3, power=0.7, volatility=0.5, deceptiveness=0.5)
|
||||||
|
biased = apply_favor_bias(traits, 1.0)
|
||||||
|
assert biased["alignment"] == traits["alignment"]
|
||||||
|
assert biased["power"] == traits["power"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_favor_bias_clamps_to_valid_range():
|
||||||
|
traits = _traits(volatility=0.02, deceptiveness=0.98)
|
||||||
|
biased = apply_favor_bias(traits, -1.0)
|
||||||
|
assert 0.0 <= biased["volatility"] <= 1.0
|
||||||
|
assert 0.0 <= biased["deceptiveness"] <= 1.0
|
||||||
|
biased_up = apply_favor_bias(traits, 1.0)
|
||||||
|
assert 0.0 <= biased_up["deceptiveness"] <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_favor_bias_effect_size_is_small():
|
||||||
|
traits = _traits(volatility=0.5, deceptiveness=0.5)
|
||||||
|
biased = apply_favor_bias(traits, 1.0)
|
||||||
|
assert abs(biased["volatility"] - traits["volatility"]) <= 0.15
|
||||||
|
assert abs(biased["deceptiveness"] - traits["deceptiveness"]) <= 0.15
|
||||||
|
|
||||||
|
|
||||||
|
def test_favor_bias_out_of_range_favor_gets_clamped_first():
|
||||||
|
traits = _traits(volatility=0.5, deceptiveness=0.5)
|
||||||
|
extreme = apply_favor_bias(traits, 5.0)
|
||||||
|
clamped = apply_favor_bias(traits, 1.0)
|
||||||
|
assert extreme == clamped
|
||||||
|
|
||||||
|
|
||||||
|
# --- generate_tell -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_tell_never_leaks_a_raw_number():
|
||||||
|
rng = random.Random(42)
|
||||||
|
traits = _traits(alignment=0.9, power=0.1, volatility=0.95, deceptiveness=0.05)
|
||||||
|
for _ in range(50):
|
||||||
|
text = generate_tell(traits, rng)
|
||||||
|
assert isinstance(text, str) and text
|
||||||
|
# No digits anywhere in the line — the whole point is never
|
||||||
|
# surfacing a stat number.
|
||||||
|
assert not any(ch.isdigit() for ch in text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_tell_is_deterministic_given_same_rng_state():
|
||||||
|
traits = _traits()
|
||||||
|
rng_a = random.Random(7)
|
||||||
|
rng_b = random.Random(7)
|
||||||
|
lines_a = [generate_tell(traits, rng_a) for _ in range(10)]
|
||||||
|
lines_b = [generate_tell(traits, rng_b) for _ in range(10)]
|
||||||
|
assert lines_a == lines_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_tell_varies_across_draws():
|
||||||
|
traits = _traits()
|
||||||
|
rng = random.Random(99)
|
||||||
|
lines = {generate_tell(traits, rng) for _ in range(30)}
|
||||||
|
assert len(lines) > 1
|
||||||
614
backend/tests/test_ws_ritual_judgment.py
Normal file
614
backend/tests/test_ws_ritual_judgment.py
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
"""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"):
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
||||||
Reference in New Issue
Block a user