"""真实感修订:凶手剧本钟点、目标自举事实锚、shout 广播、深夜常识、说书人纪实约束。"""

from genesis.runtime.bulletin import social_rhythm
from genesis.runtime.cognition import Decision, ThinkInput
from genesis.runtime.engine import WorldEngine
from tests.test_cognition import FakeLLM
from tests.test_runtime2 import FakeTime


def _engine(llm=None):
    ft = FakeTime()
    eng = WorldEngine("worlds/ravenisle", rate=600.0, time_fn=ft, brain="llm",
                      llm=llm or FakeLLM())
    eng._ft = ft
    return eng


def _set_hour(eng, hour: float) -> None:
    eng.clock._anchor_sim = hour * 3600.0


# ───────────────── 凶手剧本钟点(节拍器)─────────────────

def test_killer_pressure_only_for_killer_and_escalates():
    eng = _engine()
    _set_hour(eng, 23.0)                                   # 第1天,0 清算:进度提示但不算落后
    note0 = eng._killer_pressure()
    assert "世界回执" in note0 and "至今无人死亡" in note0 and "落后" not in note0
    assert "复仇名单还剩 6 人" in note0
    _set_hour(eng, 2 * 24.0 + 12.0)                        # 第3天仍 0 清算 → 落后,加压
    note = eng._killer_pressure()
    assert "落后" in note and "动手" in note and "世界回执" in note
    snap = eng._snapshot("江离", eng.minds["江离"])
    assert "复仇的进度" in snap.private_note               # 凶手快照携带私有压力
    snap2 = eng._snapshot("温言", eng.minds["温言"])
    assert snap2.private_note == ""                        # 其他人一无所知


def test_killer_pressure_advances_and_revenge_complete_ending():
    eng = _engine()
    _set_hour(eng, 26.0)
    eng.state.positions.update({"江离": "断崖", "程亦深": "断崖"})
    eng._execute("江离", eng.minds["江离"], Decision(action_kind="kill", action_arg="程亦深"))
    note = eng._killer_pressure()
    assert "已清算:1人" in note and "程亦深" in note and "还剩 5 人" in note   # 回执记名,名单递减
    for v in ("邵铭轩", "顾长风", "唐曼", "白聿", "温言"):
        eng.state.positions.update({"江离": "断崖", v: "断崖"})
        eng._execute("江离", eng.minds["江离"], Decision(action_kind="kill", action_arg=v))
    # 六个仇人尽数清算 → 复仇终焉(凶手胜)
    assert eng.ending is not None and eng.ending["kind"] == "复仇终焉"


def test_kill_goal_done_requires_world_receipt():
    """防回归:江离把『杀顾长风』标 done 但世界没有死亡回执 → 驳回+纠偏反馈(幻想凶案bug)。"""
    eng = _engine()
    jiang = eng.minds["江离"]
    jiang.goals = [{"desc": "用 kill 在码头完成对顾长风的清算", "status": "doing", "note": ""}]
    eng._execute("江离", jiang, Decision(thought="完成了", goal_update={"index": 1, "status": "done", "note": "第一幕完成"}))
    assert jiang.goals[0]["status"] == "doing"             # 无回执:驳回
    assert jiang.goals[0]["note"] == "尚未真正动手"
    assert any("你从未真正动手" in e.content for e in jiang.attention._inbox)   # 纠偏反馈入耳
    eng.state.positions.update({"江离": "码头", "顾长风": "码头"})
    eng._execute("江离", jiang, Decision(action_kind="kill", action_arg="顾长风"))
    eng._execute("江离", jiang, Decision(thought="落幕", goal_update={"index": 1, "status": "done", "note": "落幕"}))
    assert jiang.goals[0]["status"] == "done"              # 真杀了:回执成立,自评通过
    # 平民的非杀人目标不受此门限制
    wen = eng.minds["温言"]
    wen.goals = [{"desc": "把剪报册原稿藏好", "status": "doing", "note": ""}]
    eng._execute("温言", wen, Decision(thought="藏好了", goal_update={"index": 1, "status": "done", "note": "已藏匿"}))
    assert wen.goals[0]["status"] == "done"


