"""Stand-ins for the parts of the app a test must not really call. Import these rather than writing another copy. Nine test modules each carried their own `ScriptedProvider`, and the copies had drifted into four different feature sets, so a test that needed to raise a provider error had to be written in one of the files whose copy supported it. """ class ScriptedProvider: """Streams canned replies in place of `OpenAICompatibleProvider`. Set `replies` to the texts the model returns, one per call. The last entry repeats once the list runs out, so a test that plays more turns than it scripted still gets text. To drive the provider-error path, put an `Exception` in the list. It is raised rather than streamed. State lives on the class, not on the instance, because the turn engine constructs the provider itself and a test never sees the object. The autouse `reset_scripted_provider` fixture in `conftest.py` clears it between tests. `prompts` records every assembled `(system, story)` pair, which is what a test asserts on to check what the model was shown. """ last_usage = None replies: list = [] calls = 0 prompts: list = [] def __init__(self, *a, **k): pass async def generate(self, parts, *, temperature, max_tokens): index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1) ScriptedProvider.calls += 1 ScriptedProvider.prompts.append((parts.system, parts.story)) reply = ScriptedProvider.replies[index] if isinstance(reply, Exception): raise reply yield ("text", reply) # --------------------------------------------------------------------------- # Deterministic per-turn state instrumentation # --------------------------------------------------------------------------- # Several tests need a value that changes by a fixed amount on every turn, so # that a rollback failure is arithmetic rather than a judgement call: if a take # stacks instead of replacing, the total is off by exactly one turn's worth. # # That instrumentation used to be a JavaScript `output` hook doing # `state.gold += 10` in the QuickJS sandbox. M2 removed campaign scripting, and # the tests below are not about scripting — they are about the state snapshot, # rollback, and branch-isolation machinery in `attempts.py` and `tree.py`, # which is unchanged. # # The counter therefore moved to the world-state engine, which is a real # remaining product path: the model emits a ```state delta block, the referee # applies it, and the result lands in `adventure.world_state`. The fake # provider decides what the model "emits", so it is exactly as deterministic as # the script was, and it exercises production code rather than a test hook. #: A schema with a plain unbounded counter. No `max_delta_per_turn` and no #: `cooldown`, so every +10 is applied in full, every turn. GOLD_SCHEMA = { "player": { "hp": {"min": 0, "max": 100, "initial": 100}, "gold": {"min": 0, "max": 1_000_000, "initial": 0}, } } GOLD_PER_TURN = 10 def gold_reply(text: str, amount: int = GOLD_PER_TURN) -> str: """A model reply that narrates `text` and banks `amount` gold.""" return f'{text}\n```state\n{{"player.gold": {amount}}}\n```' def gold_replies(prefix: str = "Take", count: int = 40) -> list[str]: """`count` numbered replies, each banking one turn's worth of gold.""" return [gold_reply(f"{prefix} {n}.") for n in range(1, count + 1)]