Implements Workstream B of the character-depth-ghost-log spec: ritual_start/ritual_step/judgment WS handlers, the pure judgment.py logic module, and the User.favor / Entity.at_peace columns + migration. - app/judgment.py: pure ritual success roll (base 65%, floored at 30%, driven by an entity's power+deceptiveness difficulty), the "stuck spirit" cross_over rule (alignment >= 0.5 and volatility > 0.6, ~20% of entities), judgment correctness/favor-delta/essence-delta/ consequence resolution for all four verdicts, favor clamping, the favor-to-trait-roll bias applied at mint time, and tell-line generation (opaque behavioral flavor text, never a raw stat). - app/ws.py: wires ritual_start/ritual_step/judgment frames, emits ritual_complete/tell/judgment_result/item_drop per the spec's Contract; traits are added to serialize_entity for internal server-side use but stripped from the outbound `entity` frame via a new _public_entity helper so hidden ground truth never reaches the client outside ritual_complete; _summon excludes at-peace entities from signature re-contact and mints a fresh (salted-signature) entity instead; new entities' traits are nudged by the discovering user's favor before being persisted. - models/user.py, models/entity.py, main.py: User.favor and Entity.at_peace columns plus their idempotent ADD COLUMN IF NOT EXISTS migration lines in lifespan, alongside the existing ones. - tests/test_judgment.py, tests/test_ws_ritual_judgment.py: 56 new tests covering the ritual/judgment correctness matrix, favor clamping/bias, essence crediting, at_peace persistence + re-contact, and the entity-frame trait leak guard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
364 lines
12 KiB
Python
364 lines
12 KiB
Python
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
|