def test_killer_prompt_carries_kill_doc_others_dont():
    llm = FakeLLM()
    eng = _engine(llm)
    jiang, wen = eng.minds["江离"], eng.minds["温言"]
    jiang.goals = wen.goals = [{"desc": "x", "status": "todo", "note": ""}]   # 跳过自举
    snap = eng._snapshot("江离", jiang)
    jiang.decide(snap)
    assert "kill:" in llm.calls[-1][0]["content"]          # 凶手知道自己能动手
    wen.decide(eng._snapshot("温言", wen))
    assert "kill:" not in llm.calls[-1][0]["content"]      # 平民的动作表里没有 kill


# ───────────────── 目标自举事实锚(G2)─────────────────

def test_bootstrap_goals_prompt_anchored_to_map_and_roster():
    llm = FakeLLM('{"goals": ["去书房搜保险柜"]}')
    eng = _engine(llm)
    mind = eng.minds["温言"]
    mind._bootstrap_goals()
    sys, user = llm.calls[-1][0]["content"], llm.calls[-1][1]["content"]
    assert "雾鸦岛地图常识" in sys and "在岛人员" in sys   # 地图+名册进了 system
    assert "硬性约束" in user and "管家" in user           # 明令禁止编造人与地点
    assert mind.goals[0]["desc"] == "去书房搜保险柜"


# ───────────────── shout 广播(1→N 信息传递)─────────────────

def test_shout_reaches_far_away_agents():
    eng = _engine()
    eng.state.positions.update({"白聿": "断崖", "唐曼": "唐曼的房间"})
    eng._execute("白聿", eng.minds["白聿"], Decision(action_kind="shout", action_arg="出事了!都到门厅来!"))
    inbox = eng.minds["唐曼"].attention._inbox
    assert any("出事了" in e.content for e in inbox)       # 隔半个岛也听得见
    assert eng.minds["白聿"].last_speech == "出事了!都到门厅来!"   # 喊话上气泡


def test_shout_empty_arg_is_noop():
    eng = _engine()
    before = len(eng.minds["温言"].attention._inbox)
    eng._execute("白聿", eng.minds["白聿"], Decision(action_kind="shout"))
    assert len(eng.minds["温言"].attention._inbox) == before


# ───────────────── 深夜时间感 ─────────────────

def test_social_rhythm_deep_night_norm():
    assert "夜已深" in social_rhythm(2.5)
    assert "夜已深" in social_rhythm(23.8)
    assert social_rhythm(10.0) == ""                       # 白天上午无惯例引力


def test_dialogue_prompt_carries_night_wrap_hint():
    llm = FakeLLM('{"thought":"困","speech":"先回房吧","leave":true}')
    eng = _engine(llm)
    mind = eng.minds["温言"]
    snap = ThinkInput(now_h=27.2, time_label="第2天 03:12 凌晨", place="门厅", place_desc="",
                      occupants=["白聿"], events=[], soma_lines=[], candidates=[],
                      bulletin="", memories=[], intent="",
                      dialogue=["白聿:「睡不着」"], dialogue_with="白聿",
                      dialogue_rounds=2, dialogue_budget=8)
    mind._decide_dialogue(snap)
    assert "夜已极深" in llm.calls[-1][1]["content"]       # 凌晨长谈会被提醒收话回房


# ───────────────── 说书人纪实约束 ─────────────────

def test_narrator_system_forbids_invented_wounds_and_items():
    from genesis.runtime.narrator import Narrator
    n = Narrator(FakeLLM())
    assert "伤口" in n.system and "绷带" in n.system


# ───────────────── 观剧体验修订:群戏预算封顶/主线带地点/停电进帧 ─────────────────

def test_session_budget_capped_for_big_groups():
    from genesis.runtime.engine import Session
    s = Session(sid=1, place="放映室", members=list("ABCDEFG"))   # 7 人群戏
    assert s.budget == 10                                          # 28 → 封顶 10(会话偏短逼行动)
    s2 = Session(sid=2, place="书房", members=["A", "B"])
    assert s2.budget == 8                                          # 小会话不受影响


