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