from game import events, fallbacks, llm, presentation from game.state import GameState def make_state(**kw): s = GameState(session_id="t", phase="free_roam") for k, v in kw.items(): setattr(s, k, v) return s def play_one(state, response_type="quick_explain", text=""): event = events.next_event(state) if event["kind"] == "presentation": # walk every presentation round to completion data = presentation.advance(state, "", "") while data["kind"] == "round": data = presentation.advance(state, "custom", "We owned it and here is the plan.") return event, data outcome = events.respond(state, response_type, text) return event, outcome def test_full_quarter_runs(): state = make_state() seen_presentations = 0 while state.crisis_number < 15 or state.current_event: event, _ = play_one(state) if event["kind"] == "presentation": seen_presentations += 1 assert seen_presentations == 3 assert state.phase == "review" review = presentation.quarterly_review(state) assert review["tier"] in ("hit_target", "above_600k", "300k_to_600k", "below_300k") assert len(state.event_log) >= 15 def test_presentations_fire_at_4_8_15(): state = make_state() fired = [] while state.crisis_number < 15 or state.current_event: event, _ = play_one(state) if event["kind"] == "presentation": fired.append(event["crisis_number"]) assert fired == [4, 8, 15] def test_special_drought_forces_higher_chance(): # over many slots, specials must occur (25% → 60% after 3 dry slots) state = make_state() specials = 0 for _ in range(12): if state.crisis_number >= 15: break event, _ = play_one(state) if event.get("special"): specials += 1 assert specials >= 1 def test_fine_whatever_tracked(): state = make_state() event = events.next_event(state) if event["kind"] == "presentation": return # slot landed on a presentation; counter logic tested below events.respond(state, "quick_fine", "") assert state.consecutive_fine_whatever == 1 or state.current_event is None def test_bribery_accept_increases_pocket(): state = make_state() state.queued_special = "bribery" event = events.next_event(state) assert event["special"] == "bribery" offer = event["bribe_offer"] before = state.pocket_money events.respond(state, "option_a", "") assert state.pocket_money == before + offer assert state.bribes_accepted == 1 def test_bribery_decline_boosts_morale(): state = make_state() state.queued_special = "bribery" events.next_event(state) before = state.morale events.respond(state, "option_b", "") assert state.morale == before + 5 and state.bribes_accepted == 0 def test_event_log_grows_and_snapshot_hides_morale(): state = make_state() play_one(state) snap = state.snapshot() assert "morale" not in snap assert all("relationship" not in str(k) for k in snap) assert snap["crisis_number"] == state.crisis_number def _romance_event(state, npc_id): """Stage a romance special event as the active crisis.""" state.npc(npc_id).relationship = 72 ev = events._special_event(state, "romance") ev.update({"kind": "crisis", "special": "romance", "affected_npc": npc_id, "crisis_number": 2}) state.current_event = ev state.crisis_number = 2 state.phase = "crisis" return ev def test_romance_only_offered_when_eligible(): state = make_state() assert events._romance_candidate(state) is None # everyone at 50 state.npc("stacey").relationship = 75 assert events._romance_candidate(state) == "stacey" state.npc("stacey").romance_active = True assert events._romance_candidate(state) is None # already taken assert events._any_romance_active(state) def test_romance_pursue_sets_active(): state = make_state() _romance_event(state, "stacey") events.respond(state, "option_a", "") assert state.npc("stacey").romance_active snap = state.snapshot() assert snap["npc_romance"]["stacey"] == "active" def test_romance_decline_stays_professional(): state = make_state() _romance_event(state, "kevin") events.respond(state, "option_b", "") # the core behavior: declining does NOT start a romance. (The exact # relationship change is muddied by the crisis outcome's own delta, so we # don't assert its direction here — the decline penalty is applied in code.) assert not state.npc("kevin").romance_active def test_romance_custom_affectionate_pursues(): state = make_state() _romance_event(state, "janet") events.respond(state, "custom", "Honestly, I think I'm in love with you too.") assert state.npc("janet").romance_active def test_romance_above_80_triggers_hr(): state = make_state() state.npc("brad").romance_active = True state.npc("brad").relationship = 78 ev = {"kind": "crisis", "special": None, "affected_npc": "brad", "crisis_number": 2} state.current_event = ev state.crisis_number = 2 state.phase = "crisis" # a supportive interaction nudges Brad past 80 while dating → HR alert events.respond(state, "custom", "You crushed that pitch, genuinely the best on the team.") if state.npc("brad").relationship > 80: assert state.hr_alert and state.queued_special == "hr" def test_snapshot_romance_states(): state = make_state() state.npc("derek").relationship = 66 snap = state.snapshot() assert snap["npc_romance"]["derek"] == "available" assert snap["npc_romance"]["brad"] == "none" assert "relationship" not in str(snap["npc_romance"]) # no raw number def test_presentation_wrong_slide_on_active_romance(): state = make_state(crisis_number=3) state.npc("stacey").romance_active = True events.next_event(state) assert state.presentation["presenting_npc"] == "stacey" assert state.presentation["npc_state"] == "romance" assert state.presentation["wrong_slide_pending"] def test_extended_presentation_on_low_morale(): state = make_state(morale=10, crisis_number=3) event = events.next_event(state) assert event["kind"] == "presentation" assert state.presentation["total_rounds"] == 4 def test_call_validated_retries_then_succeeds(): # a single validation slip should trigger ONE retry, not a fallback state = make_state() seen = {"n": 0} def validate(p): seen["n"] += 1 return None if seen["n"] == 1 else p # fail first, accept second out = llm.call_validated(state, "event", "sys", "usr", validate, requested_type="normal", npc_id="brad") assert out is not None assert seen["n"] == 2 # it retried exactly once def test_call_validated_gives_up_after_retry(): state = make_state() out = llm.call_validated(state, "event", "sys", "usr", lambda p: None, requested_type="normal", npc_id="brad") assert out is None # both attempts failed → caller falls back def test_special_fallback_is_type_matched(): np = fallbacks.special_fallback("newspaper", 0) assert np["affected_npc"] == "brad" and "press" in np["headline"].lower() hr = fallbacks.special_fallback("hr", 0) assert hr["affected_npc"] == "player" client = fallbacks.special_fallback("client_emergency", 0) assert "client" in client["headline"].lower() # unknown kind degrades to the generic bank assert fallbacks.special_fallback("mystery", 0)["headline"] def test_presentation_start_is_idempotent(): # repeated empty "start" calls must re-serve round 1, not burn rounds state = make_state(crisis_number=3) events.next_event(state) first = presentation.advance(state, "", "") again = presentation.advance(state, "", "") third = presentation.advance(state, "", "") assert first["round"] == again["round"] == third["round"] == 1 assert state.presentation["round"] == 1 # a real answer still advances nxt = presentation.advance(state, "custom", "We owned it.") assert nxt["round"] == 2