def test_narrative_carries_place_and_power_in_frame():
    eng = _engine()
    eng.state.positions.update({"江离": "断崖", "程亦深": "断崖"})
    eng._execute("江离", eng.minds["江离"], Decision(action_kind="kill", action_arg="程亦深"))
    kill_n = next(n for n in eng.narrative if "☠" in n["text"])
    assert kill_n["place"] == "断崖"                               # 主线带地点(镜头跟随用)
    eng.state.positions["江离"] = "发电机房"
    eng._execute("江离", eng.minds["江离"], Decision(action_kind="use", action_arg="砸毁电闸"))
    f = eng.frame(1)
    assert f["power_on"] is False                                  # 停电状态进帧(前端压暗)
    blackout = next(n for n in eng.narrative if "停电" in n["text"])
    assert blackout["place"] == "发电机房"


def test_destroy_writes_god_view_timeline():
    eng = _engine()
    wen = eng.minds["温言"]
    eng.state.positions["温言"] = "书房"
    eng.state.items["draft"].hidden = False
    eng._execute("温言", wen, Decision(action_kind="take", action_arg="报道原稿"))
    eng._execute("温言", wen, Decision(action_kind="destroy", action_arg="报道原稿"))
    assert any("🔥" in n["text"] and "报道原稿" in n["text"] for n in eng.narrative)


def test_dialogue_prompt_carries_map_ground():
    llm = FakeLLM('{"thought":"x","speech":"嗯","leave":false}')
    eng = _engine(llm)
    mind = eng.minds["温言"]
    snap = ThinkInput(now_h=22.0, time_label="第1天 22:00 深夜", place="门厅", place_desc="",
                      occupants=["白聿"], events=[], soma_lines=[], candidates=[],
                      bulletin="", memories=[], intent="",
                      dialogue=["白聿:「去哪找?」"], dialogue_with="白聿",
                      dialogue_rounds=1, dialogue_budget=8)
    mind._decide_dialogue(snap)
    assert "雾鸦岛地图常识" in llm.calls[-1][0]["content"]         # L1 也锚地图,聊不出"剪辑室"


# ───────────────── 30分钟一局:夜话环节 + 环境感知入记录 ─────────────────

def test_night_court_auto_assembly():
    eng = _engine()
    _set_hour(eng, 23.0)
    eng.state.kill("程亦深", "断崖")                        # 已出命案 → 首场夜话有事可议,正常召开
    eng._night_court()
    assert eng.assembly is not None and eng.assembly["caller"] == "夜话"
    assert any("夜话" in n["text"] for n in eng.narrative)
    eng._night_court()                                      # 已在进行中:幂等不重入
    assert eng.assembly["caller"] == "夜话"


def test_first_court_deferred_until_first_death():
    """首场夜话:零死亡=无事可议,推迟到首案后(把从容首案窗口让给凶手);01:30 后硬性召开。"""
    eng = _engine()
    _set_hour(eng, 23.0)
    eng._night_court()
    assert eng.assembly is None                             # 零死亡 → 推迟,不召集
    assert not any("夜话" in n["text"] for n in eng.narrative)
    eng.state.kill("程亦深", "断崖")
    eng._night_court()
    assert eng.assembly is not None                         # 出了命案再到点 → 召开
    eng2 = _engine()
    _set_hour(eng2, 25.6)
    eng2._night_court()
    assert eng2.assembly is not None                        # 25.5h 后即便零死亡也强制召开


def test_night_court_aborts_inprogress_lure():
    """夜话钟声把人召回门厅,进行中的诱饵作废 + 反馈凶手等散场(凶手优先节奏)。"""
    eng = _engine()
    _set_hour(eng, 23.0)
    eng.state.kill("程亦深", "断崖")                        # 让首场夜话能开
    eng._lure_of["唐曼"] = ("江离", "暗房")
    eng._night_court()
    assert "唐曼" not in eng._lure_of                        # 诱饵被打断作废
    assert any("等散场再找机会" in e.content for e in eng.minds["江离"].attention._inbox)


def test_night_court_skips_when_too_few_alive():
    eng = _engine()
    for v in ("程亦深", "邵铭轩", "顾长风", "唐曼", "温言"):
        eng.state.kill(v, "断崖")
    eng._night_court()
    assert eng.assembly is None                             # 活人<3 不召集


def test_env_perception_recorded_once_per_observation():
    eng = _engine()
    wen = eng.minds["温言"]
    wen.goals = [{"desc": "x", "status": "todo", "note": ""}]
    eng.state.positions["温言"] = "厨房"
    eng._snapshot("温言", wen)
    env_facts = [x for x in wen.recent_facts if x.startswith("(在厨房)")]
    assert env_facts                                        # 刀架空格等环境观察成为事实
    n1 = len([m for m in wen.memory.retrieve("厨房 注意到", 23.0, top_k=10)])
    eng._snapshot("温言", wen)                              # 再看一次:不重复记录
    n2 = len([m for m in wen.memory.retrieve("厨房 注意到", 23.0, top_k=10)])
    assert n2 == n1


def test_pacing_constants_for_7day_game():
    from genesis.runtime.engine import GAME_DAYS, NIGHT_COURT_H
    eng = _engine()
    assert GAME_DAYS == 7
    assert eng.ferry_eta == ((GAME_DAYS - 1) * 24.0 + 5.5) * 3600.0   # 渡船第7天 05:30=封岛尽头
    assert len(NIGHT_COURT_H) == GAME_DAYS - 1 and NIGHT_COURT_H[0] == 23.0   # 前6晚各一场夜话


# ───────────────── 夜话投票强制参与 + 凶手钟点穿透对话 ─────────────────

def test_vote_phase_dissolves_hall_sessions_and_wakes_attendees():
    eng = _engine()
    for a in eng.minds:
        eng.state.positions[a] = "门厅"
    eng._execute("唐曼", eng.minds["唐曼"], Decision(speech_to="温言", speech="你怎么看?"))
    assert eng.sessions                                        # 门厅有闲聊会话
    eng._execute("白聿", eng.minds["白聿"], Decision(action_kind="assemble"))
    eng._assembly_advance()                                    # 召集期满 → 投票阶段
    assert eng.assembly["phase"] == "vote"
    assert not eng.sessions                                    # 会话被强制解散(否则没人能投票)
    woken = [aid for (_, _, aid, reason) in eng.sched._heap if reason == "interrupt:assembly"]
    assert len(woken) >= 3                                     # 到场者全部被错峰唤醒表态


def test_killer_private_note_reaches_dialogue_prompt():
    llm = FakeLLM('{"thought":"x","speech":"先这样","leave":true}')
    eng = _engine(llm)
    jiang = eng.minds["江离"]
    snap = ThinkInput(now_h=23.0, time_label="第1天 23:00 深夜", place="厨房", place_desc="",
                      occupants=["白聿"], events=[], soma_lines=[], candidates=[],
                      bulletin="", memories=[], intent="",
                      dialogue=["白聿:「你怎么看?」"], dialogue_with="白聿",
                      dialogue_rounds=3, dialogue_budget=8,
                      private_note="【世界回执·只有你知道】你实际完成的幕数:0")
    jiang.decide(snap)
    user = llm.calls[-1][1]["content"]
    assert "世界回执" in user and "leave=true" in user         # 钟点穿透对话,促成收场


# ───────────────── 引诱机制(G32):凶手制造 1v1 ─────────────────

def test_lure_hooks_target_private_goal_location():
    eng = _engine()
    jiang = eng.minds["江离"]
    tang = eng.minds["唐曼"]
    tang.goals = [{"desc": "去暗房冲洗当年那卷底片", "status": "doing", "note": ""}]
    eng.state.positions.update({"江离": "门厅", "唐曼": "门厅"})
    eng._execute("江离", jiang, Decision(action_kind="lure", action_arg="唐曼"))
    assert eng._lure_of.get("唐曼") == ("江离", "暗房")          # 诱饵命中目标私罪地点(可达)
    bait = [e for e in tang.attention._inbox if "暗房" in e.content and "只有你我知道" in e.content]
    assert bait, "目标没收到诱饵私语"
    assert jiang.actor.current and jiang.actor.current.dest == "暗房"   # 凶手动身去候着


def test_lure_bait_reaches_only_target_prompt_then_consumed():
    llm = FakeLLM()
    eng = _engine(llm)
    tang = eng.minds["唐曼"]
    tang.goals = [{"desc": "去冰窖找旧医疗箱", "status": "doing", "note": ""}]
    eng._lure_of["唐曼"] = ("江离", "冰窖")
    snap = eng._snapshot("唐曼", tang)
    # 诱饵指向目标本就想去的地点(冰窖在其 goal 里)→ 顺水推舟强拉力分支,仍保留风险提示
    assert "冰窖" in snap.lure_bait and "风险" in snap.lure_bait   # 被诱者收到诱饵提示
    assert "唐曼" not in eng._lure_of                              # 一次性,读后即焚
    snap2 = eng._snapshot("唐曼", tang)
    assert snap2.lure_bait == ""                                   # 不重复提示


def test_lure_only_works_on_present_target():
    eng = _engine()
    jiang = eng.minds["江离"]
    eng.state.positions.update({"江离": "门厅", "邵铭轩": "书房"})
    eng._execute("江离", jiang, Decision(action_kind="lure", action_arg="邵铭轩"))
    assert "邵铭轩" not in eng._lure_of                            # 不在身边引不动
    assert any("引不动" in e.content for e in jiang.attention._inbox)


def test_lure_doc_only_in_killer_prompt():
    llm = FakeLLM()
    eng = _engine(llm)
    jiang, wen = eng.minds["江离"], eng.minds["温言"]
    jiang.goals = wen.goals = [{"desc": "x", "status": "todo", "note": ""}]
    jiang.decide(eng._snapshot("江离", jiang))
    assert "lure:" in llm.calls[-1][0]["content"]                  # 凶手知道有引诱手段
    wen.decide(eng._snapshot("温言", wen))
    assert "lure:" not in llm.calls[-1][0]["content"]              # 平民没有


# ───────────────── 天气能见度(环境影响世界)+ 共性求生目标 ─────────────────

def test_weather_visibility_ordering():
    from genesis.runtime.weather import visibility
    assert visibility("薄雾") == 0.22
    assert visibility("薄雾") < visibility("狂风暴雨") < visibility("暴雨") < visibility("雨歇")


def test_outdoor_corpse_hidden_in_fog_but_indoor_visible():
    eng = _engine()
    eng.state.weather = "薄雾"
    eng.state.kill("程亦深", "断崖")                       # 露天 + 浓雾
    vis = eng._visible_desc("断崖", "温言")
    assert not any("尸体" in l for l in vis)                # 尸体被雾遮住,看不出
    assert any("看不真切" in l or "视线极差" in l for l in vis)
    eng.state.kill("唐曼", "书房")                         # 室内不受天气影响
    assert any("尸体" in l for l in eng._visible_desc("书房", "温言"))


def test_low_visibility_outdoor_kill_can_blind_witnesses():
    import random
    eng = _engine()
    eng.state.weather = "薄雾"                             # vis 0.22:多数目击者看不清
    eng.rng = random.Random(3)
    eng.state.positions.update({"江离": "断崖", "程亦深": "断崖",
                                "邵铭轩": "断崖", "顾长风": "断崖"})
    eng._execute("江离", eng.minds["江离"], Decision(action_kind="kill", action_arg="程亦深"))
    assert "程亦深" in eng.state.dead
    # 没看清的人收到"没认出动手的是谁",或干脆判为无人目击——总之不会三个全看清
    blinded = [e for w in ("邵铭轩", "顾长风") for e in eng.minds[w].attention._inbox
               if "没认出" in e.content]
    kn = next(x for x in eng.narrative if "☠" in x["text"])
    assert blinded or "无人目击" in kn["text"]


def test_indoor_kill_witness_not_blinded_by_weather():
    eng = _engine()
    eng.state.weather = "薄雾"
    eng.state.positions.update({"江离": "琴房", "程亦深": "琴房", "温言": "琴房"})
    eng._execute("江离", eng.minds["江离"], Decision(action_kind="kill", action_arg="程亦深"))
    hits = eng.minds["温言"].memory.retrieve("目睹 杀害", 23.0, top_k=3)
    assert any("亲眼目睹" in h.content for h in hits)        # 室内有灯,雾不影响


def test_survival_goals_in_common_ground():
    from genesis.runtime.bulletin import static_common_ground
    g = static_common_ground(["甲", "乙"], {})
    assert "求生关切" in g and "灯塔点火" in g and "凶手到底是谁" in g


# ───────────────── 目标分层:动机稳定,分目标随局势动态重规划 ─────────────────

def test_replan_replaces_plan_keeps_recent_done():
    eng = _engine()
    wen = eng.minds["温言"]
    wen.goals = [{"desc": "去书房抽剪报册", "status": "done", "note": ""},
                 {"desc": "找邵铭轩谈封口费", "status": "doing", "note": ""},
                 {"desc": "去阁楼搜旧物", "status": "todo", "note": ""}]
    # 停电了,局势剧变 → 重排
    eng._execute("温言", wen, Decision(thought="停电了,计划得变",
                                       replan=["趁黑去暗房冲洗底片", "别落单,先找个伴"]))
    descs = [g["desc"] for g in wen.goals]
    assert "趁黑去暗房冲洗底片" in descs and "别落单,先找个伴" in descs   # 新分目标上位
    assert "去书房抽剪报册" in descs                                      # 已达成的成就保留
    assert "去阁楼搜旧物" not in descs                                    # 未完成的旧计划被替换
    assert all(g["status"] == "todo" for g in wen.goals if g["desc"].startswith("趁黑"))


def test_replan_parsed_from_decision_json():
    m = eng_mind()
    d = m._to_decision('{"thought":"变天了","intent":"改道",'
                       '"goals_replan":["先去码头看船还在不在","再回堡里盯着邵铭轩"]}')
    assert d.replan == ["先去码头看船还在不在", "再回堡里盯着邵铭轩"]
    d2 = m._to_decision('{"thought":"照旧","goals_replan":null}')
    assert d2.replan is None


def test_action_doc_offers_replan_channel():
    from genesis.runtime.cognition import ACTION_DOC
    assert "goals_replan" in ACTION_DOC and "终极目的" in ACTION_DOC


def eng_mind():
    from genesis.runtime.cognition import CognitiveMind
    return CognitiveMind("温言", "card", is_killer=False, home="温言的房间",
                         llm=FakeLLM(), memory=None, static_ground="")


# ───────────────── 造物主事件注入:送达全员 + 高显著度立即打断 ─────────────────

def test_inject_event_reaches_all_and_logs_timeline():
    eng = _engine()
    for a in eng.minds:
        eng.minds[a].actor.idle(600, "原地")          # 给每人一个可被打断的长动作
    n = eng.inject_event("二楼传来一声沉重的巨响!")
    assert n >= 7                                        # 全员收到
    assert any("巨响" in e.content for w in eng.minds for e in eng.minds[w].attention._inbox)
    assert any(x["text"].startswith("🌐") and "巨响" in x["text"] for x in eng.narrative)


def test_inject_event_default_salience_interrupts():
    import inspect
    sig = inspect.signature(WorldEngine.inject_event)
    assert sig.parameters["salience"].default >= 0.8   # 默认过中断阈值,agent 立即重评估


# ───────────────── mem0 长期记忆兜底:单次失败忽略 + 连续失败熔断 ─────────────────

def test_longterm_degrades_and_trips_breaker_on_failures():
    from genesis.runtime.longterm import LongTerm

    class FlakyStore:
        def __init__(self): self.calls = 0
        def add(self, rec): self.calls += 1; raise RuntimeError("429 quota exceeded")
        def search(self, q, aid, limit=20): self.calls += 1; raise RuntimeError("429 quota exceeded")

    lt = LongTerm("g", store=FlakyStore())
    assert lt.enabled
    # 单次失败优雅忽略:不抛异常,deposit 无返回、recall 返回空
    lt.deposit("温言", "一条判断", 1.0)
    assert lt.recall("温言", "查询") == []
    # 连续失败到阈值 → 熔断停用,之后不再碰 store
    for _ in range(5):
        lt.deposit("温言", "再来", 1.0)
    assert lt.enabled is False
    calls_at_trip = lt.store.calls
    lt.deposit("温言", "熔断后", 1.0)        # 已停用 → 直接 return,不再调用 store
    lt.recall("温言", "熔断后")
    assert lt.store.calls == calls_at_trip   # store 不再被调用


def test_longterm_success_resets_failure_counter():
    from genesis.runtime.longterm import LongTerm

    class SometimesStore:
        def __init__(self): self.fail = True
        def add(self, rec):
            if self.fail: raise RuntimeError("blip")
        def search(self, q, aid, limit=20):
            from genesis.memory.store import SearchHit
            from genesis.memory.record import MemoryKind, MemoryRecord
            return [SearchHit(record=MemoryRecord(agent_id=aid, content="旧判断",
                              created_at=0.0, kind=MemoryKind.REFLECTION, importance=0.9), relevance=0.9)]

    s = SometimesStore()
    lt = LongTerm("g", store=s)
    lt.deposit("温言", "x", 1.0); lt.deposit("温言", "x", 1.0)   # 2 次失败(未到阈值 3)
    s.fail = False
    assert lt.recall("温言", "q") == ["旧判断"]                  # 成功一次 → 计数清零
    assert lt.enabled is True
    s.fail = True
    lt.deposit("温言", "x", 1.0); lt.deposit("温言", "x", 1.0)   # 又 2 次,不至熔断(已清零)
    assert lt.enabled is True


def test_dialogue_prompt_carries_pending_goals():
    """会话黑洞修复:L1 对话也注入未完成目标,逼角色聊完去行动(之前 L1 漏了目标)。"""
    llm = FakeLLM('{"thought":"x","speech":"嗯","leave":true}')
    eng = _engine(llm)
    mind = eng.minds["顾长风"]
    mind.goals = [{"desc": "去暗房找母带", "status": "doing", "note": ""},
                  {"desc": "烧掉母带", "status": "todo", "note": ""},
                  {"desc": "已办的事", "status": "done", "note": ""}]
    snap = ThinkInput(now_h=21.0, time_label="第1天 21:00 夜晚", place="门厅", place_desc="",
                      occupants=["江离"], events=[], soma_lines=[], candidates=[],
                      bulletin="", memories=[], intent="",
                      dialogue=["江离:「导演」"], dialogue_with="江离",
                      dialogue_rounds=1, dialogue_budget=8)
    mind._decide_dialogue(snap)
    user = llm.calls[-1][1]["content"]
    assert "去暗房找母带" in user and "烧掉母带" in user      # 未完成目标进对话提醒
    assert "已办的事" not in user                             # 已完成的不提
    assert "leave=true" in user                               # 引导抽身去行动


# ───────────────── 隐藏结局:角色窥见屏幕外的操控者(观众)─────────────────

def test_meta_awakening_triggers_hidden_ending():
    eng = _engine()
    words = ["看不见的手", "操控着我们", "我们是演员", "幕布之外", "屏幕之外", "第四面墙"]
    names = ["顾长风", "邵铭轩", "温言", "唐曼", "白聿", "程亦深"]   # 七天版阈值=6/7
    for i, (nm, w) in enumerate(zip(names, words)):
        eng._execute(nm, eng.minds[nm], Decision(thought=f"难道{w}在看着我们?"))
        if i < 5:
            assert eng.ending is None                       # 不足阈值(6),还没触发
            assert nm in eng._meta_awakened
    assert eng.ending is not None and "窥见" in eng.ending["kind"]   # 第6人 → 隐藏结局
    assert "屏幕" in eng.ending["text"] or "幕布之外" in eng.ending["text"]


def test_meta_awakening_ignores_normal_talk():
    eng = _engine()
    for nm in ("顾长风", "邵铭轩", "温言", "唐曼", "白聿"):
        eng._execute(nm, eng.minds[nm], Decision(thought="刀架少了一把刀,凶手就在我们中间", speech="大家冷静"))
    assert not eng._meta_awakened and eng.ending is None    # 普通推理不会误触发第四面墙
