diff --git a/.gitignore b/.gitignore index c18dd8d..097d7fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ __pycache__/ +.worktrees/ +agentbench_data/ diff --git a/docs/superpowers/plans/2026-08-02-miracle-openai-harness-loop.md b/docs/superpowers/plans/2026-08-02-miracle-openai-harness-loop.md new file mode 100644 index 0000000..ad789b8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-miracle-openai-harness-loop.md @@ -0,0 +1,469 @@ +# Miracle OpenAI Harness Loop Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add one `miracle loop --config PATH` command that calls an OpenAI-compatible Chat Completions API for complete strategy source, runs an official-logic baseline and evolved evaluation, saves every intermediate artifact and budget event, computes aligned score and strict-KL-status curves, and emits an AgentBenchResults-compatible Run. + +**Architecture:** Keep the existing `match`, `replay`, `decision_space`, and `ig` modules as low-level primitives. Add focused modules for configuration, Chat Completions transport, source snapshots, Run storage/metrics, and orchestration; the loop depends on interfaces rather than API details so a later Tool Calling transport only replaces `propose_strategy()`. Use only the Python standard library and the existing project code. + +**Tech Stack:** Python 3.11, `urllib.request`, `tomllib`, `json`, `importlib`, existing official Miracle logic, pytest. + +## Global Constraints + +- The initial transport is OpenAI-compatible `POST {base_url}/v1/chat/completions`; Tool Calling is excluded. +- The model returns one JSON object with `analysis` and complete `strategy_code` defining `CandidateAgent`. +- API secrets are read through `api_key_env` and never persisted in requests, events, summaries, or errors. +- Historical strategies are immutable files under the Run; do not add versioned classes to `AGENTS`. +- Preserve all failed, missing, incomplete, timed-out, and regressed iterations. +- Deterministic IG uses only `unchanged`, `infinite`, and `missing`; never label a proxy as finite KL. +- Existing low-level commands remain `match`, `replay`, and `ig`; add exactly one high-level command, `loop`. +- Writes that make a result look complete use a temporary sibling file followed by `Path.replace()`. +- Do not add runtime dependencies. + +--- + +## File Structure + +- Create `src/agentbench_frame/miracle/loop_config.py`: validated TOML configuration and budget definitions. +- Create `src/agentbench_frame/miracle/llm_client.py`: Chat Completions request/response transport and proposal parsing. +- Create `src/agentbench_frame/miracle/strategy_loader.py`: immutable source snapshots and `CandidateAgent` loading. +- Create `src/agentbench_frame/miracle/run_store.py`: Run directories, append-only events, atomic JSON/TOML writes, budget counters. +- Create `src/agentbench_frame/miracle/score.py`: aligned episode scoring, gain, and trapezoidal AUC. +- Create `src/agentbench_frame/miracle/loop.py`: orchestration only; dependencies are injectable for tests. +- Modify `src/agentbench_frame/miracle/cli.py`: add the single `loop` subcommand. +- Modify `skills/miracle-harness/SKILL.md`: document configuration, loop execution, artifacts, and failure semantics. +- Create tests mirroring each new module plus one official-logic end-to-end test. + +--- + +### Task 1: Validated Loop Configuration + +**Files:** +- Create: `src/agentbench_frame/miracle/loop_config.py` +- Create: `tests/miracle/test_loop_config.py` + +**Interfaces:** +- Produces: `LoopConfig.from_toml(path: Path) -> LoopConfig` +- Produces: `LoopConfig.public_dict() -> dict`, excluding any resolved API secret +- Produces dataclasses `LLMConfig`, `EvaluationConfig`, and `BudgetConfig` + +- [ ] **Step 1: Write failing configuration tests** + +```python +def test_loads_minimal_loop_config(tmp_path): + path = tmp_path / "loop.toml" + path.write_text(''' +agent = "tested_llm" +initial_strategy = "initial.py" +opponent = "sample" + +[llm] +base_url = "http://127.0.0.1:8123" +api_key_env = "TEST_LLM_KEY" +model = "mock-model" + +[evaluation] +seeds = [11] +seats = [0] + +[budget] +max_iterations = 1 +max_rollouts = 4 +max_episode_reads = 1 +max_decision_reads = 200 +max_total_tokens = 10000 +max_wall_seconds = 600 +''') + cfg = LoopConfig.from_toml(path) + assert cfg.agent == "tested_llm" + assert cfg.evaluation.seeds == (11,) + assert cfg.budget.max_iterations == 1 + +def test_rejects_unknown_opponent_and_nonpositive_budget(tmp_path): + path = write_config(tmp_path, opponent="not_registered", max_iterations=0) + with pytest.raises(ValueError, match="opponent|max_iterations"): + LoopConfig.from_toml(path) +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_loop_config.py -q` + +Expected: collection fails because `loop_config` does not exist. + +- [ ] **Step 3: Implement frozen dataclasses and strict TOML parsing** + +Implement exact defaults: `temperature=0.0`, `max_tokens=8192`, `timeout_seconds=120`, `seeds=(11,)`, `seats=(0, 1)`. Resolve `initial_strategy` relative to the config file. Reject empty agent/model, unknown `AGENTS` opponent, seats outside `{0,1}`, empty seeds/seats, and nonpositive budget values. `public_dict()` contains the configured environment-variable name, never its value. + +- [ ] **Step 4: Run configuration tests and full retained tests** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_loop_config.py tests/miracle/test_cli.py -q` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/agentbench_frame/miracle/loop_config.py tests/miracle/test_loop_config.py +git commit -m "feat(miracle): add validated loop configuration" +``` + +--- + +### Task 2: OpenAI-Compatible Complete-Source Client + +**Files:** +- Create: `src/agentbench_frame/miracle/llm_client.py` +- Create: `tests/miracle/test_llm_client.py` + +**Interfaces:** +- Consumes: `LLMConfig` +- Produces: `StrategyProposal(analysis: str, strategy_code: str, usage: dict, request_body: dict, raw_response: dict, latency_seconds: float, normalized_fence: bool)` +- Produces: `ChatCompletionsClient.propose_strategy(messages: list[dict]) -> StrategyProposal` +- Raises: `LLMRequestError(stage: str, reason: str, raw_response: dict | str | None = None)` with stages `request`, `response_json`, `assistant_content`, and `proposal_json`; this carries a sanitized response for failure preservation + +- [ ] **Step 1: Write a local HTTP-server contract test** + +Use `http.server.ThreadingHTTPServer` in a pytest fixture. Capture headers/body and return: + +```json +{ + "choices": [{"message": {"content": "{\"analysis\":\"improve\",\"strategy_code\":\"class CandidateAgent: pass\"}"}}], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30} +} +``` + +Assert the request path is `/v1/chat/completions`, `model`, `messages`, `temperature`, and `max_tokens` are present, the bearer token comes from the configured environment variable, and the returned proposal preserves usage. Add tests for a single fenced JSON fallback and malformed content raising `LLMRequestError(stage="proposal_json", ...)`. + +- [ ] **Step 2: Run tests and verify RED** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_llm_client.py -q` + +Expected: import failure. + +- [ ] **Step 3: Implement the standard-library client** + +Join `base_url.rstrip("/") + "/v1/chat/completions"`; send UTF-8 JSON with `Content-Type: application/json` and optional `Authorization: Bearer ...`. Parse `choices[0].message.content`. Accept only a JSON object whose `analysis` and `strategy_code` are nonempty strings. Convert missing usage numbers to zero. Error text may contain HTTP status and response excerpt but never request headers or the API key. + +- [ ] **Step 4: Run client tests** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_llm_client.py -q` + +Expected: PASS, including secret-redaction assertion. + +- [ ] **Step 5: Commit** + +```bash +git add src/agentbench_frame/miracle/llm_client.py tests/miracle/test_llm_client.py +git commit -m "feat(miracle): add Chat Completions strategy client" +``` + +--- + +### Task 3: Immutable Strategy Snapshots and Validation + +**Files:** +- Create: `src/agentbench_frame/miracle/strategy_loader.py` +- Create: `tests/miracle/test_strategy_loader.py` + +**Interfaces:** +- Produces: `save_source(path: Path, source: str) -> None` +- Produces: `load_candidate(path: Path, module_key: str) -> MiracleAgent` +- Produces: `validate_candidate(agent: MiracleAgent) -> None` +- Raises: `StrategyValidationError(stage: str, reason: str)` with stages `compile`, `import`, `class`, `instantiate`, `choose_cards`, and `action_shape` + +- [ ] **Step 1: Write tests for valid, invalid, and immutable sources** + +Create a valid source importing `MiracleAgent` and defining `CandidateAgent`. Assert two snapshot paths load as separate module keys. Assert syntax errors report `compile`, missing class reports `class`, and an `act()` response without `operation_type`/`operation_parameters` reports `action_shape`. Assert `save_source()` refuses to overwrite an existing snapshot with different content. + +- [ ] **Step 2: Run tests and verify RED** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_strategy_loader.py -q` + +Expected: import failure. + +- [ ] **Step 3: Implement compile/import/interface validation** + +Compile source before `importlib.util.spec_from_file_location`. Require `issubclass(CandidateAgent, MiracleAgent)`. Instantiate with no arguments. Check `choose_cards(0)` has one artifact and three creatures. Use a minimal observation to verify only action shape; official logic remains the legality authority. Do not register the class in `AGENTS` or retain it in a version registry. + +- [ ] **Step 4: Run loader tests** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_strategy_loader.py -q` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/agentbench_frame/miracle/strategy_loader.py tests/miracle/test_strategy_loader.py +git commit -m "feat(miracle): validate immutable strategy snapshots" +``` + +--- + +### Task 4: Run Store, Events, and Budget Accounting + +**Files:** +- Create: `src/agentbench_frame/miracle/run_store.py` +- Create: `tests/miracle/test_run_store.py` + +**Interfaces:** +- Produces: `MiracleRunStore.create(config: LoopConfig, data_dir: Path | None = None, run_id: str | None = None) -> MiracleRunStore` +- Produces: `iteration_dir(index: int) -> Path`, `write_event(event: str, **fields)`, `write_json_atomic(path: Path, value: dict)`, `finish(summary: dict)` +- Produces: `BudgetLedger.charge_rollout()`, `charge_read(episodes: int, decisions: int)`, `charge_usage(usage: dict)`, `charge_api_time(seconds: float)`, `charge_battle_time(seconds: float)`, `check() -> None`, and `snapshot() -> dict` +- Raises: `BudgetExceeded(dimension: str)` before work that would exceed a configured limit + +- [ ] **Step 1: Write storage and budget tests** + +Assert creation at `runs/24_miracle/{agent}/{run_id}`; copied Skills; append-only JSONL events; iteration directories `iteration-0000`; `run.toml` has `[run] type="rule_iter"`; and `summary.json` appears only on `finish()`. Charge tokens and rollouts to the exact limit, then assert the next charge raises with the correct dimension. Ensure the serialized ledger includes episode reads, decision reads, prompt/completion/total tokens, API seconds, battle seconds, rollouts, and wall seconds. + +- [ ] **Step 2: Run tests and verify RED** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_run_store.py -q` + +Expected: import failure. + +- [ ] **Step 3: Implement atomic storage and accounting** + +Use `$AGENTBENCH_DATA` only when `data_dir` is absent. Copy the two formal Skills into `run_dir/skills/`. Write `events.jsonl` with one flush per event. Implement atomic JSON/TOML via a named sibling ending `.tmp`, flush, then `replace`. `BudgetLedger.check()` compares elapsed monotonic time and all cumulative counters. + +- [ ] **Step 4: Run store tests** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_run_store.py -q` + +Expected: PASS and no `.tmp` files remain. + +- [ ] **Step 5: Commit** + +```bash +git add src/agentbench_frame/miracle/run_store.py tests/miracle/test_run_store.py +git commit -m "feat(miracle): add observable Run storage and budgets" +``` + +--- + +### Task 5: Score, Gain, and AUC Metrics + +**Files:** +- Create: `src/agentbench_frame/miracle/score.py` +- Create: `tests/miracle/test_score.py` + +**Interfaces:** +- Produces: `aggregate_score(episodes: list[dict], candidate_camp_by_episode: dict[str, int]) -> dict` +- Produces: `build_score_curve(iterations: list[dict]) -> dict` +- Produces: `trapezoid_auc(points: list[dict], x_key: str, y_key: str) -> dict` + +- [ ] **Step 1: Write exact metric tests** + +For two normal episodes where the candidate is camp 0 once and camp 1 once, assert candidate official scores are selected by seat, mean score and win rate are correct, and incomplete episodes stay in `episodes` but do not enter the measured mean. For iterations with raw `10` and evo values `10, 20, 15`, assert gains `0, 10, 5`. For points `(x,y)=(0,10),(2,20),(5,10)`, assert trapezoidal AUC is `75`. For fewer than two measured points, assert `value is None` and reason `insufficient_points`. + +- [ ] **Step 2: Run tests and verify RED** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_score.py -q` + +Expected: import failure. + +- [ ] **Step 3: Implement explicit aggregation** + +Use only episodes with `terminated_by == "normal"` and empty `errors` for measured score/win rate. Preserve counts for normal, timeout, failed, and missing. Each score-curve point includes iteration, version hash, update status, raw, evo, gain, win rate, completion rate, rollouts, cumulative tokens, episode reads, decision reads, and wall seconds. Compute AUC separately for iteration, rollout, total-token, episode-read, and wall-time axes. + +- [ ] **Step 4: Run metric tests** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_score.py -q` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/agentbench_frame/miracle/score.py tests/miracle/test_score.py +git commit -m "feat(miracle): add aligned score and budget AUC metrics" +``` + +--- + +### Task 6: Harness Context and Iteration Orchestrator + +**Files:** +- Create: `src/agentbench_frame/miracle/loop.py` +- Create: `tests/miracle/test_loop.py` +- Modify: `src/agentbench_frame/miracle/ig.py` + +**Interfaces:** +- Produces: `build_messages(current_source: str, skills: dict[str, str], evidence: list[dict], previous_metrics: dict, budget: dict) -> list[dict]` +- Produces: `run_loop(config: LoopConfig, *, client=None, match_runner=run_match, data_dir=None) -> Path` +- Consumes: `client.propose_strategy(messages) -> StrategyProposal`, snapshot loader, `run_match`, `compare_agents_on_trace`, score functions, and Run store +- Modify IG to accept agents loaded from source snapshots without `AGENTS` + +- [ ] **Step 1: Write orchestration tests with fake dependencies** + +Use a fake match runner that writes a minimal trace and returns `MatchResult`; use a fake client returning a stronger complete source and usage `{prompt_tokens: 12, completion_tokens: 8, total_tokens: 20}`. Assert: + +```python +run_dir = run_loop(config, client=fake_client, match_runner=fake_match, data_dir=tmp_path) +assert (run_dir / "iterations/iteration-0000/strategy.py").exists() +assert (run_dir / "iterations/iteration-0001/candidate.py").exists() +assert (run_dir / "score_curve.json").exists() +assert (run_dir / "ig_curve.json").exists() +assert json.loads((run_dir / "summary.json").read_text())["total_tokens"] == 20 +``` + +Also test malformed proposal: iteration 1 has `status="failed"`, `failure_stage="proposal_json"`, raw response is retained, accepted strategy hash equals iteration 0, and the Run still finishes. Test regression: valid weaker candidate is accepted and recorded with negative gain, not discarded. + +- [ ] **Step 2: Run tests and verify RED** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_loop.py -q` + +Expected: import failure. + +- [ ] **Step 3: Implement deterministic evidence selection and messages** + +Select episodes in saved evaluation order up to `max_episode_reads`; extract observation/action summaries in trace sequence up to `max_decision_reads`. Charge reads for content actually included. System text states the exact response JSON and `CandidateAgent` contract. Preserve full selected evidence in `llm_request.json`; do not insert API keys or unselected traces. + +- [ ] **Step 4: Implement baseline and update orchestration** + +Evaluate iteration 0, then for each `1..max_iterations`: check budget, build context, call client, save raw proposal, validate candidate, evaluate configured `(seed, seat)` pairs, compute IG using aligned candidate observations, write `iteration.json`, and update curves. Instantiate a fresh strategy agent for every episode and a fresh old/new pair for every IG trace so stateful agents do not leak across episodes. + +- [ ] **Step 5: Implement final summary and failure preservation** + +Summary includes `raw`, `final_evo`, `final_gain`, `best_evo`, `best_iteration`, total iterations/episodes/steps, completion and win rates, budget totals, AUC dictionary, score history, IG history, and failure counts. A failed iteration emits `iteration_failed` and does not increment accepted-version identity; it still consumes API/time/token budgets already spent. + +- [ ] **Step 6: Run loop and related tests** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_loop.py tests/miracle/test_ig.py tests/miracle/test_score.py -q` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/agentbench_frame/miracle/loop.py src/agentbench_frame/miracle/ig.py tests/miracle/test_loop.py +git commit -m "feat(miracle): orchestrate saved LLM strategy iterations" +``` + +--- + +### Task 7: Single High-Level CLI and Skill Instructions + +**Files:** +- Modify: `src/agentbench_frame/miracle/cli.py` +- Modify: `tests/miracle/test_cli.py` +- Modify: `skills/miracle-harness/SKILL.md` +- Create: `examples/miracle-loop.toml` +- Create: `examples/miracle-initial-strategy.py` + +**Interfaces:** +- Produces CLI: `uv run python -m agentbench_frame.miracle loop --config PATH [--data-dir PATH]` +- Retains CLI: `match`, `replay`, `ig` + +- [ ] **Step 1: Update CLI tests first** + +Change the expected command set to exactly `{"match", "replay", "ig", "loop"}`. Assert `loop --config x.toml --data-dir results` parses two paths and delegates once to `run_loop`; stdout must be JSON containing only `run_dir`, `run_id`, `status`, `score_curve`, and `ig_curve`. + +- [ ] **Step 2: Run CLI test and verify RED** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_cli.py -q` + +Expected: failure because `loop` is absent. + +- [ ] **Step 3: Implement the CLI entry and examples** + +Add `_cmd_loop`; load `LoopConfig`, create `ChatCompletionsClient`, call `run_loop`, read its final summary, and print stable JSON. The example initial source defines an end-round `CandidateAgent`. The TOML uses `api_key_env="OPENAI_API_KEY"`, `base_url="https://api.openai.com"`, one seed, both seats, and one iteration; it contains no key. + +- [ ] **Step 4: Update the Harness Skill** + +Document the exact loop command, config fields, response contract, Run/iteration/episode/round terminology, output tree, budget meanings, strict-KL-status interpretation, failure semantics, and `AGENTBENCH_DATA=/path/to/AgentBenchResults`. State that complete-source mode is current and Tool Calling is not yet enabled. + +- [ ] **Step 5: Run CLI and Skill checks** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_cli.py -q` + +Run: `rg -n "miracle loop|CandidateAgent|AGENTBENCH_DATA|missing_ratio" skills/miracle-harness/SKILL.md` + +Expected: tests pass and all four required terms are found. + +- [ ] **Step 6: Commit** + +```bash +git add src/agentbench_frame/miracle/cli.py tests/miracle/test_cli.py skills/miracle-harness/SKILL.md examples/miracle-loop.toml examples/miracle-initial-strategy.py +git commit -m "feat(miracle): expose one saved harness loop command" +``` + +--- + +### Task 8: Official-Logic End-to-End Acceptance and Results Contract + +**Files:** +- Create: `tests/miracle/test_loop_e2e.py` +- Modify: `AgentBenchResults/scripts/aggregate.py` only if it is intentionally in the same checked-out workspace and the existing renderer requires explicit Miracle curve discovery; otherwise record the Results change as a separate repository commit. + +**Interfaces:** +- Verifies the complete public CLI and on-disk contract + +- [ ] **Step 1: Write an end-to-end test with a mock Chat Completions server** + +Start a local HTTP server returning a complete deterministic `CandidateAgent` source that improves over the initial end-round policy against `endround` by summoning/moving/attacking. Invoke `main(["loop", "--config", ..., "--data-dir", ...])` without replacing `run_match`, so official logic creates real `.mrc` and `.trace.jsonl` files. Use one seed and one seat to bound runtime. + +Assert: + +- iteration 0 and 1 strategy snapshots differ; +- both iterations contain normal episode results and nonempty official replay/trace files; +- `score_curve.json` contains aligned versions and iteration 1 has measured evo/gain; +- `ig_curve.json` contains iteration 0 baseline and iteration 1 measured strict status; +- `events.jsonl` contains API, validation, battle, IG, and finish events; +- `run.toml` and `summary.json` pass `agentbench data check` requirements; +- saved request/response/log files do not contain the test API key. + +- [ ] **Step 2: Run the acceptance test and verify RED** + +Run: `uv run --with pytest python -m pytest tests/miracle/test_loop_e2e.py -q` + +Expected: fail at the first missing/inconsistent public artifact. + +- [ ] **Step 3: Fix only integration defects revealed by the test** + +Keep fixes in their owning modules. Do not add a second loop/export/version interface. If AgentBenchResults currently ignores additional curve files, retain its required `run.toml`/`summary.json` compatibility and add curve links in its game view as a separately tested Results repository change. + +- [ ] **Step 4: Run complete verification** + +Run: `uv run --with pytest python -m pytest tests/miracle -q` + +Run: `uv run python -m agentbench_frame.cli data check --data-dir ` + +Run: `git diff --check` + +Expected: all Miracle tests pass; data check reports the generated Run valid; diff check is silent. + +- [ ] **Step 5: Clean generated artifacts** + +Delete only test-created local `agentbench_data`, `.pytest_cache`, Miracle `__pycache__`, and generated lock files that were absent before the run. Do not touch `.worktrees`, `.venv`, unrelated caches, or user data. + +- [ ] **Step 6: Commit** + +```bash +git add src/agentbench_frame/miracle tests/miracle skills/miracle-harness/SKILL.md examples +git commit -m "test(miracle): verify OpenAI harness loop end to end" +``` + +If AgentBenchResults changed, commit it separately inside that repository: + +```bash +git add scripts templates +git commit -m "feat(results): display Miracle score and IG curves" +``` + +--- + +## Final Review Checklist + +- [ ] One command executes baseline, one LLM update, reevaluation, strict IG, score curve, and result export. +- [ ] The initial and generated strategies are immutable source snapshots, not `AGENTS` history entries. +- [ ] Every API request/response and usage record is saved without secrets. +- [ ] Episode/decision reads, rollouts, tokens, API/battle/total time are cumulatively recorded. +- [ ] Invalid and regressed updates remain visible. +- [ ] `raw`, `evo`, `gain`, win/completion rate, and AUC axes are unambiguous. +- [ ] `finite_kl_mean` is never fabricated. +- [ ] Real official `.mrc` and trace files exist for accepted evaluation episodes. +- [ ] `run.toml` and `summary.json` are accepted by the existing Results data checker. +- [ ] The formal Skills are copied into the Run for provenance. +- [ ] No Tool Calling, extra version registry, duplicate loop command, or new runtime dependency was added. diff --git a/docs/superpowers/plans/2026-08-02-miracle-streaming-chat-completions.md b/docs/superpowers/plans/2026-08-02-miracle-streaming-chat-completions.md new file mode 100644 index 0000000..1b29ab3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-miracle-streaming-chat-completions.md @@ -0,0 +1,89 @@ +# Miracle Streaming Chat Completions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make SSE streaming the default Chat Completions transport, remove the default completion-length limit, and account against a configurable one-million-token context budget before rerunning three real iterations. + +**Architecture:** Extend `LLMConfig` with default streaming, optional `max_tokens`, and `max_context_tokens`. Split the client response path into JSON and SSE readers that normalize into the existing proposal parser, keeping the Loop and Results schemas stable except for additional stream telemetry. + +**Tech Stack:** Python 3.11 standard library `urllib.request`, JSON, OpenAI-compatible SSE, pytest. + +## Global Constraints + +- `stream` defaults to `true`; explicit `false` retains non-streaming compatibility. +- Do not retry streaming failures as non-streaming. +- `max_tokens` defaults to absent and is sent only when configured. +- `max_context_tokens` defaults to `1_000_000`, is not sent to the API, and uses authoritative returned usage. +- Do not estimate tokens locally or add a tokenizer dependency. +- Preserve partial stream content, usage, timings, and failure reason. + +--- + +### Task 1: Streaming and Context Configuration + +**Files:** +- Modify: `src/agentbench_frame/miracle/loop_config.py` +- Modify: `tests/miracle/test_loop_config.py` +- Modify: `examples/miracle-loop.toml` + +**Interfaces:** +- `LLMConfig.stream: bool = True` +- `LLMConfig.max_tokens: int | None = None` +- `LLMConfig.max_context_tokens: int = 1_000_000` + +- [ ] Write tests asserting defaults, explicit `stream=false`, optional positive `max_tokens`, and rejection of nonpositive context limits. +- [ ] Run `uv run --with pytest python -m pytest tests/miracle/test_loop_config.py -q` and verify RED. +- [ ] Implement strict TOML parsing and public serialization. +- [ ] Rerun the configuration tests and verify PASS. +- [ ] Commit with `feat(miracle): configure default streaming context budget`. + +### Task 2: SSE Reader and Normalized Response + +**Files:** +- Modify: `src/agentbench_frame/miracle/llm_client.py` +- Modify: `tests/miracle/test_llm_client.py` + +**Interfaces:** +- `_read_stream(response, started: float) -> tuple[dict, float]` +- Normalized response adds `stream`, `chunk_count`, `first_chunk_seconds`, and `usage_missing` +- New error stages: `stream_chunk_json`, `stream_incomplete` + +- [ ] Add a local SSE fixture emitting heartbeat lines, split reasoning/content deltas, final usage, and `[DONE]`; assert reconstructed content and telemetry. +- [ ] Add tests for malformed JSON and EOF without `[DONE]`, including preserved partial response and usage. +- [ ] Add an explicit `stream=false` test asserting the JSON path still works and `max_tokens` is omitted by default. +- [ ] Run `uv run --with pytest python -m pytest tests/miracle/test_llm_client.py -q` and verify RED. +- [ ] Implement line-oriented SSE parsing, normalized response construction, and shared proposal parsing. +- [ ] Rerun client tests and verify PASS. +- [ ] Commit with `feat(miracle): stream Chat Completions by default`. + +### Task 3: Context Budget and Stream Telemetry in Loop + +**Files:** +- Modify: `src/agentbench_frame/miracle/loop.py` +- Modify: `src/agentbench_frame/miracle/run_store.py` +- Modify: `tests/miracle/test_loop.py` +- Modify: `skills/miracle-harness/SKILL.md` + +**Interfaces:** +- `BudgetLedger.charge_context(total_tokens: int, limit: int) -> None` +- Saved LLM response contains stream telemetry and real usage on success or failure + +- [ ] Add tests that successful streaming usage is charged, proposal failures retain usage, and reported usage above `max_context_tokens` creates a preserved `context_tokens` failure. +- [ ] Run Loop tests and verify RED. +- [ ] Implement context checking after charging API usage; save telemetry without API secrets. +- [ ] Document default streaming, optional non-streaming, omitted `max_tokens`, and the 1M context budget. +- [ ] Run Loop, store, and client tests and verify PASS. +- [ ] Commit with `feat(miracle): account streaming context usage`. + +### Task 4: Real API Verification and Three-Iteration Run + +**Files:** +- Modify only temporary `/tmp` configuration for the live run. +- Write Run artifacts beneath `AgentBenchResults/runs/24_miracle/temporary_deepseek_v4_flash/`. + +- [ ] Run one minimal streaming request and verify multiple SSE chunks, `[DONE]`, content, usage, and no 60-second idle 504. +- [ ] Run the complete command with `stream=true`, no `max_tokens`, `max_context_tokens=1000000`, and three iterations. +- [ ] Inspect all iteration statuses, replay/trace files, score/IG curves, token/time accounting, and secret absence. +- [ ] Run `agentbench data check` against AgentBenchResults. +- [ ] Run `uv run --with pytest python -m pytest tests/miracle -q` and `git diff --check`. +- [ ] Remove generated Framework caches and commit code changes; do not delete failed Run records. diff --git a/docs/superpowers/specs/2026-08-02-miracle-chat-completions-streaming-design.md b/docs/superpowers/specs/2026-08-02-miracle-chat-completions-streaming-design.md new file mode 100644 index 0000000..12389c8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-miracle-chat-completions-streaming-design.md @@ -0,0 +1,93 @@ +# Miracle Chat Completions Streaming Design + +## Goal + +Make OpenAI-compatible Chat Completions streaming the default Miracle Harness transport so upstream proxies receive continuous response bytes during long reasoning/generation and do not terminate an otherwise active request at an idle 60-second boundary. + +## Configuration + +`LLMConfig` adds: + +```toml +[llm] +stream = true +max_context_tokens = 1000000 +``` + +The default is `true`. Set `stream = false` only for endpoints that do not support SSE streaming. The client never silently retries a failed streaming request as non-streaming because that could issue two billable requests for one iteration. + +`max_tokens` becomes optional and defaults to absent. When absent, the Harness does not send a generation-length limit. `max_context_tokens` defaults to `1_000_000` and limits reported prompt plus completion usage for accounting. Because tokenizer behavior is model-specific and the project has no tokenizer dependency, the Harness does not claim an exact pre-request token count; input size remains controlled by episode/decision-read budgets, and actual API usage is authoritative. + +## Request + +Streaming requests add: + +```json +{ + "stream": true, + "stream_options": {"include_usage": true} +} +``` + +All existing fields, including `reasoning_effort`, remain unchanged. Non-streaming requests add `"stream": false` and omit `stream_options`. + +The request includes `max_tokens` only when the user explicitly configures it. It never sends `max_context_tokens`, which is a Harness budget rather than an OpenAI request field. + +## SSE Parsing + +The client reads UTF-8 Server-Sent Events line by line. It ignores blank lines and comment/heartbeat lines beginning with `:`. Every `data:` payload must be either `[DONE]` or one JSON object. + +For each chunk it: + +- appends `choices[0].delta.reasoning_content` when present; +- appends `choices[0].delta.content` when present; +- preserves the latest non-null `finish_reason`; +- preserves response `id`, `object`, `created`, `model`, and `system_fingerprint` when present; +- reads usage from any chunk containing `usage`, with the last value authoritative; +- counts parsed chunks and records first-chunk latency. + +At `[DONE]`, the accumulated stream is normalized into the same Chat Completions response shape consumed by the existing proposal parser: + +```json +{ + "choices": [{ + "message": { + "role": "assistant", + "content": "...", + "reasoning_content": "..." + }, + "finish_reason": "stop" + }], + "usage": {} +} +``` + +The stored response additionally contains `stream=true`, `chunk_count`, `first_chunk_seconds`, and `usage_missing`. + +## Error and Budget Semantics + +- HTTP, connection, or idle socket failures remain `request` failures. +- Invalid SSE JSON is `stream_chunk_json` and preserves the accumulated response. +- EOF without `[DONE]` is `stream_incomplete`, preserving accumulated content, usage, and timing. +- A complete stream whose assistant content is empty or invalid remains `assistant_content` or `proposal_json` as today. +- Usage is charged even when later proposal parsing or strategy validation fails. +- If the endpoint omits final usage, token counters remain zero and `usage_missing=true`; the Harness does not estimate tokens and does not invent accounting data. +- When an endpoint or explicit `max_tokens` truncates output, `finish_reason="length"` remains visible; streaming prevents idle gateway timeout but does not remove provider-side output limits. +- Reported `total_tokens > max_context_tokens` is preserved as a context-budget failure after charging the real usage. + +## Compatibility Boundary + +Only `ChatCompletionsClient` and `LLMConfig` change. The Loop continues to receive one `StrategyProposal`, and strategy snapshots, battles, replay parsing, IG, score curves, budgets, and AgentBenchResults schemas remain unchanged. + +## Verification + +Tests use a local HTTP SSE server and cover: + +1. split reasoning and content deltas; +2. final usage and `[DONE]`; +3. heartbeat/comment lines; +4. malformed chunk JSON; +5. EOF before `[DONE]`; +6. explicit `stream=false` compatibility; +7. secret redaction and failed-response usage accounting; +8. a minimal real request to the temporary API followed by a full three-iteration Run. diff --git a/docs/superpowers/specs/2026-08-02-miracle-openai-harness-loop-design.md b/docs/superpowers/specs/2026-08-02-miracle-openai-harness-loop-design.md new file mode 100644 index 0000000..40a20c9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-miracle-openai-harness-loop-design.md @@ -0,0 +1,217 @@ +# Miracle OpenAI-Compatible Harness Loop Design + +## 1. Goal + +Build the smallest reproducible high-level iteration loop for the 24th Miracle game. A tested LLM receives the current strategy, the game-specific Skills, and selected replay evidence through an OpenAI-compatible `POST /v1/chat/completions` API. It returns a complete replacement strategy. The Harness owns validation, versioning, battles, replay storage, strict KL status calculation, score aggregation, budgets, and result export. + +The first implementation deliberately excludes Tool Calling. Once the complete-source loop is verified end to end, the LLM transport can be extended with tools without changing battle, replay, metrics, or result schemas. + +## 2. Scope + +The implementation must complete the original five requirements: + +1. Run one traceable iteration from official game logic through battle, replay, LLM strategy modification, version save, and reevaluation. +2. Supply and validate the human-authored, Agent-polished Miracle Harness and replay-reading Skills. +3. Define the Miracle observation, parameterized macro-actions, action mask, termination conditions, and finite action support used by IG. +4. Save strict KL status data per decision, episode, and iteration, including explicit missing reasons. +5. Produce version-aligned score–iteration and IG–iteration data with at least one valid strategy update; preserve failures and incomplete evaluations. + +The first implementation will also write the accounting fields needed later for budget curves and leaderboards. It will not run the population, environment, reward, or policy ablation studies yet. + +## 3. LLM Boundary + +### Request + +The Harness calls an OpenAI-compatible endpoint: + +```text +POST {base_url}/v1/chat/completions +``` + +Configuration contains: + +- `base_url` +- `api_key_env`, naming the environment variable that holds the secret +- `model` +- `temperature` +- `max_tokens` +- request timeout + +Secrets are never written to logs. The complete request messages, with secrets excluded, are saved for audit. + +The request context contains only Harness-selected inputs: + +1. system contract and output schema; +2. `skills/miracle-harness/SKILL.md`; +3. `skills/miracle-replay-reader/SKILL.md`; +4. current strategy source; +5. structured battle summary; +6. replay/trace excerpts selected within the episode and decision-read budget; +7. previous iteration metrics and failure information; +8. remaining budget. + +### Response + +The assistant must return one JSON object: + +```json +{ + "analysis": "Concise explanation of the observed weakness and intended change.", + "strategy_code": "Complete Python source defining CandidateAgent." +} +``` + +Markdown fences are not part of the contract. The Harness may accept a single fenced JSON object as a defensive compatibility fallback, but records that normalization in the iteration log. + +The generated source must define `CandidateAgent`, a subclass of `MiracleAgent`, with `choose_cards(camp)` and `act(obs)`. It is loaded from the saved iteration snapshot, not added to the static `AGENTS` registry. This avoids accumulating `strategy_v2`-style historical interfaces while still preserving immutable versions for evaluation and rollback experiments. + +## 4. Run, Iteration, Episode, and Round + +- A **Run** is one complete LLM benchmark session. +- An **iteration** is one LLM strategy-update attempt, including failures. +- An **episode** is one complete battle. +- A **round** is one in-game Miracle turn number. + +Iteration 0 is the immutable raw baseline. Iteration N loads the accepted strategy from iteration N-1, evaluates the configured evidence episodes, calls the LLM once, validates and saves the candidate, then evaluates it. A failed update still creates an iteration record and does not silently advance the accepted strategy. + +## 5. Loop + +For a minimal Run: + +1. Create the Run directory and save configuration, git revision, Skills, initial strategy, start time, and budget limits. +2. Evaluate iteration 0 with the official Miracle logic. Save every `.mrc`, `.trace.jsonl`, stdout-equivalent result, seed, seat, opponent, duration, and error. +3. Build the LLM context from the current strategy and selected evidence. +4. Call `/v1/chat/completions`; save sanitized request, raw response, status, latency, and returned usage. +5. Parse `strategy_code`, save it before validation, import it in isolation, instantiate `CandidateAgent`, and run interface smoke validation. +6. If valid, save it as the immutable candidate for the new iteration. If invalid, record the failure and keep the prior accepted strategy. +7. Evaluate the candidate using the same configured opponent, seeds, and seats required for version alignment. +8. Compare old and new agents on the same recorded observations and save strict KL statuses. +9. Aggregate the iteration score, gain, budgets, and IG statuses. +10. Update Run-level curves and CI-compatible result files atomically. + +The minimal acceptance Run may use one opponent, one seed, and one seat to prove the pipeline. The configuration supports multiple seeds and seat swapping for later statistically meaningful experiments. + +## 6. Validation and Failure Semantics + +Validation is staged: + +1. response received; +2. response JSON parsed; +3. complete source extracted; +4. source imports without exception; +5. `CandidateAgent` exists and implements the interface; +6. card selection and action shape smoke checks pass; +7. official battle completes or reports its real termination state. + +Every stage has `pending`, `passed`, `failed`, or `missing` status and an explicit reason. API failure, malformed JSON, invalid source, import failure, illegal action, timeout, host error, replay absence, and incomplete IG are distinct conditions. Failed and regressed iterations remain in the Run and its curves. + +Generated code is untrusted. Strategy import and execution use the existing decision timeout and must later be placed behind a stronger process sandbox. The initial local implementation documents this limitation and never exposes API secrets to strategy code or prompts. + +## 7. Decision Space and Strict KL Status + +The existing Miracle decision-space implementation remains the single source for parameterized action support: + +- observation: official parsed map, players, camp, and round; +- macro-actions: `summon`, `move`, `attack`, `use`, `endround`, `surrender`; +- action mask: finite parameterized legal-support enumeration for an observation; +- termination: official game end, round cap, surrender, timeout, host failure, or logic failure; +- IG support: the same finite action support, with documented treatment of the official unbounded `WindBlessing` coordinate behavior. + +For deterministic policies, the Harness records: + +- `unchanged`: identical supported action; strict KL is `0`; +- `infinite`: different supported action; strict KL diverges; +- `missing`: an action cannot be parsed, aligned, or found in the finite support, with a reason; +- `finite_kl_mean`: mean of genuine finite values only, otherwise `null`. + +No action distance, score gain, or epsilon-smoothed proxy is labeled as KL. + +## 8. Metrics + +Per episode: + +- result, winner, official scores, rounds, termination, errors; +- decision count and replay/trace paths; +- wall time; +- strict KL counts and ratios where applicable. + +Per iteration: + +- `raw`: the fixed iteration-0 baseline score; +- `evo`: the current accepted candidate score under the aligned evaluation set; +- `gain = evo - raw`; +- win rate and completion rate; +- `unchanged_ratio`, `infinite_ratio`, `missing_ratio`, and genuine `finite_kl_mean`; +- episode-read count, decision/step-read count, rollout count; +- prompt, completion, and total tokens when supplied by the API; +- API time, battle time, and total wall time. + +Run-level output contains score–iteration and IG–iteration curves. AUC is computed over explicit iteration, rollout, token, episode-read, and wall-time axes only when at least two measured points exist; otherwise it is `null` with a reason. + +## 9. Storage and AgentBenchResults Export + +The Run is written directly beneath `$AGENTBENCH_DATA` when set, otherwise beneath local `agentbench_data`. Its standard destination is: + +```text +runs/24_miracle/{agent}/{run_id}/ +├── run.toml +├── summary.json +├── events.jsonl +├── score_curve.json +├── ig_curve.json +├── iterations/ +│ ├── iteration-0000/ +│ │ ├── strategy.py +│ │ ├── iteration.json +│ │ └── episodes/... +│ └── iteration-0001/ +│ ├── llm_request.json +│ ├── llm_response.json +│ ├── candidate.py +│ ├── strategy.py +│ ├── iteration.json +│ ├── episodes/... +│ └── ig/... +└── skills/ + ├── miracle-harness.SKILL.md + └── miracle-replay-reader.SKILL.md +``` + +`run.toml` and `summary.json` follow the existing AgentBenchResults contract. Miracle-specific curves and budgets are additional fields/files. Writes use temporary files followed by replacement so interruption does not leave a valid-looking partial summary. `events.jsonl` is append-only and is sufficient to reconstruct the sequence of iteration states. + +## 10. CLI + +Existing low-level commands remain: + +```text +miracle match +miracle replay +miracle ig +``` + +One high-level command is added: + +```text +miracle loop --config PATH +``` + +The configuration supplies LLM endpoint/model settings, initial strategy, opponent, seeds, seats, maximum iterations, rollout limits, episode/decision-read limits, token limit, and wall-time limit. There are no duplicate `iterate`, `strategy_v2`, or version-management entry points. + +## 11. Future Tool Calling Migration + +The LLM client exposes one internal operation: `propose_strategy(context) -> proposal`. The first transport implements a single Chat Completions request returning complete source. A later Tool Calling transport may implement repeated model/tool turns, but it must return the same final proposal and emit the same accounting events. + +Battle execution, replay parsing, strategy snapshots, metrics, budgets, curves, and result export remain unchanged. Tool calls add explicit counters and logs so Tool Calling results are comparable rather than silently receiving extra work. + +## 12. Acceptance Criteria + +The implementation is accepted when one local mock OpenAI-compatible server completes a deterministic Run that: + +1. evaluates an initial weak strategy with official logic; +2. sends the expected Skill, source, replay evidence, and budget context; +3. receives and saves a valid improved complete strategy; +4. reevaluates it and preserves replay/trace artifacts; +5. saves aligned score and strict KL status curves; +6. records API usage, time, episode/decision reads, and rollout counts; +7. writes valid `run.toml` and `summary.json` beneath an AgentBenchResults-compatible directory; +8. preserves a deliberately malformed-response iteration as failed without corrupting the accepted strategy or Run summary. diff --git a/examples/miracle-initial-strategy.py b/examples/miracle-initial-strategy.py new file mode 100644 index 0000000..096b173 --- /dev/null +++ b/examples/miracle-initial-strategy.py @@ -0,0 +1,12 @@ +from agentbench_frame.miracle.agent_bridge import MiracleAgent + + +class CandidateAgent(MiracleAgent): + def choose_cards(self, camp): + return { + "artifacts": ["HolyLight"], + "creatures": ["Archer", "Swordsman", "BlackBat"], + } + + def act(self, obs): + return {"operation_type": "endround", "operation_parameters": {}} diff --git a/examples/miracle-loop.toml b/examples/miracle-loop.toml new file mode 100644 index 0000000..47f50f1 --- /dev/null +++ b/examples/miracle-loop.toml @@ -0,0 +1,26 @@ +agent = "tested_llm" +initial_strategy = "miracle-initial-strategy.py" +opponent = "sample" + +[llm] +base_url = "https://api.openai.com" +api_key_env = "OPENAI_API_KEY" +model = "your-model-name" +temperature = 0.0 +timeout_seconds = 120 +# 可选;支持推理强度的 OpenAI-compatible 模型可设 low/medium/high +reasoning_effort = "low" +stream = true +max_context_tokens = 1000000 + +[evaluation] +seeds = [11] +seats = [0, 1] + +[budget] +max_iterations = 1 +max_rollouts = 4 +max_episode_reads = 1 +max_decision_reads = 500 +max_total_tokens = 20000 +max_wall_seconds = 1800 diff --git a/skills/miracle-harness/SKILL.md b/skills/miracle-harness/SKILL.md new file mode 100644 index 0000000..d1b6514 --- /dev/null +++ b/skills/miracle-harness/SKILL.md @@ -0,0 +1,200 @@ +--- +name: miracle-harness +description: 运行 Miracle(第 24 届)官方逻辑对战、保存回放,并比较新旧确定性策略的严格 KL 状态 +--- + +# Miracle 对战 Harness + +所有命令在 `AgentBenchFramework` 根目录执行。唯一入口是: + +```bash +uv run python -m agentbench_frame.miracle +``` + +## 1. Agent 接口 + +在 `src/agentbench_frame/miracle/agent_bridge.py` 中实现 `MiracleAgent`: + +- `choose_cards(camp)` 返回 `{"artifacts": [名称], "creatures": [名称, 名称, 名称]}`; +- `act(obs)` 返回 `{"operation_type": 类型, "operation_parameters": 参数}`; +- 在同一文件末尾的唯一注册表 `AGENTS` 中加入 `"名称": AgentClass`。 + +CLI 每次启动时读取 `AGENTS`,不需要修改其他 choices 或入口。内置 `endround` 只结束回合, +`sample` 是可运行示例;它们不是待迭代策略的历史版本。 + +## 2. 运行对战 + +```bash +uv run python -m agentbench_frame.miracle match \ + --agent0 sample \ + --agent1 endround \ + --seed 11 \ + --output-dir agentbench_data/replays/24_miracle \ + --tag sample-vs-endround +``` + +- `agent0` 是先手,`agent1` 是后手;严谨比较应交换双方再跑一次; +- `seed` 固定官方逻辑的地图类型和昼夜随机值; +- `output-dir` 同时保存官方 `.mrc` 和可读 `.mrc.trace.jsonl`; +- stdout JSON 给出双方、winner、scores、rounds、terminated_by、errors 和两个回放路径; +- `terminated_by=normal` 且 `errors=[]` 才是完整正常对局。 + +代码调用不要求注册: + +```python +from agentbench_frame.miracle import run_match +from my_agent import CandidateAgent, OpponentAgent + +result = run_match( + CandidateAgent(), OpponentAgent(), + seed=11, + replay_dir="agentbench_data/replays/24_miracle", + tag="candidate-vs-opponent", +) +print(result.winner, result.scores, result.replay_path, result.trace_path) +``` + +## 3. 解析官方回放 + +```bash +uv run python -m agentbench_frame.miracle replay \ + --path agentbench_data/replays/24_miracle/.mrc \ + --jsonl agentbench_data/replays/24_miracle/.events.jsonl +``` + +命令打印 winner、终局回合和事件计数;`--jsonl` 可选,写出逐事件时间线。 +分析局面和 Agent 实际收发内容时读取配套 `.mrc.trace.jsonl`。字段与事件数字含义见 +`miracle-replay-reader` Skill。 + +## 4. 比较一次策略更新 + +先用更新前或更新后的策略完成对战,取得 `match` 输出中的 `trace` 路径。然后让旧、新策略 +在同一份真实 observation 序列上重新决策: + +```bash +uv run python -m agentbench_frame.miracle ig \ + --trace agentbench_data/replays/24_miracle/.mrc.trace.jsonl \ + --old llm_v0 \ + --new llm_v1 \ + --camp 0 \ + --iteration 1 \ + --output-dir agentbench_data/ig/24_miracle +``` + +`old` 和 `new` 都是 `AGENTS` 中的注册名。命令不会修改游戏,也不会要求 Agent 输出概率; +它只调用现有 `act(obs)`。同一 trace 的另一阵营需要另跑一次并改为 `--camp 1`。 + +输出包括: + +- `iteration-0001/-camp0.json`:逐决策记录,含 observation 指纹、trace 序号、 + 新旧动作、状态及缺失原因; +- `ig_curve.json`:汇总输出目录内所有 iteration/episode,版本名与数据对齐; +- stdout:本 episode 的路径和四个核心数值。 + +严格口径如下: + +- `unchanged_ratio`:新旧确定性动作相同;严格 KL 为 0; +- `infinite_ratio`:动作不同;旧策略对新动作的概率为 0,严格 KL 发散; +- `missing_ratio`:动作不在该 observation 的有限合法支持集内,或 Agent 返回无法解析; +- `finite_kl_mean`:只平均真实有限 KL。当前确定性接口通常只有 0,若全是变化或缺失则为 + `null`,不得用动作距离、胜率变化等指标代替。 + +有限动作支持集由当前 observation 生成,覆盖 `endround`、`surrender`、合法召唤、移动、 +攻击和神器使用。官方逻辑仍是实际对战的最终仲裁者;官方 `WindBlessing` 接受无界坐标, +Benchmark 为保持支持集有限只纳入地图内坐标,支持集外动作如实记为 `missing`。 + +## 5. 运行完整 LLM 迭代闭环 + +复制并编辑 `examples/miracle-loop.toml`,然后运行: + +```bash +export OPENAI_API_KEY= +uv run python -m agentbench_frame.miracle loop \ + --config examples/miracle-loop.toml \ + --data-dir ../AgentBenchResults +``` + +`loop` 使用 OpenAI-compatible `POST /v1/chat/completions`。当前是最小的完整源码模式, +尚未启用 Tool Calling。Harness 把当前策略、这两个 Miracle Skill、选中的结构化回放、上轮指标 +和累计 budget 放入 messages。LLM 必须返回: + +```json +{ + "analysis": "本轮发现的问题和修改理由", + "strategy_code": "定义 CandidateAgent 的完整 Python 源码" +} +``` + +`CandidateAgent` 必须继承 `MiracleAgent` 并实现 `choose_cards(camp)`、`act(obs)`。不要返回 +diff、命令或只包含方法片段的代码。Harness 自己保存、加载和评测源码,不把 `v0/v1/v2` +加入 `AGENTS`。 + +### 配置与预算 + +- `agent`:本次被测 LLM/Agent 的 Results 目录名; +- `initial_strategy`:定义初始 `CandidateAgent` 的源码,相对配置文件解析; +- `opponent`:`AGENTS` 中的固定评测对手; +- `evaluation.seeds/seats`:每个版本的对齐评测集合; +- `max_iterations`:LLM 策略更新次数,不含 iteration 0; +- `max_rollouts`:实际启动的对战总数; +- `max_episode_reads`:提供给 LLM 的回放 episode 总数; +- `max_decision_reads`:提供给 LLM 的 observation/decision 总数; +- `max_total_tokens`:API 返回 usage 的累计 token 上限; +- `max_wall_seconds`:整个 Run 的累计墙钟时间上限。 + +对于会把输出预算用于隐藏推理的兼容模型,可在 `[llm]` 设置 +`reasoning_effort = "low"`;不支持该字段的服务应省略。 + +Chat Completions 默认使用 SSE 流式传输,以避免长推理期间的 60 秒空闲网关超时。只有 +不支持流式的服务才设置 `stream = false`。`max_tokens` 默认不发送;若确实需要限制单次 +生成才显式配置。`max_context_tokens` 默认 1000000,并按 API 返回的真实 prompt + +completion usage 检查;Harness 不用字符数冒充 token 数。 + +Run 是一次完整测评;iteration 是一次策略更新尝试;episode 是一局完整对战;round 是游戏内 +回合。无效代码、API 错误、超时、性能倒退都保留,不从曲线中删除。 + +### 保存位置 + +设置 `AGENTBENCH_DATA=/path/to/AgentBenchResults` 或使用 `--data-dir` 后,输出为: + +```text +runs/24_miracle/// +├── run.toml +├── summary.json +├── events.jsonl +├── score_curve.json +├── ig_curve.json +├── skills/ +└── iterations/ + ├── iteration-0000/strategy.py + └── iteration-0001/ + ├── llm_request.json + ├── llm_response.json + ├── candidate.py + ├── strategy.py + ├── episodes/ + └── ig/ +``` + +`summary.json` 保存 `raw`、`final_evo`、`final_gain`、最佳 iteration、AUC、失败计数和 +累计 budget。`events.jsonl` 是顺序日志,可用于事后重算 episode-read、decision-read、 +rollout、token 和耗时曲线。API key 永不写入这些文件。 + +## 6. 最小验收 + +```bash +uv run --with pytest python -m pytest \ + tests/miracle/test_cli.py \ + tests/miracle/test_smoke.py \ + tests/miracle/test_replay.py \ + tests/miracle/test_decision_space.py \ + tests/miracle/test_ig.py \ + tests/miracle/test_loop_config.py \ + tests/miracle/test_llm_client.py \ + tests/miracle/test_strategy_loader.py \ + tests/miracle/test_run_store.py \ + tests/miracle/test_score.py \ + tests/miracle/test_loop.py +``` + +不要把 `agentbench_data/` 当源码提交;它是每次对战可重新生成的运行产物。 diff --git a/skills/miracle-replay-reader/SKILL.md b/skills/miracle-replay-reader/SKILL.md new file mode 100644 index 0000000..9f8b6cb --- /dev/null +++ b/skills/miracle-replay-reader/SKILL.md @@ -0,0 +1,164 @@ +--- +name: miracle-replay-reader +description: 读取 Miracle(24 届)对战回放:trace.jsonl 逐帧格式、obs 字段数字含义、关键事件、常见误读清单与解析工作流 +--- + +# Agent 看游戏回放:Miracle(24 届) + +读取并解读 Miracle 对战回放。回放由 `run_match()`(`agentbench_frame/miracle/match.py`)落盘在 `agentbench_data/replays/24_miracle/`,一份对局产生两个文件: + +| 文件 | 格式 | 内容 | +|---|---|---| +| `*.mrc` | **二进制**(官方 magic replay,非 JSON) | 官方格式完整对局,给官方客户端/裁判用 | +| `*.mrc.trace.jsonl` | JSONL 逐帧 | 本框架的**可读 trace**,逐帧记录 host 与官方逻辑的往返消息(含完整 obs 与操作) | + +> 常见误读①:`.mrc` 不是 JSON,别用 `json.load` 读;分析一律用 `.trace.jsonl`。 + +## 1. 游戏规则速览(24 届 Miracle) + +- 六边形立方坐标地图(`x+y+z=0`),每方一座神迹(camp0 `(-7,7,0)`、camp1 `(7,-7,0)`,HP 30)。 +- 选卡:开局各选 1 神器 + 3 生物卡组(样例 `HolyLight` + `Archer/Swordsman/BlackBat`)。 +- 回合制,`MAX_ROUND=100`;每回合 mana +1(上限 12),回合开始重置 `can_move/can_atk`。 +- 生物:召唤(需 mana、容量、召唤点)→ 移动(≤max_move)→ 攻击(射程内)→ `endround`。 +- 地图上有 4 个固定驻扎点(Barrack),占领后该方多 3 个召唤点。 +- 固定障碍:`Abyss`(地面单位不可过、飞行可过)、Miracle 障碍和地图边界;这些不在 obs 里。 + +## 2. 胜负与计分 + +trace 的官方终局帧是 `from_logic`、`state=-1`,比分位于 +`json.loads(payload["end_info"])` 的 `"0"`/`"1"`。`.mrc` 中对应一条 +`GameEnd` 事件,其第一个参数是 winner。 + +- `score` 30000 = 该方神迹被毁或一方全灭(**30000 是"胜/负"标记,不是真实分数**)。 +- 正常打完:`score = 神迹剩余 HP`(可能一胜一负)。 +- `winner`:分数高者;**平局时后手(camp1)+1 分**。 +- 正常终局包括达到回合上限或神迹被毁;超时/异常还要结合对战结果的 `terminated_by` 和 `errors`。 + +> 常见误读②:看到 score=30000 会以为"拿了 30000 分"——它是胜负标记。 + +## 3. trace.jsonl 行格式 + +每行 JSON,字段:`kind`、`state`、`player`、`seq`、`payload`、`summary`、`ts`。 + +`summary` 是 `payload` 的 JSON 截断(host 记录时截到 200 字符),**别拿 summary 当完整内容**,解析一律用 `payload`。 + +| kind | state | 含义 | +|---|---|---| +| `init` | - | 对局开始 | +| `from_logic` | 0 | 计时/长度通知,没有 `content`,分析局面时跳过 | +| `to_logic` | - | Agent → 官方操作(`content` 是 JSON 字符串,即官方操作 dict) | +| `from_logic` | 1、2 | camp0/camp1 选卡请求,解码后为 `{"camp": 0/1}` | +| `from_logic` | ≥3 | 局面消息序号;同一 state 可出现多次,实际回合读解码后的 `round` | +| `from_logic` | -1 | 终局,`payload.end_info` 是比分 JSON 字符串 | + +`summary` 是 `payload` 的快捷视图(同内容);`content` 里每个元素是 `NNNNNN{...}` 形式——**前 6 位是长度前缀**,后面才是 JSON。 + +> 常见误读③:直接 `json.loads(content[0])` 会失败——先去掉前 6 位长度前缀。 +> 常见误读:外层 `state` 不是游戏回合号;必须读取内层 obs 的 `round`。 + +### 快速筛选局面帧 + +```python +import json + +for line in open(trace_path, encoding="utf-8"): + row = json.loads(line) + content = row.get("payload", {}).get("content") + if row.get("kind") != "from_logic" or not content: + continue + message = json.loads(content[0][6:]) + if "map" in message: + print(message["round"], message["camp"], message["map"]["miracles"]) +``` + +## 4. Obs 字段与数字含义 + +obs JSON:`{"map": {"units": [[18 字段],...], "miracles": [hp0,hp1], "barracks": [4 个 camp]}, "players": [[5 字段],...], "round": N, "camp": 0/1}`。 + +**units[i] 18 字段**: + +| 索引 | 含义 | +|---|---| +| 0 `ID` | 单位 id(对局内唯一,官方全局计数器分配) | +| 1 `CAMP` | 0/1 | +| 2 `TYPE` | **全局编号**:Archer=0, Swordsman=1, BlackBat=2, Priest=3, VolcanoDragon=4, Inferno=5, FrostDragon=6 | +| 3-6 | `COST`/`ATK`/`MAX_HP`/`HP` | +| 7 `ATK_RANGE` | `[min,max]` | +| 8 `MAX_MOVE` | 移动力 | +| 9 `COOL_DOWN` | 召唤冷却 | +| 10 `POS` | `[x,y,z]` | +| 11-15 | `LEVEL`/`FLYING`/`ATK_FLYING`/`AGILITY`/`HOLY_SHIELD` | +| 16 `CAN_ATK` / 17 `CAN_MOVE` | **本回合是否可行动**(0=已行动/刚召唤/冷却) | + +**players[camp] 5 字段**:`[artifacts, mana, max_mana, capacities, newly_summoned]`;`capacities = [[卡组序号, 上限, [已召唤 id]], ...]`。 + +> 常见误读④:`CAN_MOVE=0` 不是"坏单位",是新召唤或本回合已行动;下一回合开始自动恢复。 +> 常见误读⑤:`TYPE` 是全局编号,不是卡组顺序(Archer 永远是 0)。 +> 常见误读⑥:obs 里没有障碍和边界表;不能只看 obs 坐标推断所有移动是否合法。 + +## 5. 操作格式(to_logic 的 content) + +官方操作 dict:`{"player": 0, "round": N, "operation_type": "summon|move|attack|endround|init", "operation_parameters": {...}}`。 + +- `summon`: `{type: 名, level: 1-3, position: [x,y,z]}` +- `move`: `{mover: id, position: [x,y,z]}` +- `attack`: `{attacker: id, target: id}`(target 可为敌方单位 id 或**敌方神迹 id=camp**) +- `endround`: 空参数 + +> 常见误读⑦:`attack` 的 `target` 传 `camp`(0/1)表示攻击神迹,不是单位 id。 + +## 6. 常见误读汇总(先查这里) + +1. `.mrc` 是二进制,别 `json.load` → 用 `.trace.jsonl`。 +2. score 30000 = 胜负标记,不是真实得分。 +3. `content` 前 6 位是长度前缀,先切掉再 `json.loads`。 +4. `CAN_MOVE/CAN_ATK=0` 是"本回合已行动/刚召唤",非故障。 +5. `TYPE` 是全局编号(Archer=0…FrostDragon=6)。 +6. 障碍/边界表不在 obs 里,最终合法性以官方逻辑是否推进局面为准。 +7. `attack target=camp` 是打神迹。 +8. 平局后手 +1 分(winner 判定)。 +9. 飞行单位可与地面单位同格(官方 `get_unit_at` 按 flying 过滤)。 +10. 新召唤单位**当回合不能行动**(can_move/can_atk=0),下一回合才行。 + +## 7. `.mrc` 事件与 args 含义 + +运行: + +```bash +uv run python -m agentbench_frame.miracle replay --path --jsonl +``` + +每行是 `{"round": N, "type": 事件名, "args": [...]}`。主要事件参数: + +| type | args | +|---|---| +| `TurnStart` / `TurnEnd` | `[camp]` | +| `GameStart` | `[camp, artifact_code, creature1_code, creature2_code, creature3_code]` | +| `Summon` | `[creature_code, level, x, y]` | +| `Spawn` | `[creature_code, level, x, y, unit_id]` | +| `Move` | `[unit_id, dest_x, dest_y]` | +| `Leave` / `Arrive` | `[unit_id, x, y]` | +| `Attack` / `Attacking` / `Attacked` | `[attacker_id, target_id]` | +| `Damage` | `[target_id, source_id, damage, damage_type]` | +| `Death` | `[unit_id]` | +| `Heal` | `[target_id, source_id, heal]` | +| `ActivateArtifact` | `[camp, artifact_code, target...]` | +| `BuffAdd` / `BuffRemove` | `[unit_id, buff_type]` | +| `GameEnd` | `[winner]` | +| `END` | `[]`,文件结束标记 | + +`creature_code`、`artifact_code` 使用“基础编号 + 10×camp”:个位是类型编号,十位是阵营。 +生物基础编号:Swordsman=1、Archer=2、BlackBat=3、Priest=4、VolcanoDragon=5、 +FrostDragon=6、Inferno=7。神器基础编号:HolyLight=1、SalamanderShield=2、 +InfernoFlame=3、WindBlessing=4。 + +`damage_type`:Attack=1、AttackBack=2、VolcanoDragonSplash=3、InfernoFlameActivate=4。 +`buff_type`:BaseBuff=0、PriestAtkBuff=1、HolyShield=2、HolyLightAtkBuff=3、 +SalamanderShieldBuff=4。 + +## 8. 解析工作流(验证回放自洽) + +1. 取 `*.mrc.trace.jsonl`,逐行解析。 +2. 收集:双方 `to_logic` 的 `init` 选卡、所有含 `map` 的 `from_logic` obs、双方操作序列、`state=-1` 终局比分。 +3. 将每条 `to_logic` 操作与其前后的 obs 对齐;局面未推进通常表示操作被拒绝。 +4. 对照 `state=-1` 的 `end_info`、`.mrc` 的 `GameEnd` 和 CLI 的 MatchResult 验证胜负一致。 diff --git a/src/agentbench_frame/miracle/__init__.py b/src/agentbench_frame/miracle/__init__.py new file mode 100644 index 0000000..83ca06d --- /dev/null +++ b/src/agentbench_frame/miracle/__init__.py @@ -0,0 +1,25 @@ +"""24_miracle(神迹之战):官方逻辑、对战与回放核心封装。 + +包结构: +- ``official_logic/``:官方对战逻辑原样移植(零改动,子进程运行) +- ``protocol.py`` / ``logic_runner.py``:官方线协议与子进程运行器 +- ``host.py`` / ``match.py``:评测机(对局驱动、trace 记录) +- ``agent_bridge.py``:Agent 接口与内置策略 +- ``replay.py``:官方二进制回放解析 +""" + +from .agent_bridge import AGENTS, EndRoundAgent, MiracleAgent, SampleAgent +from .host import MatchResult, MiracleHost +from .match import run_match + +__version__ = "0.1.0" + +__all__ = [ + "MiracleAgent", + "EndRoundAgent", + "SampleAgent", + "AGENTS", + "MiracleHost", + "MatchResult", + "run_match", +] diff --git a/src/agentbench_frame/miracle/__main__.py b/src/agentbench_frame/miracle/__main__.py new file mode 100644 index 0000000..3bc92d6 --- /dev/null +++ b/src/agentbench_frame/miracle/__main__.py @@ -0,0 +1,8 @@ +"""``python -m agentbench_frame.miracle`` → CLI 入口。""" + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/agentbench_frame/miracle/agent_bridge.py b/src/agentbench_frame/miracle/agent_bridge.py new file mode 100644 index 0000000..31b1d77 --- /dev/null +++ b/src/agentbench_frame/miracle/agent_bridge.py @@ -0,0 +1,224 @@ +"""MiracleAgent 接口与内置策略(自己实现)。 + +Agent 与评测机(``host.py``)的接口约定: +- ``choose_cards(camp) -> {"artifacts": [...], "creatures": [...]}``:开局的卡组选择, + 返回的神器/生物名必须是官方 ``Data.json`` 中的合法名称。 +- ``act(obs) -> {"operation_type": ..., "operation_parameters": {...}}``:回合内决策。 + ``obs`` 是官方内层消息(``statesystem.parse()`` + ``round`` + ``camp``): + ``{"map": {...}, "players": [...], "camp": int, "round": int}``。 + ``player``/``round`` 由评测机补全,Agent 不必携带。 + +obs 关键字段(官方 ``StateSystem.parse`` 输出): +- ``map.units`` 每行 18 个元素: + [id, camp, type, cost, atk, max_hp, hp, atk_range, max_move, cool_down, + pos[x,y,z], level, flying, atk_flying, agility, holy_shield, can_atk, can_move] +- ``map.miracles``: [hp0, hp1] +- ``map.barracks``: [camp](4 个固定驻扎点) +- ``players[camp]``: [artifacts_parsed, mana, max_mana, capacities_parsed, newly_summoned] +- ``camp``: 本玩家阵营 0/1;``round``: 当前回合号 +""" + +from __future__ import annotations + +import hashlib +import json +import random +from abc import ABC, abstractmethod +from typing import Optional + +__all__ = [ + "MiracleAgent", + "EndRoundAgent", + "SampleAgent", + "AGENTS", + "DEFAULT_ARTIFACTS", + "DEFAULT_CREATURES", + "MIRACLE_POS", + "SUMMON_POS", +] + +#: 初始卡组(1 神器 + 3 生物,均为官方合法名称) +DEFAULT_ARTIFACTS = ["HolyLight"] +DEFAULT_CREATURES = ["Archer", "Swordsman", "BlackBat"] + +#: 双方神迹位置(官方 StateSystem 固定):camp0 左上,camp1 右下 +MIRACLE_POS = {0: (-7, 7, 0), 1: (7, -7, 0)} + +#: 双方神迹召唤点(官方 StateSystem.miracle_list 的第 1 个召唤点) +SUMMON_POS = {0: (-8, 6, 2), 1: (8, -6, -2)} + +#: 单位 level1 的召唤费用(官方 Data.json UnitData.cost[0]) +COST_LEVEL1 = { + "Archer": 2, "Swordsman": 2, "BlackBat": 2, "Priest": 3, + "VolcanoDragon": 6, "Inferno": 4, "FrostDragon": 7, +} + + +def cube_distance(a, b) -> int: + """六边形立方坐标距离(官方 Geometry.calculator.cube_distance 同式)。""" + return ( + abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2]) + ) // 2 + + +class MiracleAgent(ABC): + """对局策略接口。子类只需实现 ``choose_cards`` 与 ``act``。""" + + name: str = "MiracleAgent" + + @abstractmethod + def choose_cards(self, camp: int) -> dict: + """开局选卡,返回 ``{"artifacts": [1 个], "creatures": [3 个]}``。""" + + @abstractmethod + def act(self, obs: dict) -> dict: + """给定局面 obs 返回一个回合内操作。""" + + +class EndRoundAgent(MiracleAgent): + """每回合直接 endround,用于协议/链路冒烟测试。""" + + name = "endround" + + def choose_cards(self, camp: int) -> dict: + return { + "artifacts": list(DEFAULT_ARTIFACTS), + "creatures": list(DEFAULT_CREATURES), + } + + def act(self, obs: dict) -> dict: + return {"operation_type": "endround", "operation_parameters": {}} + + +class SampleAgent(MiracleAgent): + """自写的规则策略(链路冒烟/评测基线,非强策略): + + 1. 能打则打:射程内优先敌方单位(hp>0),其次敌方神迹; + 2. 法力够就补单位(按 ``summon_order``,放在神迹召唤点); + 3. 还有能动的单位就向敌方神迹方向走一步; + 4. 否则 endround。 + """ + + name = "sample" + + #: 召唤优先级(容量未满且法力够的第一个类型);v2 策略覆盖此顺序 + summon_order = ["Archer", "Swordsman", "BlackBat"] + + def __init__(self, *, seed: Optional[int] = None) -> None: + self._rng = random.Random(seed) + #: 上次收到的 obs 指纹;相同 ⇒ 上一操作被官方拒绝(obs 未推进) + self._last_obs_key: Optional[str] = None + + def choose_cards(self, camp: int) -> dict: + return { + "artifacts": list(DEFAULT_ARTIFACTS), + "creatures": list(DEFAULT_CREATURES), + } + + def act(self, obs: dict) -> dict: + camp = int(obs.get("camp", 0)) + units = obs.get("map", {}).get("units", []) + + # 被拒感知:obs 与上次完全相同 ⇒ 官方拒绝了上一操作,本回合直接 endround, + # 避免对同一局面无限重试同一操作(如弹道被障碍挡住、目标被挡等)。 + key = hashlib.md5( + json.dumps(obs, sort_keys=True).encode() + ).hexdigest() + rejected = key == self._last_obs_key + self._last_obs_key = key + if rejected: + return self._end() + + mine = [u for u in units if u[1] == camp] + foes = [u for u in units if u[1] != camp] + + # 1) 攻击 + for u in mine: + if not u[16]: # can_atk + continue + lo, hi = u[7] + pos = u[10] + for f in foes: + if f[6] > 0 and lo <= cube_distance(pos, f[10]) <= hi: + return self._attack(u[0], f[0]) + mpos = MIRACLE_POS[1 - camp] + if lo <= cube_distance(pos, mpos) <= hi: + return self._attack(u[0], 1 - camp) # 神迹 id == camp + + # 2) 召唤:按召唤优先级选第一个容量未满且法力够的生物 + players = obs.get("players", []) + mana = players[camp][1] if len(players) > camp else 0 + capacities = players[camp][3] if len(players) > camp else [] + # capacities: [[type_index, capacity, [已召唤unit id...]], ...] + for ti, type_name in enumerate(self.summon_order): + cap = capacities[ti] if ti < len(capacities) else None + used = len(cap[2]) if cap else 0 + limit = cap[1] if cap else 0 + if cap is not None and used >= limit: + continue + if mana < COST_LEVEL1[type_name]: + continue + spos = SUMMON_POS[camp] + if not any(cube_distance(u[10], spos) == 0 for u in units): + return { + "operation_type": "summon", + "operation_parameters": { + "type": type_name, + "level": 1, + "position": list(spos), + }, + } + + # 3) 移动(向敌方神迹走一步,跳过被占位置) + mpos = MIRACLE_POS[1 - camp] + occupied = {tuple(u[10]) for u in units} + for u in mine: + if u[17]: # can_move + nxt = self._step_toward(u[10], mpos, occupied) + if nxt is not None: + return { + "operation_type": "move", + "operation_parameters": {"mover": u[0], "position": nxt}, + } + + return self._end() + + def _step_toward(self, pos, target, occupied=None) -> Optional[list]: + """向目标沿立方坐标最短路走一格(六边形 6 邻居中使距离严格减小的第一个空位)。""" + # 立方坐标 6 个邻居方向(dx+dy+dz==0) + neighbors = [ + (1, -1, 0), (1, 0, -1), (0, 1, -1), + (-1, 1, 0), (-1, 0, 1), (0, -1, 1), + ] + occupied = occupied or set() + d0 = cube_distance(pos, target) + for n in neighbors: + nxt = [pos[0] + n[0], pos[1] + n[1], pos[2] + n[2]] + if cube_distance(nxt, target) >= d0: + continue + # 界内(官方地图为半径约 9 的六边形,±10 保守外框) + if any(abs(c) > 10 for c in nxt): + continue + if tuple(nxt) in occupied: + continue + return nxt + return None + + + @staticmethod + def _attack(attacker: int, target: int) -> dict: + return { + "operation_type": "attack", + "operation_parameters": {"attacker": attacker, "target": target}, + } + + @staticmethod + def _end() -> dict: + return {"operation_type": "endround", "operation_parameters": {}} + + +# CLI 的唯一 Agent 注册表。新增 Agent 子类后只需在这里注册一次。 +AGENTS = { + "endround": EndRoundAgent, + "sample": SampleAgent, +} diff --git a/src/agentbench_frame/miracle/cli.py b/src/agentbench_frame/miracle/cli.py new file mode 100644 index 0000000..abbc4da --- /dev/null +++ b/src/agentbench_frame/miracle/cli.py @@ -0,0 +1,157 @@ +"""Miracle 核心入口:运行对战与解析回放。""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .agent_bridge import AGENTS + + +def _cmd_match(args) -> int: + from .match import run_match + + result = run_match( + AGENTS[args.agent0](), + AGENTS[args.agent1](), + seed=args.seed, + replay_dir=args.output_dir, + tag=args.tag or f"{args.agent0}_vs_{args.agent1}", + ) + print(json.dumps({ + "agent0": args.agent0, + "agent1": args.agent1, + "winner": result.winner, + "scores": list(result.scores), + "rounds": result.rounds, + "terminated_by": result.terminated_by, + "errors": list(result.errors), + "replay": result.replay_path, + "trace": result.trace_path, + }, ensure_ascii=False, indent=2)) + return 0 if result.terminated_by in {"normal", "timeout"} else 1 + + +def _cmd_replay(args) -> int: + from .replay import parse_replay, save_replay_json, summarize + + events = parse_replay(args.path) + print(json.dumps(summarize(events), ensure_ascii=False, indent=2)) + if args.jsonl: + save_replay_json(events, args.jsonl) + print(f"事件时间线已写: {args.jsonl}") + return 0 + + +def _cmd_ig(args) -> int: + from .ig import ( + build_ig_curve, + compare_agents_on_trace, + load_episode_ig, + save_episode_ig, + save_ig_curve, + versions_from_episodes, + ) + + episode = compare_agents_on_trace( + args.trace, + AGENTS[args.old](), + AGENTS[args.new](), + camp=args.camp, + iteration=args.iteration, + old_version=args.old, + new_version=args.new, + ) + episode_path = save_episode_ig(episode, args.output_dir) + episodes = load_episode_ig(args.output_dir) + curve = build_ig_curve(episodes, versions=versions_from_episodes(episodes)) + curve_path = args.output_dir / "ig_curve.json" + save_ig_curve(curve, curve_path) + print(json.dumps({ + "episode": str(episode_path.resolve()), + "curve": str(curve_path.resolve()), + "iteration": args.iteration, + "old_version": args.old, + "new_version": args.new, + "n_decisions": episode["n_decisions"], + "finite_kl_mean": episode["finite_kl_mean"], + "unchanged_ratio": episode["unchanged_ratio"], + "infinite_ratio": episode["infinite_ratio"], + "missing_ratio": episode["missing_ratio"], + }, ensure_ascii=False, indent=2)) + return 0 + + +def _cmd_loop(args) -> int: + from .loop import run_loop + from .loop_config import LoopConfig + + config = LoopConfig.from_toml(args.config) + run_dir = run_loop(config, data_dir=args.data_dir) + summary = json.loads((run_dir / "summary.json").read_text(encoding="utf-8")) + print(json.dumps({ + "run_dir": str(run_dir.resolve()), + "run_id": summary["run_id"], + "status": summary["status"], + "score_curve": str((run_dir / "score_curve.json").resolve()), + "ig_curve": str((run_dir / "ig_curve.json").resolve()), + }, ensure_ascii=False, indent=2)) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="miracle", description="Miracle 对战、回放与严格 KL 状态工具") + sub = parser.add_subparsers(dest="cmd", required=True) + names = sorted(AGENTS) + + match = sub.add_parser("match", help="运行一局官方逻辑对战") + match.add_argument("--agent0", default="sample", choices=names, help="先手 Agent") + match.add_argument("--agent1", default="endround", choices=names, help="后手 Agent") + match.add_argument("--seed", type=int, default=11) + match.add_argument( + "--output-dir", type=Path, + default=Path("agentbench_data") / "replays" / "24_miracle", + help=".mrc 与 .trace.jsonl 的统一输出目录", + ) + match.add_argument("--tag", default="", help="写入文件名的简短标识") + match.set_defaults(func=_cmd_match) + + replay = sub.add_parser("replay", help="解析官方 .mrc 回放") + replay.add_argument("--path", type=Path, required=True) + replay.add_argument("--jsonl", type=Path, help="可选的事件时间线输出路径") + replay.set_defaults(func=_cmd_replay) + + ig = sub.add_parser("ig", help="在真实 trace 上比较新旧确定性策略") + ig.add_argument("--trace", type=Path, required=True, help="match 生成的 .trace.jsonl") + ig.add_argument("--old", required=True, choices=names, help="更新前 Agent 版本") + ig.add_argument("--new", required=True, choices=names, help="更新后 Agent 版本") + ig.add_argument("--camp", type=int, choices=(0, 1), required=True, help="trace 中待比较的阵营") + ig.add_argument("--iteration", type=int, required=True, help="新版本 iteration,须大于 0") + ig.add_argument( + "--output-dir", type=Path, + default=Path("agentbench_data") / "ig" / "24_miracle", + help="episode IG 与 ig_curve.json 的统一输出目录", + ) + ig.set_defaults(func=_cmd_ig) + + loop = sub.add_parser("loop", help="执行一次可追溯的 LLM 策略迭代 Run") + loop.add_argument("--config", type=Path, required=True, help="Loop TOML 配置") + loop.add_argument( + "--data-dir", type=Path, + help="结果根目录;默认读取 AGENTBENCH_DATA,否则使用 agentbench_data", + ) + loop.set_defaults(func=_cmd_loop) + + return parser + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + if args.cmd == "ig" and args.iteration <= 0: + raise SystemExit("--iteration 必须大于 0") + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agentbench_frame/miracle/decision_space.py b/src/agentbench_frame/miracle/decision_space.py new file mode 100644 index 0000000..7fcf784 --- /dev/null +++ b/src/agentbench_frame/miracle/decision_space.py @@ -0,0 +1,180 @@ +"""Miracle observation 上的有限 Benchmark 动作支持集。 + +官方 Logic 保持最终仲裁。WindBlessing 的官方实现允许无限坐标;为使 IG 支持集有限, +本模块只纳入官方地图内坐标,支持集外动作由 IG 记录为缺失。 +""" + +from __future__ import annotations + +import json +from collections import deque +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Action: + type: str + params: dict + + def signature(self) -> str: + return json.dumps( + {"operation_type": self.type, "operation_parameters": self.params}, + ensure_ascii=False, sort_keys=True, separators=(",", ":"), + ) + + def to_dict(self) -> dict: + return {"operation_type": self.type, "operation_parameters": dict(self.params)} + + +_DATA = json.loads((Path(__file__).parent / "official_logic" / "Data.json").read_text()) +UNIT_NAMES = {value: key for key, value in _DATA["UnitNameParsed"].items()} +ARTIFACT_NAMES = {value: key for key, value in _DATA["ArtifactNameParsed"].items()} +UNIT_DATA = _DATA["UnitData"] + +MIRACLE_POS = {0: (-7, 7, 0), 1: (7, -7, 0)} +MIRACLE_SUMMON = { + 0: [(-8, 6, 2), (-7, 6, 1), (-6, 6, 0), (-6, 7, -1), (-6, 8, -2)], + 1: [(8, -6, -2), (7, -6, -1), (6, -6, 0), (6, -7, 1), (6, -8, 2)], +} +BARRACKS = [ + ((-6, -6, 12), [(-7, -5, 12), (-5, -7, 12), (-5, -6, 11)]), + ((6, 6, -12), [(7, 5, -12), (5, 7, -12), (5, 6, -11)]), + ((0, -5, 5), [(0, -4, 4), (-1, -4, 5), (-1, -5, 6)]), + ((0, 5, -5), [(0, 4, -4), (1, 4, -5), (1, 5, -6)]), +] +ABYSS = { + (0, 0, 0), (-1, 0, 1), (0, -1, 1), (1, -1, 0), (1, 0, -1), + (0, 1, -1), (-1, 1, 0), (-2, -1, 3), (-1, -2, 3), (-2, -2, 4), + (-3, -2, 5), (-4, -4, 8), (-5, -4, 9), (-4, -5, 9), (-5, -5, 10), + (-6, -5, 11), (1, 2, -3), (2, 1, -3), (2, 2, -4), (3, 2, -5), + (4, 4, -8), (5, 4, -9), (4, 5, -9), (5, 5, -10), (6, 5, -11), + (5, 8, -13), (6, 7, -13), (7, 6, -13), (8, 5, -13), (6, 8, -14), + (7, 7, -14), (8, 6, -14), (-5, -8, 13), (-6, -7, 13), (-7, -6, 13), + (-8, -5, 13), (-6, -8, 14), (-7, -7, 14), (-8, -6, 14), +} +DIRECTIONS = ((1, 0, -1), (1, -1, 0), (0, -1, 1), + (-1, 0, 1), (-1, 1, 0), (0, 1, -1)) + + +def in_map(pos) -> bool: + x, y, z = pos + return x + y + z == 0 and -8 <= x <= 8 and -8 <= y <= 8 and -14 <= z <= 14 \ + and tuple(pos) not in MIRACLE_POS.values() + + +ALL_MAP_POSITIONS = tuple( + (x, y, -x - y) for x in range(-8, 9) for y in range(-8, 9) + if in_map((x, y, -x - y)) +) + + +def cube_distance(a, b) -> int: + return (abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2])) // 2 + + +def _reachable(unit, units) -> set[tuple]: + start = tuple(unit[10]) + flying = bool(unit[12]) + occupied = {tuple(u[10]) for u in units if bool(u[12]) == flying and u[0] != unit[0]} + blocked = set(MIRACLE_POS.values()) | occupied + if not flying: + blocked |= ABYSS + seen = {start} + queue = deque([(start, 0)]) + out = set() + while queue: + pos, distance = queue.popleft() + if distance >= int(unit[8]): + continue + for delta in DIRECTIONS: + nxt = tuple(pos[i] + delta[i] for i in range(3)) + if nxt in seen or nxt in blocked or not in_map(nxt): + continue + seen.add(nxt) + out.add(nxt) + queue.append((nxt, distance + 1)) + return out + + +def _summon_positions(obs, camp: int) -> list[tuple]: + positions = list(MIRACLE_SUMMON[camp]) + owners = obs.get("map", {}).get("barracks", []) + for index, owner in enumerate(owners): + if owner == camp and index < len(BARRACKS): + positions.extend(BARRACKS[index][1]) + return positions + + +def action_mask(obs: dict, camp: int | None = None) -> list[Action]: + """枚举当前 observation 的有限、带参数动作支持集。""" + camp = int(obs.get("camp", 0) if camp is None else camp) + map_data = obs.get("map", {}) + units = map_data.get("units", []) + players = obs.get("players", []) + player = players[camp] if len(players) > camp else [[], 0, 0, [], []] + mana = int(player[1]) + actions: list[Action] = [Action("endround", {}), Action("surrender", {})] + + occupied_by_layer = {(tuple(u[10]), bool(u[12])) for u in units} + for capacity in player[3]: + type_name = UNIT_NAMES.get(int(capacity[0])) + if not type_name or int(capacity[1]) <= 0 or type_name == "Inferno": + continue + flying = bool(UNIT_DATA[type_name]["flying"]) + for level, cost in enumerate(UNIT_DATA[type_name]["cost"], start=1): + if mana < int(cost): + continue + for pos in _summon_positions(obs, camp): + if (tuple(pos), flying) not in occupied_by_layer: + actions.append(Action("summon", { + "type": type_name, "level": level, "position": list(pos), + })) + + mine = [u for u in units if int(u[1]) == camp] + enemies = [u for u in units if int(u[1]) != camp and int(u[6]) > 0] + for unit in mine: + if bool(unit[17]): + for pos in sorted(_reachable(unit, units)): + actions.append(Action("move", {"mover": int(unit[0]), "position": list(pos)})) + if bool(unit[16]) and int(unit[4]) > 0: + lo, hi = unit[7] + for target in enemies: + distance = cube_distance(unit[10], target[10]) + can_hit_air = bool(unit[12]) or bool(unit[13]) or not bool(target[12]) + if int(lo) <= distance <= int(hi) and can_hit_air: + actions.append(Action("attack", { + "attacker": int(unit[0]), "target": int(target[0]), + })) + enemy_miracle = 1 - camp + if int(lo) <= cube_distance(unit[10], MIRACLE_POS[enemy_miracle]) <= int(hi): + actions.append(Action("attack", { + "attacker": int(unit[0]), "target": enemy_miracle, + })) + + for artifact in player[0]: + artifact_id, name_idx, cost, _, _, state_idx, target_idx, _ = artifact + if int(state_idx) != 0 or mana < int(cost): + continue + name = ARTIFACT_NAMES[int(name_idx)] + if int(target_idx) == 1: # Unit;官方仅 SalamanderShield 使用该类型 + for unit in mine: + actions.append(Action("use", {"card": int(artifact_id), "target": int(unit[0])})) + continue + for pos in ALL_MAP_POSITIONS: + if name == "InfernoFlame": + owned_barracks = [BARRACKS[i][0] for i, owner in enumerate(map_data.get("barracks", [])) if owner == camp] + in_range = cube_distance(MIRACLE_POS[camp], pos) <= 7 or any( + cube_distance(barrack, pos) <= 5 for barrack in owned_barracks + ) + ground_occupied = any(tuple(u[10]) == pos and not bool(u[12]) for u in units) + if not in_range or pos in ABYSS or ground_occupied: + continue + actions.append(Action("use", {"card": int(artifact_id), "target": list(pos)})) + + unique = {action.signature(): action for action in actions} + return [unique[key] for key in sorted(unique)] + + +def support_set(obs: dict, camp: int | None = None) -> tuple[str, ...]: + return tuple(action.signature() for action in action_mask(obs, camp)) diff --git a/src/agentbench_frame/miracle/host.py b/src/agentbench_frame/miracle/host.py new file mode 100644 index 0000000..bac5173 --- /dev/null +++ b/src/agentbench_frame/miracle/host.py @@ -0,0 +1,317 @@ +"""评测机(host):驱动官方 logic 子进程并把 MiracleAgent 桥接成协议消息(自己实现)。 + +消息流(与官方 ``main.py`` 一致): +- 启动后先发 init:``{"player_list": [1, 1, 2], "replay": <路径>}`` +- 循环读 logic 帧(``target, payload = read_logic_frame``): + - ``payload["state"] == 0`` → send_init 初始化消息,忽略 + - ``payload["state"] == -1`` → 终局(``end_info`` 为 ``'{"0": s0, "1": s1}'``) + - 其余为选卡/回合消息:``content[0]`` 解析出内层 dict + - 无 ``round`` 字段 → 选卡(含 ``camp``),调 ``choose_cards`` + - 有 ``round`` 字段 → 回合局面,调 ``act`` +- 每次决策写回一条 ``{"player", "round", "operation_type", "operation_parameters"}``; + 决策超时则向 logic 发官方约定的异常帧 ``{"player": -1, "content": json.dumps({...})}``。 + +全部收发逐帧记入 trace(jsonl),保证"日志可追溯"。 +""" + +from __future__ import annotations + +import hashlib +import json +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from typing import Optional + +from .agent_bridge import MiracleAgent +from .protocol import ( + ProtocolError, + decode_content, + encode_to_logic, + read_logic_frame, +) + +__all__ = ["FrameRecord", "MatchResult", "MiracleHost"] + +#: 官方 AI_TIME(非媒体玩家的决策时限,秒) +DEFAULT_DECISION_TIMEOUT = 3.0 +#: logic 长时间无输出的兜底超时(秒) +DEFAULT_IDLE_TIMEOUT = 8.0 + + +@dataclass +class FrameRecord: + """一条可追溯的收发记录。""" + + seq: int + ts: float + kind: str # "from_logic" | "to_logic" | "timeout" | "init" + state: Optional[int] = None + player: Optional[int] = None + summary: str = "" + payload: dict = field(default_factory=dict) + + +@dataclass +class MatchResult: + """一场对局的结果与产物。""" + + winner: int + scores: tuple # (s0, s1),官方 score(平局时后手 +1,winner 记为 1) + rounds: int + replay_path: str + trace_path: str + duration: float + terminated_by: str # normal | timeout | idle_timeout | logic_exit | host_error + errors: list = field(default_factory=list) + frames: int = 0 + stderr_tail: str = "" + + +class MiracleHost: + """对局驱动:持有 logic 子进程与两个 Agent,跑完整场对局。""" + + def __init__( + self, + proc, + agents: tuple, + *, + decision_timeout: float = DEFAULT_DECISION_TIMEOUT, + idle_timeout: float = DEFAULT_IDLE_TIMEOUT, + trace_path: Optional[str] = None, + ) -> None: + self.proc = proc + self.agents: tuple = agents + self.decision_timeout = decision_timeout + self.idle_timeout = idle_timeout + self.trace_path = trace_path + self._executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="miracle-agent") + self._trace: list = [] + self._trace_file = None + self._last_was_timeout = False + self._rounds_seen = 0 + self._last_obs_key = None # (listen, obs 指纹) + self._repeat_count = 0 + + # ---- trace ---- + def _open_trace(self) -> None: + if self.trace_path: + self._trace_file = open(self.trace_path, "w", encoding="utf-8") + + def _record(self, rec: FrameRecord) -> None: + self._trace.append(rec) + if self._trace_file: + row = { + "seq": rec.seq, + "ts": round(rec.ts, 4), + "kind": rec.kind, + "state": rec.state, + "player": rec.player, + "summary": rec.summary, + "payload": rec.payload, + } + self._trace_file.write(json.dumps(row, ensure_ascii=False) + "\n") + + def _close_trace(self) -> None: + if self._trace_file: + self._trace_file.close() + self._trace_file = None + + # ---- 消息收发 ---- + def _send(self, payload: dict, kind: str = "to_logic") -> None: + self.proc.stdin.write(encode_to_logic(payload)) + self.proc.stdin.flush() + self._record( + FrameRecord( + seq=len(self._trace), + ts=time.time(), + kind=kind, + state=payload.get("state"), + player=payload.get("player"), + summary=json.dumps(payload, ensure_ascii=False)[:200], + payload=payload, + ) + ) + + def _send_action(self, player: int, inner: dict) -> None: + """按官方格式发一条玩家操作:``{"player": p, "content": json字符串}``。 + + 注意内层操作 dict 也必须含 ``player``(官方 ``Parser.to_object`` 要求)。 + """ + inner = {"player": player, **inner} + self._send({"player": player, "content": json.dumps(inner)}) + + def _decide(self, fn, *args): + """带超时地调用 Agent 决策;超时/异常返回 None。""" + future = self._executor.submit(fn, *args) + try: + return future.result(timeout=self.decision_timeout) + except TimeoutError: + return None + except Exception as exc: # Agent 内部异常 + return exc + + # ---- 主循环 ---- + def run(self, replay_path: str) -> MatchResult: + t0 = time.time() + self._open_trace() + errors: list = [] + terminated_by = "host_error" + winner = 1 + scores = (0, 0) + rounds = 0 + + try: + # 1) init + init_payload = {"player_list": [1, 1, 2], "replay": replay_path} + self._send(init_payload, kind="init") + self._last_was_timeout = False + + # 2) 主循环 + while True: + try: + target, payload = read_logic_frame( + self.proc.stdout, self.idle_timeout, label="logic" + ) + except TimeoutError: + errors.append("idle timeout: no frame from logic") + terminated_by = "idle_timeout" + break + except EOFError: + errors.append("logic exited unexpectedly") + terminated_by = "logic_exit" + break + + state = payload.get("state") + self._record( + FrameRecord( + seq=len(self._trace), + ts=time.time(), + kind="from_logic", + state=state, + player=None, + summary=json.dumps(payload, ensure_ascii=False)[:200], + payload=payload, + ) + ) + + # 终局 + if state == -1: + terminated_by = "timeout" if self._last_was_timeout else "normal" + end_info = payload.get("end_info", "{}") + try: + d = json.loads(end_info) + scores = (int(d.get("0", 0)), int(d.get("1", 0))) + except (ValueError, TypeError): + errors.append(f"bad end_info: {end_info!r}") + winner = 0 if scores[0] > scores[1] else 1 + rounds = self._rounds_seen + break + + # state==0 的初始化消息:忽略 + if state == 0: + continue + + # 选卡 / 回合消息 + content = payload.get("content", []) + msg = decode_content(content) + if msg is None: + errors.append(f"undecodable content frame: {payload!r}") + terminated_by = "host_error" + break + + listen = payload.get("listen", [0])[0] + if listen not in (0, 1): + errors.append(f"unexpected listen={listen!r}") + terminated_by = "host_error" + break + + if "round" not in msg: # 选卡消息:{"camp": c} + camp = int(msg.get("camp", listen)) + self._last_was_timeout = False + result = self._decide(self.agents[camp].choose_cards, camp) + if result is None: + self._send_timeout(camp, state) + continue + if isinstance(result, Exception): + errors.append(f"agent{camp}.choose_cards raised: {result!r}") + self._send_timeout(camp, state) + continue + action_inner = { + "round": 0, + "operation_type": "init", + "operation_parameters": result, + } + self._send_action(camp, action_inner) + else: # 回合消息 + round_no = int(msg.get("round", 0)) + self._rounds_seen = max(self._rounds_seen, round_no) + camp = int(msg.get("camp", listen)) + # 防卡死:同一玩家收到完全相同的 obs ≥3 次 → 按官方超时判负 + obs_key = (listen, hashlib.md5(content[0].encode()).hexdigest()) + if obs_key == self._last_obs_key: + self._repeat_count += 1 + else: + self._last_obs_key = obs_key + self._repeat_count = 1 + if self._repeat_count >= 3: + errors.append( + f"agent{listen} stuck (same obs x{self._repeat_count}) at round {round_no}" + ) + self._send_timeout(listen, state) + continue + self._last_was_timeout = False + result = self._decide(self.agents[camp].act, msg) + if result is None: + errors.append(f"agent{camp}.act timeout after {self.decision_timeout}s") + self._send_timeout(camp, state) + continue + if isinstance(result, Exception): + errors.append(f"agent{camp}.act raised: {result!r}") + self._send_timeout(camp, state) + continue + action_inner = { + "round": round_no, + **result, + } + self._send_action(camp, action_inner) + + # 3) 收尾 + try: + self.proc.kill() + except OSError: + pass + self.proc.wait(timeout=5) + except Exception as exc: # pragma: no cover - 防御性兜底 + errors.append(f"host error: {exc!r}") + terminated_by = "host_error" + try: + self.proc.kill() + except OSError: + pass + finally: + self._close_trace() + + return MatchResult( + winner=winner, + scores=scores, + rounds=rounds, + replay_path=replay_path, + trace_path=self.trace_path or "", + duration=time.time() - t0, + terminated_by=terminated_by, + errors=errors, + frames=len(self._trace), + ) + + # ---- 超时/异常帧 ---- + def _send_timeout(self, player: int, state: int) -> None: + """按官方约定向 logic 发 AI 超时/异常帧(player=-1)。""" + content = json.dumps( + {"error": 1, "state": state, "player": player} + ) + self._send( + {"player": -1, "content": content}, + kind="timeout", + ) + self._last_was_timeout = True diff --git a/src/agentbench_frame/miracle/ig.py b/src/agentbench_frame/miracle/ig.py new file mode 100644 index 0000000..3a49d89 --- /dev/null +++ b/src/agentbench_frame/miracle/ig.py @@ -0,0 +1,210 @@ +"""确定性 Miracle 策略的严格 KL 状态记录与 iteration 曲线。""" + +from __future__ import annotations + +import json +import hashlib +from collections import Counter +from pathlib import Path + +from .decision_space import Action, action_mask +from .protocol import decode_content + + +def _as_action(value) -> Action | None: + if not isinstance(value, dict): + return None + action_type = value.get("operation_type") + params = value.get("operation_parameters") + if not isinstance(action_type, str) or not isinstance(params, dict): + return None + return Action(action_type, params) + + +def _missing_row(obs_id: str, reason: str, old_action=None, new_action=None) -> dict: + return { + "obs_id": obs_id, + "old_action": old_action.signature() if isinstance(old_action, Action) else None, + "new_action": new_action.signature() if isinstance(new_action, Action) else None, + "status": "missing", + "kl": None, + "missing_reason": reason, + } + + +def observations_from_trace(path, *, camp: int): + """按 trace 顺序读取指定阵营实际收到的官方 observation。""" + path = Path(path) + with path.open(encoding="utf-8") as stream: + for line_no, line in enumerate(stream, start=1): + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if row.get("kind") != "from_logic": + continue + payload = row.get("payload", {}) + msg = decode_content(payload.get("content", [])) + if not isinstance(msg, dict) or "round" not in msg or "map" not in msg: + continue + if int(msg.get("camp", -1)) != camp: + continue + yield int(row.get("seq", line_no)), msg + + +def compare_agents_on_trace( + trace_path, + old_agent, + new_agent, + *, + camp: int, + iteration: int, + old_version: str, + new_version: str, +) -> dict: + """在同一条真实 observation 序列上比较两个确定性 Agent。""" + rows = [] + for trace_seq, obs in observations_from_trace(trace_path, camp=camp): + canonical = json.dumps(obs, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + obs_id = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + try: + old_action = _as_action(old_agent.act(obs)) + except Exception: + old_action = None + try: + new_action = _as_action(new_agent.act(obs)) + except Exception: + new_action = None + if old_action is None: + row = _missing_row(obs_id, "old_action_invalid", new_action=new_action) + elif new_action is None: + row = _missing_row(obs_id, "new_action_invalid", old_action=old_action) + else: + row = compare_deterministic(obs_id, old_action, new_action, action_mask(obs, camp)) + row["trace_seq"] = trace_seq + row["round"] = int(obs.get("round", 0)) + rows.append(row) + + episode_id = f"{Path(trace_path).name.removesuffix('.trace.jsonl')}-camp{camp}" + result = aggregate_episode(rows, episode_id=episode_id, iteration=iteration) + result.update({ + "source_trace": str(Path(trace_path).resolve()), + "camp": camp, + "old_version": old_version, + "new_version": new_version, + }) + return result + + +def compare_deterministic( + obs_id: str, + old_action: Action, + new_action: Action, + support, +) -> dict: + support_signatures = { + action.signature() if isinstance(action, Action) else str(action) for action in support + } + old_sig = old_action.signature() + new_sig = new_action.signature() + base = {"obs_id": obs_id, "old_action": old_sig, "new_action": new_sig} + if old_sig not in support_signatures or new_sig not in support_signatures: + return {**base, "status": "missing", "kl": None, + "missing_reason": "action_outside_support"} + if old_sig == new_sig: + return {**base, "status": "unchanged", "kl": 0.0, "missing_reason": None} + return {**base, "status": "infinite", "kl": None, + "missing_reason": "support_expansion"} + + +def aggregate_episode(rows: list[dict], *, episode_id: str, iteration: int) -> dict: + counts = Counter(row["status"] for row in rows) + total = len(rows) + finite = [row["kl"] for row in rows if row.get("kl") is not None] + return { + "episode_id": episode_id, + "iteration": iteration, + "n_decisions": total, + "finite_kl_count": len(finite), + "finite_kl_mean": round(sum(finite) / len(finite), 6) if finite else None, + "unchanged_ratio": round(counts["unchanged"] / total, 6) if total else 0.0, + "infinite_ratio": round(counts["infinite"] / total, 6) if total else 0.0, + "missing_ratio": round(counts["missing"] / total, 6) if total else 0.0, + "counts": {name: counts[name] for name in ("unchanged", "infinite", "missing")}, + "decisions": rows, + } + + +def build_ig_curve(episodes: list[dict], *, versions: dict[int, str]) -> dict: + points = [] + for iteration in sorted(versions): + if iteration == 0: + points.append({ + "iteration": 0, "version": versions[iteration], "status": "baseline", + "finite_kl_mean": None, "unchanged_ratio": None, + "infinite_ratio": None, "missing_ratio": None, + }) + continue + current = [ep for ep in episodes if ep["iteration"] == iteration] + if not current: + points.append({ + "iteration": iteration, "version": versions[iteration], "status": "missing", + "finite_kl_mean": None, "unchanged_ratio": None, + "infinite_ratio": None, "missing_ratio": 1.0, + }) + continue + weights = [ep["n_decisions"] for ep in current] + total = sum(weights) + finite_weight = sum(ep.get("finite_kl_count", 0) for ep in current) + points.append({ + "iteration": iteration, + "version": versions[iteration], + "status": "measured", + "finite_kl_mean": round(sum( + ep["finite_kl_mean"] * ep.get("finite_kl_count", 0) + for ep in current if ep["finite_kl_mean"] is not None + ) / finite_weight, 6) if finite_weight else None, + "unchanged_ratio": round(sum(ep["unchanged_ratio"] * w for ep, w in zip(current, weights)) / total, 6) if total else 0.0, + "infinite_ratio": round(sum(ep["infinite_ratio"] * w for ep, w in zip(current, weights)) / total, 6) if total else 0.0, + "missing_ratio": round(sum(ep["missing_ratio"] * w for ep, w in zip(current, weights)) / total, 6) if total else 0.0, + }) + return { + "metric": "strict_kl_status", + "note": "确定性策略:相同动作 KL=0;不同动作严格 KL 发散;不以代理指标冒充有限 KL。", + "points": points, + } + + +def save_ig_curve(curve: dict, path) -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(curve, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def save_episode_ig(episode: dict, output_dir) -> Path: + path = Path(output_dir) / f"iteration-{int(episode['iteration']):04d}" / f"{episode['episode_id']}.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(episode, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return path + + +def load_episode_ig(output_dir) -> list[dict]: + episodes = [] + for path in sorted(Path(output_dir).glob("iteration-*/*.json")): + try: + episode = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if isinstance(episode, dict) and "iteration" in episode and "n_decisions" in episode: + episodes.append(episode) + return episodes + + +def versions_from_episodes(episodes: list[dict]) -> dict[int, str]: + versions = {} + for episode in sorted(episodes, key=lambda item: int(item["iteration"])): + iteration = int(episode["iteration"]) + versions.setdefault(iteration, episode.get("new_version", f"iteration-{iteration}")) + if iteration > 0: + versions.setdefault(iteration - 1, episode.get("old_version", f"iteration-{iteration - 1}")) + return versions diff --git a/src/agentbench_frame/miracle/llm_client.py b/src/agentbench_frame/miracle/llm_client.py new file mode 100644 index 0000000..eb6a091 --- /dev/null +++ b/src/agentbench_frame/miracle/llm_client.py @@ -0,0 +1,238 @@ +"""OpenAI-compatible Chat Completions transport for strategy proposals.""" + +from __future__ import annotations + +import json +import os +import time +import urllib.error +import urllib.request +from dataclasses import dataclass + +from .loop_config import LLMConfig + + +class LLMRequestError(RuntimeError): + def __init__( + self, stage: str, reason: str, raw_response=None, + usage: dict | None = None, latency_seconds: float = 0.0, + ): + super().__init__(f"{stage}: {reason}") + self.stage = stage + self.reason = reason + self.raw_response = raw_response + self.usage = usage or { + "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, + } + self.latency_seconds = latency_seconds + + +@dataclass(frozen=True) +class StrategyProposal: + analysis: str + strategy_code: str + usage: dict + request_body: dict + raw_response: dict + latency_seconds: float + normalized_fence: bool = False + + +class ChatCompletionsClient: + def __init__(self, config: LLMConfig): + self.config = config + + def propose_strategy(self, messages: list[dict]) -> StrategyProposal: + body = { + "model": self.config.model, + "messages": messages, + "temperature": self.config.temperature, + "stream": self.config.stream, + } + if self.config.max_tokens is not None: + body["max_tokens"] = self.config.max_tokens + if self.config.stream: + body["stream_options"] = {"include_usage": True} + if self.config.reasoning_effort is not None: + body["reasoning_effort"] = self.config.reasoning_effort + headers = { + "Content-Type": "application/json", + "User-Agent": "AgentBenchFramework/0.1", + } + api_key = os.environ.get(self.config.api_key_env, "") if self.config.api_key_env else "" + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + request = urllib.request.Request( + self.config.base_url.rstrip("/") + "/v1/chat/completions", + data=json.dumps(body).encode("utf-8"), + headers=headers, + method="POST", + ) + started = time.monotonic() + try: + with urllib.request.urlopen(request, timeout=self.config.timeout_seconds) as response: + if self.config.stream: + raw, latency = self._read_stream(response, started) + else: + raw_bytes = response.read() + latency = time.monotonic() - started + try: + raw = json.loads(raw_bytes) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + excerpt = raw_bytes[:500].decode("utf-8", errors="replace") + raise LLMRequestError( + "response_json", str(exc), excerpt, + latency_seconds=latency, + ) from exc + if isinstance(raw, dict): + raw.setdefault("stream", False) + raw.setdefault("chunk_count", 1) + raw.setdefault("first_chunk_seconds", latency) + raw.setdefault("usage_missing", not bool(raw.get("usage"))) + except LLMRequestError: + raise + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise LLMRequestError("request", self._safe_error(exc)) from exc + raw_usage = raw.get("usage", {}) if isinstance(raw, dict) else {} + usage = { + name: int(raw_usage.get(name, 0) or 0) + for name in ("prompt_tokens", "completion_tokens", "total_tokens") + } + try: + content = raw["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise LLMRequestError( + "assistant_content", "missing choices[0].message.content", raw, + usage, latency, + ) from exc + if not isinstance(content, str): + raise LLMRequestError( + "assistant_content", "assistant content is not a string", raw, + usage, latency, + ) + + normalized = False + candidate = content.strip() + if candidate.startswith("```") and candidate.endswith("```"): + first_newline = candidate.find("\n") + if first_newline >= 0: + candidate = candidate[first_newline + 1:-3].strip() + normalized = True + try: + proposal = json.loads(candidate) + except json.JSONDecodeError as exc: + raise LLMRequestError("proposal_json", str(exc), raw, usage, latency) from exc + if not isinstance(proposal, dict): + raise LLMRequestError( + "proposal_json", "proposal must be an object", raw, usage, latency, + ) + analysis = proposal.get("analysis") + strategy_code = proposal.get("strategy_code") + if not isinstance(analysis, str) or not analysis.strip(): + raise LLMRequestError( + "proposal_json", "analysis must be a nonempty string", raw, usage, latency, + ) + if not isinstance(strategy_code, str) or not strategy_code.strip(): + raise LLMRequestError( + "proposal_json", "strategy_code must be a nonempty string", raw, + usage, latency, + ) + return StrategyProposal( + analysis.strip(), strategy_code, usage, body, raw, latency, normalized, + ) + + def _read_stream(self, response, started: float) -> tuple[dict, float]: + content_parts = [] + reasoning_parts = [] + metadata = {} + usage = {} + finish_reason = None + chunk_count = 0 + first_chunk_seconds = None + + def normalized() -> dict: + return { + **metadata, + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "".join(content_parts), + "reasoning_content": "".join(reasoning_parts), + }, + "finish_reason": finish_reason, + }], + "usage": usage, + "stream": True, + "chunk_count": chunk_count, + "first_chunk_seconds": first_chunk_seconds, + "usage_missing": not bool(usage), + } + + for raw_line in response: + try: + line = raw_line.decode("utf-8").strip() + except UnicodeDecodeError as exc: + latency = time.monotonic() - started + raise LLMRequestError( + "stream_chunk_json", str(exc), normalized(), + self._usage(usage), latency, + ) from exc + if not line or line.startswith(":"): + continue + if not line.startswith("data:"): + continue + payload = line[5:].strip() + if payload == "[DONE]": + return normalized(), time.monotonic() - started + try: + chunk = json.loads(payload) + except json.JSONDecodeError as exc: + latency = time.monotonic() - started + raise LLMRequestError( + "stream_chunk_json", str(exc), normalized(), + self._usage(usage), latency, + ) from exc + if not isinstance(chunk, dict): + continue + chunk_count += 1 + if first_chunk_seconds is None: + first_chunk_seconds = time.monotonic() - started + for name in ("id", "object", "created", "model", "system_fingerprint"): + if chunk.get(name) is not None: + metadata[name] = chunk[name] + if isinstance(chunk.get("usage"), dict): + usage = chunk["usage"] + choices = chunk.get("choices", []) + if choices and isinstance(choices[0], dict): + choice = choices[0] + delta = choice.get("delta", {}) + if isinstance(delta, dict): + if isinstance(delta.get("reasoning_content"), str): + reasoning_parts.append(delta["reasoning_content"]) + if isinstance(delta.get("content"), str): + content_parts.append(delta["content"]) + if choice.get("finish_reason") is not None: + finish_reason = choice["finish_reason"] + latency = time.monotonic() - started + raise LLMRequestError( + "stream_incomplete", "stream ended before [DONE]", normalized(), + self._usage(usage), latency, + ) + + @staticmethod + def _usage(raw_usage: dict) -> dict: + return { + name: int(raw_usage.get(name, 0) or 0) + for name in ("prompt_tokens", "completion_tokens", "total_tokens") + } + + @staticmethod + def _safe_error(exc: Exception) -> str: + if isinstance(exc, urllib.error.HTTPError): + try: + excerpt = exc.read(500).decode("utf-8", errors="replace") + except OSError: + excerpt = "" + return f"HTTP {exc.code}: {excerpt}" + return str(exc) diff --git a/src/agentbench_frame/miracle/logic_runner.py b/src/agentbench_frame/miracle/logic_runner.py new file mode 100644 index 0000000..2c75016 --- /dev/null +++ b/src/agentbench_frame/miracle/logic_runner.py @@ -0,0 +1,76 @@ +"""官方 logic 子进程的启动与路径解析(自己实现)。 + +官方逻辑(``official_logic/``,从 `AgentBench/backend_sources/corpus/24_miracle/ +logic/gamecode_logic/` 原样移植,零改动)以独立子进程运行:进程内 +``import main; main.Game().start()``,I/O 走 stdin/stdout 管道。 + +为了可复现评测,允许通过 ``-c`` 方式在子进程内 ``random.seed(...)``, +这只影响子进程运行时的随机源(地图类型/昼夜),不修改官方源码。 +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import Optional + +__all__ = [ + "OFFICIAL_LOGIC_DIR", + "resolve_official_dir", + "build_logic_command", + "start_logic", +] + +#: 框架内官方逻辑的默认位置 +OFFICIAL_LOGIC_DIR = Path(__file__).resolve().parent / "official_logic" + + +def resolve_official_dir(override: Optional[str | os.PathLike] = None) -> Path: + """返回官方逻辑目录;可通过 ``override`` 指向别的 24_miracle 逻辑包。""" + if override is not None: + path = Path(override).resolve() + else: + path = OFFICIAL_LOGIC_DIR + if not (path / "main.py").is_file(): + raise FileNotFoundError(f"official logic not found under {path}") + return path + + +def build_logic_command( + official_dir: Path, + seed: Optional[int] = None, +) -> list[str]: + """构造运行官方逻辑的 argv。 + + 无 seed 时直接 ``python main.py``(保持最原样);有 seed 时用 + ``python -c`` 注入 ``random.seed(seed)``(官方源码不变)。 + """ + if seed is None: + return [sys.executable, str(official_dir / "main.py")] + code = ( + "import random; random.seed(%d); " + "from main import Game; Game().start()" % int(seed) + ) + return [sys.executable, "-c", code] + + +def start_logic( + official_dir: Path, + seed: Optional[int] = None, + env: Optional[dict] = None, +) -> subprocess.Popen: + """以子进程启动官方 logic,返回 Popen(stdin/stdout/stderr 均为管道)。""" + cmd = build_logic_command(official_dir, seed) + proc_env = dict(os.environ) + if env: + proc_env.update(env) + return subprocess.Popen( + cmd, + cwd=str(official_dir), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=proc_env, + ) diff --git a/src/agentbench_frame/miracle/loop.py b/src/agentbench_frame/miracle/loop.py new file mode 100644 index 0000000..7a5abd2 --- /dev/null +++ b/src/agentbench_frame/miracle/loop.py @@ -0,0 +1,332 @@ +"""Miracle 的高层 LLM 策略迭代闭环。""" + +from __future__ import annotations + +import hashlib +import json +from collections import Counter +from pathlib import Path + +from .agent_bridge import AGENTS +from .ig import build_ig_curve, compare_agents_on_trace +from .llm_client import ChatCompletionsClient, LLMRequestError +from .loop_config import LoopConfig +from .match import run_match +from .protocol import decode_content +from .run_store import BudgetExceeded, BudgetLedger, MiracleRunStore +from .score import aggregate_score, build_score_curve +from .strategy_loader import ( + StrategyValidationError, + load_candidate, + save_source, + validate_candidate, +) + + +def _version(source: str) -> str: + return hashlib.sha256(source.encode("utf-8")).hexdigest()[:12] + + +def build_messages( + current_source: str, + skills: dict[str, str], + evidence: list[dict], + previous_metrics: dict, + budget: dict, +) -> list[dict]: + system = ( + "You improve a deterministic Miracle game strategy. Return exactly one JSON object " + 'with nonempty string fields "analysis" and "strategy_code". strategy_code must be ' + "complete Python source defining CandidateAgent(MiracleAgent), including choose_cards(camp) " + "and act(obs). Do not return a patch or shell commands." + ) + user = "\n\n".join([ + "# Miracle Harness Skill\n" + skills["harness"], + "# Miracle Replay Reader Skill\n" + skills["replay"], + "# Current strategy\n" + current_source, + "# Previous metrics\n" + json.dumps(previous_metrics, ensure_ascii=False, indent=2), + "# Selected replay evidence\n" + json.dumps(evidence, ensure_ascii=False, indent=2), + "# Cumulative budget\n" + json.dumps(budget, ensure_ascii=False, indent=2), + ]) + return [{"role": "system", "content": system}, {"role": "user", "content": user}] + + +def _read_evidence(episodes: list[dict], max_episodes: int, max_decisions: int) -> list[dict]: + evidence = [] + remaining = max_decisions + for episode in episodes[:max_episodes]: + observations = [] + trace_path = episode.get("trace") + if trace_path and Path(trace_path).is_file() and remaining > 0: + with Path(trace_path).open(encoding="utf-8") as stream: + for line in stream: + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if row.get("kind") != "from_logic": + continue + msg = decode_content(row.get("payload", {}).get("content", [])) + if isinstance(msg, dict) and "map" in msg and "round" in msg: + observations.append({"trace_seq": row.get("seq"), "observation": msg}) + remaining -= 1 + if remaining == 0: + break + evidence.append({ + "episode_id": episode["episode_id"], + "candidate_camp": episode["candidate_camp"], + "scores": episode.get("scores"), + "winner": episode.get("winner"), + "terminated_by": episode.get("terminated_by"), + "errors": episode.get("errors", []), + "observations": observations, + }) + if remaining == 0: + break + return evidence + + +def _evaluate( + *, + source_path: Path, + iteration: int, + config: LoopConfig, + store: MiracleRunStore, + ledger: BudgetLedger, + match_runner, +) -> tuple[dict, list[dict]]: + episode_rows = [] + camps = {} + episodes_dir = store.iteration_dir(iteration) / "episodes" + for seed in config.evaluation.seeds: + for seat in config.evaluation.seats: + ledger.charge_rollout() + episode_id = f"iter{iteration:04d}-seed{seed}-camp{seat}" + candidate = load_candidate(source_path, f"candidate_{iteration}_{seed}_{seat}") + opponent = AGENTS[config.opponent]() + agents = (candidate, opponent) if seat == 0 else (opponent, candidate) + store.write_event( + "battle_started", iteration=iteration, episode_id=episode_id, + seed=seed, candidate_camp=seat, opponent=config.opponent, + ) + result = match_runner( + agents[0], agents[1], replay_dir=episodes_dir, + seed=seed, tag=episode_id, + ) + ledger.charge_battle_time(result.duration) + row = { + "episode_id": episode_id, + "iteration": iteration, + "seed": seed, + "candidate_camp": seat, + "winner": result.winner, + "scores": list(result.scores), + "rounds": result.rounds, + "terminated_by": result.terminated_by, + "errors": list(result.errors), + "duration": result.duration, + "replay": result.replay_path, + "trace": result.trace_path, + } + store.write_json_atomic(episodes_dir / f"{episode_id}.json", row) + store.write_event("battle_finished", **row) + episode_rows.append(row) + camps[episode_id] = seat + return aggregate_score(episode_rows, camps), episode_rows + + +def _skills(store: MiracleRunStore) -> dict[str, str]: + return { + "harness": (store.run_dir / "skills/miracle-harness.SKILL.md").read_text(encoding="utf-8"), + "replay": (store.run_dir / "skills/miracle-replay-reader.SKILL.md").read_text(encoding="utf-8"), + } + + +def run_loop( + config: LoopConfig, + *, + client=None, + match_runner=run_match, + data_dir=None, + run_id: str | None = None, +) -> Path: + store = MiracleRunStore.create(config, data_dir=data_dir, run_id=run_id) + ledger = BudgetLedger(config.budget) + client = client or ChatCompletionsClient(config.llm) + iterations = [] + ig_episodes = [] + failure_counts = Counter() + total_episodes = 0 + total_steps = 0 + + initial_source = config.initial_strategy.read_text(encoding="utf-8") + baseline_dir = store.iteration_dir(0) + baseline_path = baseline_dir / "strategy.py" + save_source(baseline_path, initial_source) + baseline_agent = load_candidate(baseline_path, "candidate_baseline_validation") + validate_candidate(baseline_agent) + store.write_event("iteration_started", iteration=0, status="baseline") + baseline_score, baseline_episodes = _evaluate( + source_path=baseline_path, iteration=0, config=config, store=store, + ledger=ledger, match_runner=match_runner, + ) + total_episodes += len(baseline_episodes) + total_steps += sum(row["rounds"] for row in baseline_episodes) + accepted_source = initial_source + accepted_path = baseline_path + accepted_version = _version(initial_source) + accepted_episodes = baseline_episodes + baseline_record = { + "iteration": 0, "version": accepted_version, "status": "baseline", + "score": baseline_score["mean_score"], "win_rate": baseline_score["win_rate"], + "completion_rate": baseline_score["completion_rate"], + "score_detail": baseline_score, "budget": ledger.snapshot(), + } + iterations.append(baseline_record) + store.write_json_atomic(baseline_dir / "iteration.json", baseline_record) + store.write_event("iteration_finished", iteration=0, status="baseline") + + for index in range(1, config.budget.max_iterations + 1): + iteration_dir = store.iteration_dir(index) + store.write_event("iteration_started", iteration=index, status="pending") + evidence = _read_evidence( + accepted_episodes, + config.budget.max_episode_reads - ledger.episode_reads, + config.budget.max_decision_reads - ledger.decision_reads, + ) + decision_reads = sum(len(item["observations"]) for item in evidence) + try: + ledger.charge_read(len(evidence), decision_reads) + messages = build_messages( + accepted_source, _skills(store), evidence, iterations[-1], ledger.snapshot(), + ) + store.write_json_atomic(iteration_dir / "llm_request.json", { + "messages": messages, + "model": config.llm.model, + "base_url": config.llm.base_url, + "temperature": config.llm.temperature, + "max_tokens": config.llm.max_tokens, + "reasoning_effort": config.llm.reasoning_effort, + "stream": config.llm.stream, + "max_context_tokens": config.llm.max_context_tokens, + }) + store.write_event("llm_request_started", iteration=index, model=config.llm.model) + proposal = client.propose_strategy(messages) + store.write_json_atomic(iteration_dir / "llm_request.json", { + "messages": messages, "request_body": proposal.request_body, + }) + store.write_json_atomic(iteration_dir / "llm_response.json", { + "raw_response": proposal.raw_response, + "analysis": proposal.analysis, + "usage": proposal.usage, + "latency_seconds": proposal.latency_seconds, + "normalized_fence": proposal.normalized_fence, + }) + ledger.charge_api_time(proposal.latency_seconds) + ledger.charge_usage(proposal.usage) + ledger.charge_context( + proposal.usage.get("total_tokens", 0), + config.llm.max_context_tokens, + ) + store.write_event( + "llm_request_finished", iteration=index, + latency_seconds=proposal.latency_seconds, usage=proposal.usage, + ) + candidate_path = iteration_dir / "candidate.py" + save_source(candidate_path, proposal.strategy_code) + candidate = load_candidate(candidate_path, f"candidate_validation_{index}") + validate_candidate(candidate) + except (LLMRequestError, StrategyValidationError, BudgetExceeded) as exc: + stage = getattr(exc, "stage", getattr(exc, "dimension", "unknown")) + raw = getattr(exc, "raw_response", None) + if isinstance(exc, LLMRequestError): + ledger.charge_api_time(exc.latency_seconds) + ledger.charge_usage(exc.usage) + try: + ledger.charge_context( + exc.usage.get("total_tokens", 0), + config.llm.max_context_tokens, + ) + except BudgetExceeded as context_exc: + exc = context_exc + stage = context_exc.dimension + failure_counts[stage] += 1 + if raw is not None and not (iteration_dir / "llm_response.json").exists(): + store.write_json_atomic(iteration_dir / "llm_response.json", { + "raw_response": raw, "error": str(exc), "stage": stage, + }) + failed = { + "iteration": index, "version": accepted_version, "status": "failed", + "failure_stage": stage, "failure_reason": str(exc), "score": None, + "win_rate": None, "completion_rate": 0.0, "budget": ledger.snapshot(), + } + iterations.append(failed) + store.write_json_atomic(iteration_dir / "iteration.json", failed) + store.write_event("iteration_failed", iteration=index, stage=stage, reason=str(exc)) + continue + + strategy_path = iteration_dir / "strategy.py" + save_source(strategy_path, proposal.strategy_code) + new_version = _version(proposal.strategy_code) + evolved_score, latest_episodes = _evaluate( + source_path=strategy_path, iteration=index, config=config, store=store, + ledger=ledger, match_runner=match_runner, + ) + total_episodes += len(latest_episodes) + total_steps += sum(row["rounds"] for row in latest_episodes) + for episode in latest_episodes: + old_agent = load_candidate(accepted_path, f"ig_old_{index}_{episode['episode_id']}") + new_agent = load_candidate(strategy_path, f"ig_new_{index}_{episode['episode_id']}") + ig_episode = compare_agents_on_trace( + episode["trace"], old_agent, new_agent, + camp=episode["candidate_camp"], iteration=index, + old_version=accepted_version, new_version=new_version, + ) + ig_episodes.append(ig_episode) + store.write_json_atomic( + iteration_dir / "ig" / f"{ig_episode['episode_id']}.json", ig_episode, + ) + record = { + "iteration": index, "version": new_version, "status": "accepted", + "score": evolved_score["mean_score"], "win_rate": evolved_score["win_rate"], + "completion_rate": evolved_score["completion_rate"], + "score_detail": evolved_score, "analysis": proposal.analysis, + "budget": ledger.snapshot(), + } + iterations.append(record) + store.write_json_atomic(iteration_dir / "iteration.json", record) + store.write_event("iteration_finished", iteration=index, status="accepted") + accepted_source = proposal.strategy_code + accepted_path = strategy_path + accepted_version = new_version + accepted_episodes = latest_episodes + + score_curve = build_score_curve(iterations) + versions = {int(row["iteration"]): str(row["version"]) for row in iterations} + ig_curve = build_ig_curve(ig_episodes, versions=versions) + store.write_json_atomic(store.run_dir / "score_curve.json", score_curve) + store.write_json_atomic(store.run_dir / "ig_curve.json", ig_curve) + points = score_curve["points"] + measured = [point for point in points if point["evo"] is not None] + final = measured[-1] if measured else {"evo": None, "gain": None, "win_rate": None} + best = max(measured, key=lambda point: point["evo"]) if measured else None + budget = ledger.snapshot() + summary = { + "status": "complete", + "total_iterations": config.budget.max_iterations + 1, + "total_episodes": total_episodes, + "total_steps": total_steps, + "win_rate": final.get("win_rate"), + "raw": points[0]["raw"] if points else None, + "final_evo": final.get("evo"), + "final_gain": final.get("gain"), + "best_evo": best.get("evo") if best else None, + "best_iteration": best.get("iteration") if best else None, + "failure_counts": dict(failure_counts), + "score_history": points, + "ig_history": ig_curve["points"], + "auc": score_curve["auc"], + **budget, + } + store.finish(summary) + return store.run_dir diff --git a/src/agentbench_frame/miracle/loop_config.py b/src/agentbench_frame/miracle/loop_config.py new file mode 100644 index 0000000..55a7aae --- /dev/null +++ b/src/agentbench_frame/miracle/loop_config.py @@ -0,0 +1,138 @@ +"""Miracle 高层迭代 Loop 的 TOML 配置。""" + +from __future__ import annotations + +import tomllib +from dataclasses import asdict, dataclass +from pathlib import Path + +from .agent_bridge import AGENTS + + +@dataclass(frozen=True) +class LLMConfig: + base_url: str + api_key_env: str + model: str + temperature: float = 0.0 + max_tokens: int | None = None + timeout_seconds: float = 120.0 + reasoning_effort: str | None = None + stream: bool = True + max_context_tokens: int = 1_000_000 + + +@dataclass(frozen=True) +class EvaluationConfig: + seeds: tuple[int, ...] = (11,) + seats: tuple[int, ...] = (0, 1) + + +@dataclass(frozen=True) +class BudgetConfig: + max_iterations: int + max_rollouts: int + max_episode_reads: int + max_decision_reads: int + max_total_tokens: int + max_wall_seconds: float + + +@dataclass(frozen=True) +class LoopConfig: + agent: str + initial_strategy: Path + opponent: str + llm: LLMConfig + evaluation: EvaluationConfig + budget: BudgetConfig + + @classmethod + def from_toml(cls, path: Path | str) -> "LoopConfig": + path = Path(path).resolve() + with path.open("rb") as stream: + raw = tomllib.load(stream) + + agent = str(raw.get("agent", "")).strip() + if not agent: + raise ValueError("agent must be nonempty") + initial_value = str(raw.get("initial_strategy", "")).strip() + initial = (path.parent / initial_value).resolve() if initial_value else Path() + if not initial_value or not initial.is_file(): + raise ValueError(f"initial_strategy does not exist: {initial_value!r}") + opponent = str(raw.get("opponent", "")).strip() + if opponent not in AGENTS: + raise ValueError(f"opponent is not registered: {opponent!r}") + + llm_raw = raw.get("llm", {}) + llm = LLMConfig( + base_url=str(llm_raw.get("base_url", "")).strip(), + api_key_env=str(llm_raw.get("api_key_env", "")).strip(), + model=str(llm_raw.get("model", "")).strip(), + temperature=float(llm_raw.get("temperature", 0.0)), + max_tokens=( + int(llm_raw["max_tokens"]) + if llm_raw.get("max_tokens") is not None else None + ), + timeout_seconds=float(llm_raw.get("timeout_seconds", 120.0)), + reasoning_effort=( + str(llm_raw["reasoning_effort"]).strip() + if llm_raw.get("reasoning_effort") is not None else None + ), + stream=llm_raw.get("stream", True), + max_context_tokens=int(llm_raw.get("max_context_tokens", 1_000_000)), + ) + if not llm.base_url or not llm.model: + raise ValueError("llm.base_url and llm.model must be nonempty") + if llm.max_tokens is not None and llm.max_tokens <= 0: + raise ValueError("llm.max_tokens must be positive when configured") + if llm.timeout_seconds <= 0 or llm.max_context_tokens <= 0: + raise ValueError("llm.timeout_seconds and llm.max_context_tokens must be positive") + if not isinstance(llm.stream, bool): + raise ValueError("llm.stream must be true or false") + if llm.reasoning_effort not in (None, "low", "medium", "high"): + raise ValueError("llm.reasoning_effort must be low, medium, or high") + + eval_raw = raw.get("evaluation", {}) + evaluation = EvaluationConfig( + seeds=tuple(int(value) for value in eval_raw.get("seeds", [11])), + seats=tuple(int(value) for value in eval_raw.get("seats", [0, 1])), + ) + if not evaluation.seeds: + raise ValueError("evaluation.seeds must be nonempty") + if not evaluation.seats or any(seat not in (0, 1) for seat in evaluation.seats): + raise ValueError("evaluation.seats must be a nonempty subset of [0, 1]") + + budget_raw = raw.get("budget", {}) + required = ( + "max_iterations", "max_rollouts", "max_episode_reads", + "max_decision_reads", "max_total_tokens", "max_wall_seconds", + ) + missing = [name for name in required if name not in budget_raw] + if missing: + raise ValueError(f"budget missing fields: {', '.join(missing)}") + budget = BudgetConfig( + max_iterations=int(budget_raw["max_iterations"]), + max_rollouts=int(budget_raw["max_rollouts"]), + max_episode_reads=int(budget_raw["max_episode_reads"]), + max_decision_reads=int(budget_raw["max_decision_reads"]), + max_total_tokens=int(budget_raw["max_total_tokens"]), + max_wall_seconds=float(budget_raw["max_wall_seconds"]), + ) + for name, value in asdict(budget).items(): + if value <= 0: + raise ValueError(f"budget.{name} must be positive") + return cls(agent, initial, opponent, llm, evaluation, budget) + + def public_dict(self) -> dict: + return { + "agent": self.agent, + "initial_strategy": str(self.initial_strategy), + "opponent": self.opponent, + "llm": asdict(self.llm), + "evaluation": { + "seeds": list(self.evaluation.seeds), + "seats": list(self.evaluation.seats), + }, + "budget": asdict(self.budget), + } diff --git a/src/agentbench_frame/miracle/match.py b/src/agentbench_frame/miracle/match.py new file mode 100644 index 0000000..86aee88 --- /dev/null +++ b/src/agentbench_frame/miracle/match.py @@ -0,0 +1,91 @@ +"""高层对局运行器(自己实现):拼装 logic 子进程 + host + 两个 Agent。 + +``run_match`` 负责: +- 创建 replay 输出路径(官方 logic 会在 init 消息指定的路径写入 replay 二进制) +- 以子进程启动官方逻辑(可注入 random seed 保证可复现) +- 运行 ``MiracleHost`` 驱动整场对局,返回 ``MatchResult`` +- 回收子进程、收集 stderr 尾部便于排障 +""" + +from __future__ import annotations + +import os +import tempfile +import time +from pathlib import Path +from typing import Optional + +from .agent_bridge import MiracleAgent +from .host import DEFAULT_DECISION_TIMEOUT, DEFAULT_IDLE_TIMEOUT, MatchResult, MiracleHost +from .logic_runner import resolve_official_dir, start_logic + +__all__ = ["run_match", "DEFAULT_REPLAY_DIR"] + +#: 默认 replay 输出目录(调用方可覆盖) +DEFAULT_REPLAY_DIR = Path("agentbench_data") / "replays" / "24_miracle" + + +def run_match( + agent0: MiracleAgent, + agent1: MiracleAgent, + *, + replay_dir: Optional[str | os.PathLike] = None, + trace_dir: Optional[str | os.PathLike] = None, + seed: Optional[int] = None, + official_dir: Optional[str | os.PathLike] = None, + decision_timeout: float = DEFAULT_DECISION_TIMEOUT, + idle_timeout: float = DEFAULT_IDLE_TIMEOUT, + tag: str = "", +) -> MatchResult: + """跑一场完整对局。返回 MatchResult(含 replay/trace 路径)。 + + 参数: + - ``replay_dir``:replay 落盘目录(默认 ``agentbench_data/replays/24_miracle``) + - ``trace_dir``:trace jsonl 落盘目录(默认与 replay 同目录) + - ``seed``:官方逻辑随机种子(影响地图类型/昼夜),None 则随机 + - ``official_dir``:官方逻辑目录(默认包内 official_logic/) + """ + official = resolve_official_dir(official_dir) + replay_dir = Path(replay_dir) if replay_dir else DEFAULT_REPLAY_DIR + replay_dir.mkdir(parents=True, exist_ok=True) + trace_dir = Path(trace_dir) if trace_dir else replay_dir + trace_dir.mkdir(parents=True, exist_ok=True) + + stamp = time.strftime("%Y%m%d-%H%M%S") + tag_part = f"_{tag}" if tag else "" + fname = f"match{tag_part}_{stamp}{'_seed' + str(seed) if seed is not None else ''}.mrc" + # 绝对路径:官方逻辑子进程的 cwd 是 official_logic,相对路径会解析错 + replay_path = str((replay_dir / fname).resolve()) + trace_path = str((trace_dir / f"{fname}.trace.jsonl").resolve()) + + proc = start_logic(official, seed=seed) + result = None + try: + host = MiracleHost( + proc, + (agent0, agent1), + decision_timeout=decision_timeout, + idle_timeout=idle_timeout, + trace_path=trace_path, + ) + result = host.run(replay_path) + finally: + # 兜底回收子进程 + if proc.poll() is None: + try: + proc.kill() + except OSError: + pass + stderr_tail = b"" + try: + if proc.stderr: + stderr_tail = proc.stderr.read(4096) + except OSError: + pass + try: + proc.wait(timeout=5) + except Exception: + pass + if result is not None: + result.stderr_tail = stderr_tail.decode("utf-8", errors="replace")[-2000:] + return result diff --git a/src/agentbench_frame/miracle/official_logic/Data.json b/src/agentbench_frame/miracle/official_logic/Data.json new file mode 100644 index 0000000..9b80343 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/Data.json @@ -0,0 +1,157 @@ +{ + "CreatureCapacityLevelUpTurn": [51,76], + "UnitData": { + "Archer": { + "cost": [2,4,6], + "atk": [1,2,3], + "hp": [2,3,4], + "atk_range": [[3,4],[3,4],[3,4]], + "max_move": [3,3,3], + "cool_down": [4,4,4], + "duplicate": [3,4,5], + "flying": false, + "atk_flying": true, + "agility": false, + "holy_shield": false + }, + "Swordsman": { + "cost": [2,4,6], + "atk": [2,4,6], + "hp": [2,4,6], + "atk_range": [[1,1],[1,1],[1,1]], + "max_move": [3,3,3], + "cool_down": [2,2,3], + "duplicate": [6,7,8], + "flying": false, + "atk_flying": false, + "agility": false, + "holy_shield": false + }, + "BlackBat": { + "cost": [2,3,6], + "atk": [1,2,4], + "hp": [1,1,2], + "atk_range": [[0,1],[0,1],[0,1]], + "max_move": [4,4,5], + "cool_down": [3,3,4], + "duplicate": [3,4,5], + "flying": true, + "atk_flying": true, + "agility": false, + "holy_shield": false + }, + "Priest": { + "cost": [2,4,7], + "atk": [0,0,0], + "hp": [3,4,6], + "atk_range": [[0,1],[0,1],[0,2]], + "max_move": [5,5,5], + "cool_down": [3,3,5], + "duplicate": [3,4,4], + "flying": false, + "atk_flying": true, + "agility": false, + "holy_shield": false, + "heal": [1,1,1], + "heal_range": [2,3,3], + "atk_up": [1,1,1], + "atk_up_range": [2,3,3] + }, + "VolcanoDragon": { + "cost": [5,7,9], + "atk": [3,4,5], + "hp": [5,7,9], + "atk_range": [[1,2],[1,2],[1,2]], + "max_move": [2,2,2], + "cool_down": [5,5,5], + "duplicate": [3,4,5], + "flying": false, + "atk_flying": false, + "agility": false, + "holy_shield": false, + "splash_damage": [3,4,5], + "self_range": 2, + "target_range": 1 + }, + "Inferno": { + "cost": [0], + "atk": [8], + "hp": [12], + "atk_range": [[0,1]], + "max_move": [3], + "cool_down": [999], + "duplicate": [1], + "flying": false, + "atk_flying": true, + "agility": false, + "holy_shield": false + }, + "FrostDragon": { + "cost": [5,7,9], + "atk": [3,4,5], + "hp": [4,6,8], + "atk_range": [[0,2],[0,2],[0,2]], + "max_move": [2,2,2], + "cool_down": [4,4,5], + "duplicate": [3,4,5], + "flying": false, + "atk_flying": true, + "agility": false, + "holy_shield": false + } + }, + "Artifacts": { + "HolyLight": { + "target_type": "Pos", + "cost": 6, + "cool_down": 5, + "affect_range": 2, + "atk_up": 2, + "effect_rounds": 4 + }, + "SalamanderShield": { + "target_type": "Unit", + "cost": 6, + "cool_down": 4, + "hp_up": 3 + }, + "InfernoFlame": { + "target_type": "Pos", + "cost": 8, + "cool_down": 6, + "damage": 2, + "affect_range": 2, + "summon": "Inferno" + }, + "WindBlessing": { + "target_type": "Pos", + "cost": 8, + "cool_down": 12, + "affect_range": 1 + } + }, + "UnitNameParsed": { + "Archer": 0, + "Swordsman": 1, + "BlackBat": 2, + "Priest": 3, + "VolcanoDragon": 4, + "Inferno": 5, + "FrostDragon": 6 + }, + "ArtifactNameParsed": { + "HolyLight": 0, + "SalamanderShield": 1, + "InfernoFlame": 2, + "WindBlessing": 3 + }, + "ArtifactStateParsed": { + "Ready": 0, + "In Use": 1, + "Cooling Down":2 + }, + "ArtifactTargetParsed": { + "Pos": 0, + "Unit": 1 + } +} \ No newline at end of file diff --git a/src/agentbench_frame/miracle/official_logic/Geometry/__init__.py b/src/agentbench_frame/miracle/official_logic/Geometry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agentbench_frame/miracle/official_logic/Geometry/calculator.py b/src/agentbench_frame/miracle/official_logic/Geometry/calculator.py new file mode 100644 index 0000000..808f92c --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/Geometry/calculator.py @@ -0,0 +1,234 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +''' +calculator for hex-grids +''' + +def MAPBORDER(): + ''' + return map border''' + # left-up, right-up, left-down, right-down, left-right, up, down + border = [(-6+i, -9, 15-i) for i in range(0, 14)] + \ + [(9, 6-i, -15+i) for i in range(0, 14)] + \ + [(-9, -6+i, 15-i) for i in range(0, 14)] + \ + [(6-i, 9, -15+i) for i in range(0, 14)] + \ + [(-7, -8, 15), (-8, -7, 15), (8, 7, -15), (7, 8, -15)] + \ + [(7, -8, 1), (8, -8, 0), (8, -7, -1)] + \ + [(-8, 7, 1), (-8, 8, 0), (-7, 8, -1)] + return border + +def cube_distance(a, b): + ''' + return distance between two unit, a/b is position + ''' + try: + distance = (abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2]))/2 + except KeyError: + raise ValueError("Point format wrong: a: %s, b:%s"%(str(a), str(b))) + return int(distance + 1e-8) + +def cube_neighbor(pos, dir): + ''' + neighbor of pos, dir ranges from 0 to 5 + ''' + _dir = dir%6 + if _dir == 0: # first neighbor + neighbor = (pos[0]+1, pos[1], pos[2]-1) + elif _dir == 1: + neighbor = (pos[0]+1, pos[1]-1, pos[2]) + elif _dir == 2: + neighbor = (pos[0], pos[1]-1, pos[2]+1) + elif _dir == 3: + neighbor = (pos[0]-1, pos[1], pos[2]+1) + elif _dir == 4: + neighbor = (pos[0]-1, pos[1]+1, pos[2]) + else: + neighbor = (pos[0], pos[1]+1, pos[2]-1) + return neighbor + +class Node: + def __init__(self, pos, G, H, parent=None): + self.pos = tuple(pos) + self.G = G + self.H = H + self.parent = parent + + def __str__(self): + return ''' + pos: {}, + G: {}, + F: {}, + '''.format(self.pos, self.G, self.H) + +def search_path(start, to, obstacles=[], obstructs=[]): + ''' + return shortest path + ''' + #_start = () + #_to = () + #for i in range(3): + # _start += (start[i],) + # _to += (to[i],) + _start = tuple(start) + _to = tuple(to) + if _to in obstacles: + #print("to: " + str(_to)) + #print(obstacles) + return False + opened = {} + closed = {} + opened[_start] = Node(start, 0, cube_distance(start, to)) + while opened: + cur_node = opened[min(opened, key=lambda x: opened[x].G + opened[x].H)] + #print("Opened: "+str(opened.keys())) + #print("cur node:" + str(cur_node.pos)) + for i in range(6): + neighbor = cube_neighbor(cur_node.pos, i) + if neighbor not in closed and neighbor not in obstacles: + if neighbor in opened: + if cur_node.G+1 < opened[neighbor].G: + opened[neighbor].G = cur_node.G + 1 + opened[neighbor].parent = cur_node + else: + opened[neighbor] = Node(neighbor, cur_node.G+1, cube_distance(neighbor, _to), cur_node) + if neighbor == _to: + final_path = [] + node = opened[neighbor] + while node is not None: + final_path.insert(0, node.pos) + node = node.parent + return final_path + elif neighbor in obstructs: + del opened[neighbor] + closed[tuple(cur_node.pos)] = cur_node + del opened[tuple(cur_node.pos)] + return False + +def cube_reachable(start, movement, obstacles=[], obstructs=[]): + ''' + return reachable position from start point in steps limited by movement + ''' + visited = [] # positions that have been visited + visited.append(start) + fringes = [] # list of list of reachable points in certain steps(subscripts means steps) + fringes.append([start]) + + for i in range(0, movement): + fringes.append([]) + for pos in fringes[i]: + if pos in obstructs: + continue + for j in range(0, 6): + neighbor = cube_neighbor(pos, j) + if neighbor not in visited and neighbor not in obstacles\ + and in_map(neighbor): + visited.append(neighbor) + fringes[i+1].append(neighbor) + return fringes + +def get_obstacles_by_unit(unit, _map): + ''' + returns all obstacles for a unit + ''' + obstacles = MAPBORDER() + #obstacles=[] + if unit.flying: + fixed_obstacles = _map.get_flying_obstacles() + else: + fixed_obstacles = _map.get_ground_obstacles() + for obstacle in fixed_obstacles: + obstacles.append(obstacle.pos) + obstacle_unit = _map.get_units() + for obstacle in obstacle_unit: + #if obstacle.camp != unit.camp: + if unit.flying == obstacle.flying: + obstacles.append(obstacle.pos) + return obstacles + +def get_obstructs_by_unit(unit, _map): + ''' + returns all obstructs for a unit, obstructs means the unit can + stay at that point but cannot pass it + ''' + obstructs = [] + obstacle_unit = _map.get_units() + for obstruct in obstacle_unit: + if obstruct.camp != unit.camp: + if obstruct.flying == unit.flying: + for i in range(0, 6): + obstructs.append(cube_neighbor(obstruct.pos, i)) + else: + obstructs.append(obstruct.pos) + if unit.pos in obstructs: + obstructs.remove(unit.pos) + return obstructs + +''' +below are public sdk +''' + +def path(unit, dest, _map): + ''' + public sdk for search_path + ''' + obstacles = get_obstacles_by_unit(unit, _map) + #print("mapborder: "+str(MAPBORDER())) + #print("obstacles:" + str(obstacles)) + obstructs = get_obstructs_by_unit(unit, _map) + #print("obstructs:" + str(obstructs)) + result = search_path(unit.pos, dest, obstacles, obstructs) + #print("Path:" + str(result)) + return result + +def reachable(unit, _map): + ''' + public sdk for cube_reachable + ''' + obstacles = get_obstacles_by_unit(unit, _map) + obstructs = get_obstructs_by_unit(unit, _map) + result = cube_reachable(unit.pos, unit.max_move, obstacles, obstructs) + return result + +def units_in_range(pos, dist, _map, camp=-1, flyingIncluded=True, onlandIncluded=True): + ''' + return list of units whose distance to the pos is less than dist + default camp = -1, return units of both camp, 0 for the first camp, 1 for the second + flyingIncluded = True will include flying units, + onlandIncluded = True will include onland units + ''' + units = [] + all_units = _map.get_units() + for _unit in all_units: + if cube_distance(_unit.pos, pos) <= dist and \ + (camp == -1 or camp == _unit.camp) and \ + ((_unit.flying and flyingIncluded) or \ + (not _unit.flying and onlandIncluded)): + units.append(_unit) + return units + +def in_map(pos): + ''' + return if the position in inside the map + ''' + if pos[0] > 8 or pos[0] < -8 or \ + pos[1] > 8 or pos[1] < -8 or \ + pos[2] >14 or pos[2] < -14: + return False + elif pos == (-7, 7, 0) or pos == (7, -7, 0): + return False + return True + +def all_pos_in_map(): + ''' + return all positions in map + ''' + all_pos = [] + for i in range(-8, 9): + for j in range(-8, 9): + cur_pos = (i, j, -(i+j)) + if in_map(cur_pos): + all_pos.append(cur_pos) + return all_pos + +if __name__ == "__main__": + print(MAPBORDER()) diff --git a/src/agentbench_frame/miracle/official_logic/PlayerLegality/__init__.py b/src/agentbench_frame/miracle/official_logic/PlayerLegality/__init__.py new file mode 100644 index 0000000..85e9a3b --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/PlayerLegality/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- + diff --git a/src/agentbench_frame/miracle/official_logic/PlayerLegality/operations.py b/src/agentbench_frame/miracle/official_logic/PlayerLegality/operations.py new file mode 100644 index 0000000..09372a0 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/PlayerLegality/operations.py @@ -0,0 +1,446 @@ +#/usr/bin/python +# -*- coding:utf-8 -*- +''' +classes of operations +''' + +from Geometry import calculator +from StateSystem.Event import Event +from StateSystem.UnitData import UNIT_DATA +from StateSystem.UnitData import ARTIFACTS + +def to_position(src): + ''' + turn a variable to postion (x, y, z) + ''' + try: + position = list(src) + sum_up = 0 + for i in range(3): + position[i] = int(position[i]) + sum_up += position[i] + position = tuple(position) + assert sum_up == 0 + return position + except Exception: + raise ValueError("Invalid postion: {}".format(src)) + +class AbstractOperation: + ''' + base class of all operations + ''' + def __init__(self, _parser, _id, _map): + self.parser = _parser + self.player_id = _id + self.map = _map + self.player = _map.get_player_by_id(_id) + if self.player is None: + raise ValueError("Invalid player id: {}".format(_id)) + + def check_legality(self): + ''' + check legality of this operation + ''' + + def act(self): + ''' + emit action event after legality check + ''' + def unit_conflict(self, unit, pos): + ''' + judge if unit will conflict with another unit + ''' + target = self.map.get_unit_at(pos, flying=unit.flying) + result = True + if target is None: + result = False + return result + + def get_unit_by_id(self, unit_id): + ''' + get unit by id + ''' + try: + _id = int(unit_id) + unit = self.map.get_unit_by_id(_id) + assert unit is not None + return unit + except Exception: + raise ValueError("Invalid unit id: {}".format(unit_id)) + + def get_artifact_by_id(self, artifact_id): + ''' + get artifact by id + ''' + try: + _id = int(artifact_id) + artifact = self.map.get_artifact_by_id(_id) + assert artifact is not None + return artifact + except Exception: + raise ValueError("Invalid artifact id: {}".format(artifact_id)) + +class Forbid(AbstractOperation): + ''' + operation of forbiding artifact and so on + depreciated + ''' + def __init__(self, _parser, _id, _map, _params): + AbstractOperation.__init__(self, _parser, _id, _map) + self.name = "Forbid" + self.type = _params["type"] + self.target = _params["target"] + + def check_legality(self): + return True + + def act(self): + pass + +class Select(AbstractOperation): + ''' + operation of selecting artifact and so on + depreciated + ''' + def __init__(self, _parser, _id, _map, _params): + AbstractOperation.__init__(self, _parser, _id, _map) + self.name = "Select" + self.type = _params["type"] + self.target = _params["target"] + + def check_legality(self): + return True + + def act(self): + pass + +class Init(AbstractOperation): + ''' + initialize status of a player + unchecked + ''' + def __init__(self, _parser, _id, _map, _params): + AbstractOperation.__init__(self, _parser, _id, _map) + self.name = "Init" + self.artifacts = _params["artifacts"] + self.creatures = _params["creatures"] + + def creature_legality(self): + all_creatures = list(UNIT_DATA) + for item in self.creatures: + if item not in all_creatures: + return False + return True + + def artifact_legality(self): + all_artifacts = [] + for art in ARTIFACTS: + all_artifacts.append(art) + for item in self.artifacts: + if item not in all_artifacts: + return False + return True + + def check_legality(self): + # 是否已经初始化过了? + if len(self.artifacts) != 1 or len(self.creatures) != 3: # 1张神器,3张生物 + return "Wrong number of cards" + elif len(self.creatures) != len(set(self.creatures)): # 生物互不相同 + return "Duplicate creatures" + elif not self.creature_legality(): # 生物是否存在 + return "Wrong creature name" + elif not self.artifact_legality(): # 神器是否存在 + return "Wrong artifact name" + return True + + def act(self): + self.map.emit( + Event("GameStart", { + "camp": int(self.player_id), + "cards": { + "artifacts": self.artifacts, + "creatures": self.creatures + } + })) + self.map.start_event_processing() + +class StartRound(AbstractOperation): + ''' + start stage of a new round + unchecked + ''' + def __init__(self, _parser, _id, _map): + AbstractOperation.__init__(self, _parser, _id, _map) + + def check_legality(self): + return True + + def act(self): + self.map.emit(Event("TurnStart")) + self.map.start_event_processing() + +class EndRound(AbstractOperation): + ''' + end of a round + unfinished + ''' + def __init__(self, _parser, _id, _map): + AbstractOperation.__init__(self, _parser, _id, _map) + + def check_legality(self): + return True + + def act(self): + self.map.emit(Event("TurnEnd")) + self.map.start_event_processing() + +class Surrender(AbstractOperation): + ''' + one player surrender to the other + ''' + def __init__(self, _parser, _id, _map): + AbstractOperation.__init__(self, _parser, _id, _map) + + def check_legality(self): + return True + + def act(self): + pass + +class AbstractAct(AbstractOperation): + ''' + abstract class for operations in battle(summon, move, attack) + ''' + def __init__(self, _parser, _id, _map): + AbstractOperation.__init__(self, _parser, _id, _map) + + def summoned_this_round(self, creature_id): + ''' + judge if target is summoned this round + ''' + return creature_id in self.player.newly_summoned_id_list + + def acted_this_round(self, creature_id): + ''' + check if the creature has acted this round + ''' + return creature_id in self.parser.moved or creature_id in self.parser.attacked + + +class Summon(AbstractAct): + ''' + summon creature + ''' + def __init__(self, _parser, _id, _map, _params): + AbstractAct.__init__(self, _parser, _id, _map) + self.name = "Summon" + self.type = _params["type"] + self.level = _params["level"] + self.position = to_position(_params["position"]) + self.all_type = UNIT_DATA.keys() + + def check_mana_cost(self): + ''' + check mana cost + ''' + return self.player.mana >= UNIT_DATA[self.type]["cost"][self.level-1] + + def check_unit_cool_down(self, _type): + ''' + check if the creature is in cool-down time + ''' + for creature in self.player.creature_capacity_list: + if creature.type == _type and creature.available_count > 0: + return True + return False + + def unit_conflict(self, creature_type, pos): + ''' + override, check if the summon position already had a creature on it + ''' + flying = UNIT_DATA[creature_type]["flying"] + target = self.map.get_unit_at(pos, flying=flying) + result = True + if target is None: + result = False + return result + + + def check_legality(self): + result = True + if self.type not in self.all_type: + result = "Invalid creature type" + elif self.level not in [1, 2, 3]: + result = "Invalid level" + elif self.position not in self.map.get_summon_pos_list(self.player_id): + result = "No barrack at the point" + elif self.unit_conflict(self.type, self.position): + result = "Unit conflict" + elif not self.check_unit_cool_down(self.type): + result = "Unit in cooling down" + elif not self.check_mana_cost(): + result = "Magic cost too high" + return result + + def act(self): + self.map.emit( + Event("Summon", { + "type": self.type, + "level": self.level, + "pos": self.position, + "camp": self.player_id + })) + self.map.start_event_processing() + +class Move(AbstractAct): + ''' + move creature + ''' + def __init__(self, _parser, _id, _map, _params): + AbstractAct.__init__(self, _parser, _id, _map) + self.name = "Move" + self.mover = self.get_unit_by_id(_params["mover"]) + self.position = to_position(_params["position"]) + + def acted_special_check(self): + if self.mover.agility: + return True + return False + + def check_legality(self): + result = True + path = calculator.path(self.mover, self.position, self.map) + if self.mover.camp != self.player_id: + result = "You cannot manipulate the unit of the other player" + elif self.unit_conflict(self.mover, self.position): + result = "Unit conflict: target: {}".format(self.position) + elif not path: # no path found + result = "No suitable path" + elif self.mover.max_move < len(path)-1: # path include start point, so len need -1 + result = "Out of reach: max move: {}, shortest path: {}"\ + .format(self.mover.max_move, path) + elif not self.mover.can_move: + result = "Has acted this round" + if result is not True: + result += "\nstart: {}, end: {}\n".format(self.mover.pos, self.position) + return result + + def act(self): + self.parser.moved.append(self.mover.id) + self.map.emit( + Event("Move", { + "source": self.mover, + "dest": self.position + })) + self.map.start_event_processing() + +class Attack(AbstractAct): + ''' + attack operation + ''' + def __init__(self, _parser, _id, _map, _params): + AbstractAct.__init__(self, _parser, _id, _map) + self.name = "Attack" + self.attacker = self.get_unit_by_id(_params["attacker"]) + target_id = _params["target"] + if target_id in (0, 1): + self.target = self.map.get_miracle_by_id(target_id) + else: + self.target = self.map.get_unit_by_id(_params["target"]) + + def acted_special_check(self): + if self.attacker.agility: + return True + return False + + def check_legality(self): + result = True + dist = calculator.cube_distance(self.attacker.pos, self.target.pos) + if self.attacker.camp != self.player_id: + result = "You cannot manipulate the unit of the other player" + elif self.target.camp == self.player_id: + result = "You cannot attack your allies" + elif self.attacker.atk <= 0: + result = "Attack below zero" + elif not self.attacker.can_atk: + result = "Has acted this round" + elif not self.attacker.atk_range[0] <= dist <= self.attacker.atk_range[-1]: + result = "Out of range:\nattack range: {}, target distance: {}"\ + .format(self.attacker.atk_range, dist) + elif self.target.hp <= 0: + result = "Target hp <= 0" + elif self.target.id != 0 and self.target.id != 1 and self.target.flying and not self.attacker.flying and not self.attacker.atk_flying: + result = "Cannot reach unit in sky" + if result is not True: + result += "\nattacker: {}\n, target: {}"\ + .format(self.attacker, self.target) + return result + + def act(self): + self.parser.attacked.append(self.attacker.id) + self.map.emit( + Event("Attack", { + "source": self.attacker, + "target": self.target + })) + self.map.start_event_processing() + +class Use(AbstractOperation): + ''' + use artifact + ''' + def __init__(self, _parser, _id, _map, _params): + AbstractOperation.__init__(self, _parser, _id, _map) + self.name = "Use" + # self.type = _params["type"] + self.artifact = self.get_artifact_by_id(_params["card"]) + if self.artifact.target_type == "Pos": + self.target = to_position(_params["target"]) + elif self.artifact.target_type == "Unit": + self.target = self.get_unit_by_id(_params["target"]) + else: + self.target = None + + def special_check(self): + ''' + special check for certain artifact + ''' + if self.artifact.name == "InfernoFlame": + miracle = self.map.get_miracle_by_id(self.player_id) + barracks = self.map.get_barracks(self.player_id) + abyss = [obstacle.pos for obstacle in self.map.get_ground_obstacles()] + # infilter abyss and miracle + if self.target in abyss or self.target == miracle.pos: + return False + in_range = False + for barrack in barracks: + if calculator.cube_distance(barrack.pos, self.target) <= 5: + in_range = True + if calculator.cube_distance(miracle.pos, self.target) <= 7: + in_range = True + return in_range and self.map.get_unit_at(self.target, flying = False) is None + + elif self.artifact.name == "HolyLight": + return calculator.in_map(self.target) + return True + + def check_legality(self): + result = True + if self.artifact.camp != self.player_id: + result = "That's not your artifact" + elif self.artifact.state != "Ready": + result = "The artifact is " + self.artifact.state + elif self.artifact.cost > self.player.mana: + result = "Insufficient mana" + elif not self.special_check(): + result = "Conditions not covered" + return result + + def act(self): + self.map.emit( + Event("ActivateArtifact", { + "camp": self.player_id, + "name": self.artifact.name, + "target": self.target + })) + self.map.start_event_processing() diff --git a/src/agentbench_frame/miracle/official_logic/PlayerLegality/player_legality.py b/src/agentbench_frame/miracle/official_logic/PlayerLegality/player_legality.py new file mode 100644 index 0000000..caf07b4 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/PlayerLegality/player_legality.py @@ -0,0 +1,118 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- + +''' + player legality check api +''' +from . import operations +from StateSystem.Event import Event +import json + +class Parser: + ''' + class of parser + ''' + def __init__(self, _map): + self.map = _map + self.summoned = [] + self.moved = [] + self.attacked = [] + self.round = 0 + + def set_round(self, _round): + ''' + update round + ''' + self.summoned = [] + self.moved = [] + self.attacked = [] + self.round = _round + self.map.emit(Event("TurnStart")) + self.map.start_event_processing() + + def parse(self, operation_json): + ''' + parse operation, check legality and emit responding event + ''' + try: + operation = json.loads(operation_json) + except json.decoder.JSONDecodeError: + raise Exception("error: json decode error") + try: + _round = int(operation["round"]) + except Exception: + raise Exception("error: round error") + + #check round number + if _round != self.round: + # return "Not the same round" + raise Exception("error: not the same round") + #create operation object + operation_object = self.to_object(operation) + if isinstance(operation_object, BaseException): + #return error message + #print(operation_object) + #return operation_object + raise operation_object + # check legality + try: + legality = operation_object.check_legality() + except Exception: + legality = str(Exception) + if legality is True: + #emit responding event + #print("emit " + operation_object.name) + try: + operation_object.act() + except Exception: + raise Exception("from StateSystem:"+str(Exception)) + #return "OK" + else: + #return error message + #print("emit " + operation_object.name + " error: " + str(legality)) + #return legality + raise Exception(operation_object.name + " error: " + str(legality)) + + def to_object(self, operation_json): + ''' + convert JSON to corresponding operation object + ''' + try: + operation_type = operation_json["operation_type"].lower() + player_id = int(operation_json["player"]) + params = operation_json["operation_parameters"] + if operation_type == "forbid": + operation_object = operations.Forbid(self, player_id, self.map, params) + elif operation_type == "select": + operation_object = operations.Select(self, player_id, self.map, params) + elif operation_type == "summon": + operation_object = operations.Summon(self, player_id, self.map, params) + elif operation_type == "move": + operation_object = operations.Move(self, player_id, self.map, params) + elif operation_type == "attack": + operation_object = operations.Attack(self, player_id, self.map, params) + elif operation_type == "use": + operation_object = operations.Use(self, player_id, self.map, params) + elif operation_type == "startround": + operation_object = operations.StartRound(self, player_id, self.map) + elif operation_type == "endround": + operation_object = operations.EndRound(self, player_id, self.map) + elif operation_type == "surrender": + operation_object = operations.Surrender(self, player_id, self.map) + elif operation_type == "init": + operation_object = operations.Init(self, player_id, self.map, params) + return operation_object + except KeyError as error: + return KeyError("From player legality, KeyError: " + str(error)) + except ValueError as error: + return ValueError("From player legalit, ValueError: " + str(error).split(':')[-1]) + except Exception as error: + return Exception("From player legality" + str(error)) + +if __name__ == "__main__": + example = { + "player": "0", + "operation_type": "Forbid", + "operation_parameters":{ + } + } diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/Artifact.py b/src/agentbench_frame/miracle/official_logic/StateSystem/Artifact.py new file mode 100644 index 0000000..bd80a7c --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/Artifact.py @@ -0,0 +1,218 @@ +from Geometry import calculator +from StateSystem.Event import Event +from StateSystem.Buff import Buff +from StateSystem.EventListener import EventListener +from StateSystem.Unit import Unit +from StateSystem.UnitData import ARTIFACT_NAME_PARSED,ARTIFACT_STATE_PARSED,ARTIFACT_TARGET_PARSED,ARTIFACTS + +ARTIFACT_ID = 0 + +def gen_artifact_by_name(name,camp,state_system): + if name == "HolyLight": + return HolyLightArtifact(camp,state_system) + elif name == "SalamanderShield": + return SalamanderShieldArtifact(camp,state_system) + elif name == "InfernoFlame": + return InfernoFlameArtifact(camp,state_system) + elif name == "WindBlessing": + return WindBlessingArtifact(camp,state_system) + else: + return None + +class Artifact: + def __init__(self,camp,name,state_system): + global ARTIFACT_ID + self.id = ARTIFACT_ID + ARTIFACT_ID += 1 + self.state_system = state_system + self.event_listener_list = [] + self.cost = ARTIFACTS[name]["cost"] + self.max_cool_down = ARTIFACTS[name]["cool_down"] + self.cool_down_time = 0 + self.state = "Ready" + self.camp = camp + self.name = name + self.last_used_pos = (-1,-1,-1) + self.target_type = ARTIFACTS[name]["target_type"] + + def add_event_listener(self,listener): + listener.host = self + self.event_listener_list.append(listener) + + def deal_event(self,event): + for listener in self.event_listener_list: + listener.deal_event(event) + + def emit(self,event): + self.state_system.emit(event) + + def parse(self): + return [ + self.id, + ARTIFACT_NAME_PARSED[self.name], + self.cost, + self.max_cool_down, + self.cool_down_time, + ARTIFACT_STATE_PARSED[self.state], + ARTIFACT_TARGET_PARSED[self.target_type], + list(self.last_used_pos) + ] + + def activate(self,target): + self.state = "In Use" + if(self.target_type == "Unit"): + self.last_used_pos = target.pos + else: + self.last_used_pos = target + self.state_system.get_player_by_id(self.camp).mana -= self.cost + self.effect(target) + + def effect(self,target): + pass + + def recycle(self): + self.state = "Cooling Down" + self.cool_down_time = self.max_cool_down + + def cool_down(self): + if self.state == "Cooling Down": + if self.cool_down_time > 0: + self.cool_down_time -= 1 + if self.cool_down_time == 0: + self.state = "Ready" + +class HolyLightArtifact(Artifact): + def __init__(self,camp,state_system): + Artifact.__init__(self,camp,"HolyLight",state_system) + + def effect(self,target): + for unit in self.state_system.map.unit_list: + if calculator.cube_distance(unit.pos,target) <= ARTIFACTS["HolyLight"]["affect_range"] \ + and unit.camp == self.camp: + self.emit(Event("Heal",{ + "source": self, + "target": unit, + "heal": unit.max_hp + },-1)) + new_buff = HolyLightAtkBuff(self.state_system) + new_buff.add_on(unit) + self.recycle() + +class RemoveOnEndTurnListener(EventListener): + def deal_event(self,event): + if event.name == "TurnEnd": + self.host.effect_rounds -= 1 + if self.host.effect_rounds == 0: + self.host.delete() + +class HolyLightAtkBuff(Buff): + def __init__(self,state_system): + Buff.__init__(self,state_system) + self.add_event_listener(RemoveOnEndTurnListener()) + self.type = "HolyLightAtkBuff" + self.effect_rounds = ARTIFACTS["HolyLight"]["effect_rounds"] + + def buff(self): + self.host.atk += ARTIFACTS["HolyLight"]["atk_up"] + + def debuff(self): + self.host.atk -= ARTIFACTS["HolyLight"]["atk_up"] + +class SalamanderShieldArtifact(Artifact): + def __init__(self,camp,state_system): + Artifact.__init__(self,camp,"SalamanderShield",state_system) + + def effect(self,target): + new_buff = SalamanderShieldBuff(self.state_system, self) + new_buff.add_on(target) + +class SalamanderShieldRefreshListener(EventListener): + def deal_event(self,event): + if event.name == "TurnStart" and self.host.host.state_system.current_player_id == self.host.host.camp \ + and not self.host.host.holy_shield: + self.host.emit(Event("BuffAdd",{ + "source": self.host.host, + "type": "HolyShield" + },-1)) + +class SalamanderShieldDeathRecycleListener(EventListener): + def deal_event(self,event): + if event.name == "Death" and event.parameter_dict["source"] == self.host.host: + self.host.artifact_host.recycle() + self.host.delete() + +class SalamanderShieldBuff(Buff): + def __init__(self,state_system,artifact_host): + self.artifact_host = artifact_host + Buff.__init__(self,state_system) + self.add_event_listener(SalamanderShieldRefreshListener()) + self.add_event_listener(SalamanderShieldDeathRecycleListener()) + self.type = "SalamanderShieldBuff" + + def buff(self): + self.host.max_hp += ARTIFACTS["SalamanderShield"]["hp_up"] + self.host.hp += ARTIFACTS["SalamanderShield"]["hp_up"] + if not self.host.holy_shield: + self.state_system.emit(Event("BuffAdd",{ + "source": self.host, + "type": "HolyShield" + },1)) + + def debuff(self): + self.host.max_hp -= ARTIFACTS["SalamanderShield"]["hp_up"] + self.host.hp = min(self.host.hp, self.host.max_hp) + +class InfernoFlameArtifact(Artifact): + def __init__(self,camp,state_system): + Artifact.__init__(self,camp,"InfernoFlame",state_system) + + def effect(self,target): + for unit in self.state_system.map.unit_list: + if calculator.cube_distance(unit.pos,target) <= ARTIFACTS[self.name]["affect_range"] \ + and unit.camp != self.camp: + self.emit(Event("Damage",{ + "source": self, + "target": unit, + "damage": ARTIFACTS[self.name]["damage"], + "type": "InfernoFlameActivate" + },-3)) + self.emit(Event("Summon",{ + "type": ARTIFACTS[self.name]["summon"], + "level": 1, + "pos": target, + "camp": self.camp, + "artifact_host": self + })) + self.emit(Event("CheckDeath",priority=4)) + +class Inferno(Unit): + def __init__(self,camp,level,pos,state_system,artifact_host): + name = "Inferno" + self.artifact_host = artifact_host + Unit.__init__( + self, + camp, + name, + level, # Only a single level + pos, + state_system + ) + + self.add_event_listener(InfernoRecycleListener()) + +class InfernoRecycleListener(EventListener): + def deal_event(self,event): + if event.name == "Death" and event.parameter_dict["source"] == self.host: + self.host.artifact_host.recycle() + +class WindBlessingArtifact(Artifact): + def __init__(self,camp,state_system): + Artifact.__init__(self,camp,"WindBlessing",state_system) + + def effect(self,target): + for unit in self.state_system.map.unit_list: + if calculator.cube_distance(unit.pos,target) <= ARTIFACTS["WindBlessing"]["affect_range"] \ + and unit.camp == self.camp: + unit.can_atk = True + unit.can_move = True + self.recycle() \ No newline at end of file diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/Barrack.py b/src/agentbench_frame/miracle/official_logic/StateSystem/Barrack.py new file mode 100644 index 0000000..b8b6957 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/Barrack.py @@ -0,0 +1,15 @@ +class Barrack: + def __init__(self,pos,camp,summon_pos_list): + self.pos = pos + self.camp = camp + self.summon_pos_list = summon_pos_list + + def parse(self): + return self.camp + +BARRACK_INIT_LIST = [ + ((-6,-6,12), -1, [(-7,-5,12), (-5,-7,12), (-5,-6,11)]), + ((6,6,-12), -1, [(7,5,-12), (5,7,-12), (5,6,-11)]), + ((0,-5,5), -1, [(0,-4,4), (-1,-4,5), (-1,-5,6)]), + ((0,5,-5), -1, [(0,4,-4), (1,4,-5), (1,5,-6)]) +] \ No newline at end of file diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/Buff.py b/src/agentbench_frame/miracle/official_logic/StateSystem/Buff.py new file mode 100644 index 0000000..64422e0 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/Buff.py @@ -0,0 +1,58 @@ +from StateSystem.Event import Event +from StateSystem.UnitData import UNIT_DATA + +class Buff: + def __init__(self,state_system): + self.state_system = state_system + self.event_listener_list = [] + self.host = None + self.type = "BaseBuff" + + def add_event_listener(self,listener): + listener.host = self + self.event_listener_list.append(listener) + + def deal_event(self,event): + for listener in self.event_listener_list: + listener.deal_event(event) + + def emit(self,event): + self.state_system.emit(event) + + def buff(self): + pass + + def debuff(self): + pass + + def add_on(self,host): + # host is a unit + self.host = host + host.buff_list.append(self) + self.state_system.emit(Event("BuffAdd",{ + "source": self.host, + "type": self.type + })) + self.buff() + + def delete(self): + self.host.buff_list.remove(self) + self.state_system.emit(Event("BuffRemove",{ + "source": self.host, + "type": self.type + })) + self.debuff() + self.host = None + self.event_listener_list = [] + +class PriestAtkBuff(Buff): + def __init__(self,level,state_system): + Buff.__init__(self,state_system) + self.level = level + self.type = "PriestAtkBuff" + + def buff(self): + self.host.atk += UNIT_DATA["Priest"]["atk_up"][self.level-1] + + def debuff(self): + self.host.atk -= UNIT_DATA["Priest"]["atk_up"][self.level-1] diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/CreatureCapacity.py b/src/agentbench_frame/miracle/official_logic/StateSystem/CreatureCapacity.py new file mode 100644 index 0000000..2385237 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/CreatureCapacity.py @@ -0,0 +1,38 @@ +from .UnitData import UNIT_DATA,UNIT_NAME_PARSED + +class CreatureCapacity: + def __init__(self,name): + self.type = name + self.duplicate_level = 0 # 用于之后增加容量 + self.duplicate = UNIT_DATA[name]["duplicate"][self.duplicate_level] + self.cool_down_list = [] + self.available_count = self.duplicate + + def duplicate_level_up(self): + self.duplicate_level += 1 + self.available_count -= self.duplicate + self.duplicate = UNIT_DATA[self.type]["duplicate"][self.duplicate_level] + self.available_count += self.duplicate + + def cool_down(self): + self.cool_down_list = [item - 1 for item in self.cool_down_list] + new_list = [] + for item in self.cool_down_list: + if item != 0: + new_list.append(item) + else: + self.available_count += 1 + self.cool_down_list = new_list + + def summon(self): + self.available_count -= 1 + + def new_cool_down(self,level): + self.cool_down_list.append(UNIT_DATA[self.type]["cool_down"][level-1]) + + def parse(self): + return [ + UNIT_NAME_PARSED[self.type], + self.available_count, + self.cool_down_list + ] \ No newline at end of file diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/Data.json b/src/agentbench_frame/miracle/official_logic/StateSystem/Data.json new file mode 100644 index 0000000..9b80343 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/Data.json @@ -0,0 +1,157 @@ +{ + "CreatureCapacityLevelUpTurn": [51,76], + "UnitData": { + "Archer": { + "cost": [2,4,6], + "atk": [1,2,3], + "hp": [2,3,4], + "atk_range": [[3,4],[3,4],[3,4]], + "max_move": [3,3,3], + "cool_down": [4,4,4], + "duplicate": [3,4,5], + "flying": false, + "atk_flying": true, + "agility": false, + "holy_shield": false + }, + "Swordsman": { + "cost": [2,4,6], + "atk": [2,4,6], + "hp": [2,4,6], + "atk_range": [[1,1],[1,1],[1,1]], + "max_move": [3,3,3], + "cool_down": [2,2,3], + "duplicate": [6,7,8], + "flying": false, + "atk_flying": false, + "agility": false, + "holy_shield": false + }, + "BlackBat": { + "cost": [2,3,6], + "atk": [1,2,4], + "hp": [1,1,2], + "atk_range": [[0,1],[0,1],[0,1]], + "max_move": [4,4,5], + "cool_down": [3,3,4], + "duplicate": [3,4,5], + "flying": true, + "atk_flying": true, + "agility": false, + "holy_shield": false + }, + "Priest": { + "cost": [2,4,7], + "atk": [0,0,0], + "hp": [3,4,6], + "atk_range": [[0,1],[0,1],[0,2]], + "max_move": [5,5,5], + "cool_down": [3,3,5], + "duplicate": [3,4,4], + "flying": false, + "atk_flying": true, + "agility": false, + "holy_shield": false, + "heal": [1,1,1], + "heal_range": [2,3,3], + "atk_up": [1,1,1], + "atk_up_range": [2,3,3] + }, + "VolcanoDragon": { + "cost": [5,7,9], + "atk": [3,4,5], + "hp": [5,7,9], + "atk_range": [[1,2],[1,2],[1,2]], + "max_move": [2,2,2], + "cool_down": [5,5,5], + "duplicate": [3,4,5], + "flying": false, + "atk_flying": false, + "agility": false, + "holy_shield": false, + "splash_damage": [3,4,5], + "self_range": 2, + "target_range": 1 + }, + "Inferno": { + "cost": [0], + "atk": [8], + "hp": [12], + "atk_range": [[0,1]], + "max_move": [3], + "cool_down": [999], + "duplicate": [1], + "flying": false, + "atk_flying": true, + "agility": false, + "holy_shield": false + }, + "FrostDragon": { + "cost": [5,7,9], + "atk": [3,4,5], + "hp": [4,6,8], + "atk_range": [[0,2],[0,2],[0,2]], + "max_move": [2,2,2], + "cool_down": [4,4,5], + "duplicate": [3,4,5], + "flying": false, + "atk_flying": true, + "agility": false, + "holy_shield": false + } + }, + "Artifacts": { + "HolyLight": { + "target_type": "Pos", + "cost": 6, + "cool_down": 5, + "affect_range": 2, + "atk_up": 2, + "effect_rounds": 4 + }, + "SalamanderShield": { + "target_type": "Unit", + "cost": 6, + "cool_down": 4, + "hp_up": 3 + }, + "InfernoFlame": { + "target_type": "Pos", + "cost": 8, + "cool_down": 6, + "damage": 2, + "affect_range": 2, + "summon": "Inferno" + }, + "WindBlessing": { + "target_type": "Pos", + "cost": 8, + "cool_down": 12, + "affect_range": 1 + } + }, + "UnitNameParsed": { + "Archer": 0, + "Swordsman": 1, + "BlackBat": 2, + "Priest": 3, + "VolcanoDragon": 4, + "Inferno": 5, + "FrostDragon": 6 + }, + "ArtifactNameParsed": { + "HolyLight": 0, + "SalamanderShield": 1, + "InfernoFlame": 2, + "WindBlessing": 3 + }, + "ArtifactStateParsed": { + "Ready": 0, + "In Use": 1, + "Cooling Down":2 + }, + "ArtifactTargetParsed": { + "Pos": 0, + "Unit": 1 + } +} \ No newline at end of file diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/Event.py b/src/agentbench_frame/miracle/official_logic/StateSystem/Event.py new file mode 100644 index 0000000..4fd135f --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/Event.py @@ -0,0 +1,19 @@ +EVENT_ID = 0 + +class Event: + def __init__(self,name,parameter_dict = {},priority = 0): + global EVENT_ID + self.id = EVENT_ID + EVENT_ID += 1 + self.name = name + self.priority = priority + self.parameter_dict = parameter_dict + + def __lt__(self,other): + return (self.priority < other.priority) or \ + (self.priority == other.priority and self.id < other.id) + + def __str__(self): + return '''Event: {} + Priority: {} + Parameters: {}'''.format(self.name,self.priority,self.parameter_dict) \ No newline at end of file diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/EventHeap.py b/src/agentbench_frame/miracle/official_logic/StateSystem/EventHeap.py new file mode 100644 index 0000000..d8c17f7 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/EventHeap.py @@ -0,0 +1,20 @@ +import heapq +from . import Event +from . import Unit +from . import EventListener + +class EventHeap: + def __init__(self): + self.data = [] + self.record = [] + + def append(self,item): + heapq.heappush(self.data,item) + + def pop(self): + poper = heapq.heappop(self.data) + self.record.append(poper) + return poper + + def len(self): + return len(self.data) diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/EventListener.py b/src/agentbench_frame/miracle/official_logic/StateSystem/EventListener.py new file mode 100644 index 0000000..bcdfadb --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/EventListener.py @@ -0,0 +1,196 @@ +from StateSystem.Event import Event +from Geometry import calculator +from StateSystem.Buff import PriestAtkBuff +from StateSystem.UnitData import UNIT_DATA + +class EventListener: + def __init__(self): + self.host = None # where listener is + + def deal_event(self,event): + pass + +class RefreshMoveAtkListener(EventListener): + def deal_event(self,event): + if event.name == "Refresh" and event.parameter_dict["camp"] == self.host.camp: + self.host.can_move = True + self.host.can_atk = True + +class OneMoveListener(EventListener): + def deal_event(self,event): + if event.name == "Arrive" and event.parameter_dict["source"] == self.host: + self.host.can_move = False + if not self.host.agility: + self.host.can_atk = False + if event.name == "Attack" and event.parameter_dict["source"] == self.host: + self.host.can_atk = False + if not self.host.agility: + self.host.can_move = False + + +class DamageListener(EventListener): + def deal_event(self,event): + if event.name == "Damage": + if event.parameter_dict["target"] == self.host: + if self.host.holy_shield and event.parameter_dict["damage"] != 0: + event.parameter_dict["damage"] = 0 + self.host.emit(Event("BuffRemove", { + "source": self.host, + "type": "HolyShield" + }, -1)) + self.host.hp -= event.parameter_dict["damage"] + # print("Deal {} damage on {} (ID: {})".format( + # event.parameter_dict["damage"],self.host.name,self.host.id + # )) + +class HolyShieldAddListener(EventListener): + def deal_event(self,event): + if event.name == "BuffAdd" and event.parameter_dict["type"] == "HolyShield": + if event.parameter_dict["source"] == self.host and not self.host.holy_shield: + self.host.holy_shield = True + # print("{} (ID: {}) gains Holy Shield".format( + # self.host.name,self.host.id + # )) + + +class HolyShieldBreakListener(EventListener): + def deal_event(self,event): + if event.name == "BuffRemove" and event.parameter_dict["type"] == "HolyShield": + if event.parameter_dict["source"] == self.host: + if not self.host.holy_shield: + raise BaseException() + self.host.holy_shield = False + # print("{} (ID: {})'s Holy Shield is broken".format( + # self.host.name,self.host.id + # )) + +class AttackListener(EventListener): + def deal_event(self,event): + if event.name == "Attack": + if event.parameter_dict["source"] == self.host: + self.host.emit(Event("Attacking",event.parameter_dict)) + self.host.emit(Event("Damage",{ + "source": event.parameter_dict["source"], + "target": event.parameter_dict["target"], + "damage": event.parameter_dict["source"].atk, + "type": "Attack" + })) + self.host.emit(Event("Attacked",event.parameter_dict)) + self.host.emit(Event("CheckDeath",priority=4)) + # print("{} (ID: {}) attacks {} (ID: {})".format( + # event.parameter_dict["source"].name, + # event.parameter_dict["source"].id, + # event.parameter_dict["target"].name, + # event.parameter_dict["target"].id + # )) + +class AttackBackListener(EventListener): + def deal_event(self,event): + if event.name == "Attacked": + if event.parameter_dict["target"] == self.host: + distance = calculator.cube_distance( + event.parameter_dict["source"].pos, + event.parameter_dict["target"].pos, + ) + if self.host.atk_range[0] <= distance <= self.host.atk_range[1] and \ + (not event.parameter_dict["source"].flying or self.host.atk_flying) and \ + self.host.atk != 0: + self.host.emit(Event("Damage",{ + "source": event.parameter_dict["target"], + "target": event.parameter_dict["source"], + "damage": event.parameter_dict["target"].atk, + "type": "AttackBack" + })) + # print("{} (ID: {}) attacks back on {} (ID: {})".format( + # event.parameter_dict["target"].name, + # event.parameter_dict["target"].id, + # event.parameter_dict["source"].name, + # event.parameter_dict["source"].id + # )) + +class MoveListener(EventListener): + def deal_event(self,event): + if event.name == "Move": + if event.parameter_dict["source"] == self.host: + self.host.emit(Event("Leave",{ + "source": event.parameter_dict["source"], + "pos": event.parameter_dict["source"].pos + })) + self.host.pos = event.parameter_dict["dest"] + self.host.emit(Event("Arrive",{ + "source": event.parameter_dict["source"], + "pos": event.parameter_dict["source"].pos + })) + self.host.emit(Event("UpdateRingBuff",priority = 3)) + # print("{} (ID: {}) moves to {}".format( + # event.parameter_dict["source"].name, + # event.parameter_dict["source"].id, + # event.parameter_dict["dest"] + # )) + +class HealListener(EventListener): + def deal_event(self,event): + if event.name == "Heal": + if event.parameter_dict["target"] == self.host: + if self.host.hp < self.host.max_hp: + self.host.hp += event.parameter_dict["heal"] + self.host.hp = min(self.host.hp, self.host.max_hp) + # print("Heal {} HP on {} (ID: {})".format( + # event.parameter_dict["heal"],self.host.name,self.host.id + # )) + +class PriestHealListener(EventListener): + def deal_event(self,event): + if event.name == "TurnEnd" and self.host.state_system.current_player_id == self.host.camp: + for unit in self.host.state_system.map.unit_list: + if calculator.cube_distance(unit.pos,self.host.pos) <= UNIT_DATA["Priest"]["heal_range"][self.host.level-1] \ + and unit.camp == self.host.camp: + self.host.emit(Event("Heal",{ + "source": self.host, + "target": unit, + "heal": UNIT_DATA["Priest"]["heal"][self.host.level-1] + },-3)) + +class PriestAtkListener(EventListener): + def deal_event(self,event): + if event.name == "UpdateRingBuff": + # Add buff + for unit in self.host.state_system.map.unit_list: + if calculator.cube_distance(unit.pos,self.host.pos) <= UNIT_DATA["Priest"]["atk_up_range"][self.host.level-1] \ + and unit.camp == self.host.camp \ + and unit != self.host: + found = False + for buff in self.host.priest_buff_list: + if buff.host == unit: + found = True + break + if not found: + new_buff = PriestAtkBuff(self.host.level,self.host.state_system) + new_buff.add_on(unit) + self.host.priest_buff_list.append(new_buff) + # Delete Buff + for buff in self.host.priest_buff_list: + if calculator.cube_distance(buff.host.pos,self.host.pos) > UNIT_DATA["Priest"]["atk_up_range"][self.host.level-1]: + buff.delete() + self.host.priest_buff_list.remove(buff) + if event.name == "Death": + if event.parameter_dict["source"] == self.host: + for buff in self.host.priest_buff_list: + buff.delete() + +class VolcanoDragonAtkListener(EventListener): + def deal_event(self,event): + if event.name == "Attacked": + if event.parameter_dict["source"] == self.host and\ + event.parameter_dict["target"].type != "Miracle": + for unit in self.host.state_system.map.unit_list: + if (calculator.cube_distance(unit.pos,self.host.pos) == UNIT_DATA["VolcanoDragon"]["self_range"] and + calculator.cube_distance(unit.pos,event.parameter_dict["target"].pos) == UNIT_DATA["VolcanoDragon"]["target_range"]) and \ + unit.camp != self.host.camp and not unit.flying: + self.host.emit(Event("Damage",{ + "source": self.host, + "target": unit, + "damage": UNIT_DATA["VolcanoDragon"]["splash_damage"][self.host.level-1], + "type": "VolcanoDragonSplash" + },priority=-1)) + \ No newline at end of file diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/Map.py b/src/agentbench_frame/miracle/official_logic/StateSystem/Map.py new file mode 100644 index 0000000..2f54ce7 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/Map.py @@ -0,0 +1,62 @@ +''' + Definition of map class +''' + +class Map: + def __init__(self): + self.unit_list = [] + self.obstacle_list = [] + self.barrack_list = [] + self.miracle_list = [] + + def parse(self): + return { + "units": [unit.parse() for unit in self.unit_list], + "barracks": [barrack.parse() for barrack in self.barrack_list], + "miracles": [miracle.parse() for miracle in self.miracle_list] + } + + def get_unit_at(self,pos,flying = None): + for unit in self.unit_list: + if pos == unit.pos: + if flying == None: + return unit + else: + if unit.flying == flying: + return unit + return None + + def get_unit_by_id(self,id): + for unit in self.unit_list: + if unit.id == id: + return unit + return None + + def get_miracle_by_id(self,id): + for miracle in self.miracle_list: + if miracle.camp == id: + return miracle + return None + + def add_unit(self,unit): + self.unit_list.append(unit) + + def remove_unit(self,unit): + self.unit_list.remove(unit) + + def get_obstacles(self): + return self.obstacle_list + + def get_ground_obstacles(self): + result = [] + for item in self.obstacle_list: + if not item.allow_ground: + result.append(item) + return result + + def get_flying_obstacles(self): + result = [] + for item in self.obstacle_list: + if not item.allow_flying: + result.append(item) + return result \ No newline at end of file diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/Miracle.py b/src/agentbench_frame/miracle/official_logic/StateSystem/Miracle.py new file mode 100644 index 0000000..8e65d6a --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/Miracle.py @@ -0,0 +1,42 @@ +from StateSystem.EventListener import EventListener +from StateSystem.Event import Event + +class Miracle: + def __init__(self,camp,hp,pos,summon_pos_list,state_system): + self.name = "Miracle (belongs to Player {})".format(camp) + self.type = "Miracle" + self.id = camp + self.max_hp = hp + self.hp = hp + self.camp = camp + self.pos = pos + self.summon_pos_list = summon_pos_list + self.state_system = state_system + self.event_listener_list = [] + + self.add_event_listener(MiracleDamageListener()) + + def add_event_listener(self,listener): + listener.host = self + self.event_listener_list.append(listener) + + def deal_event(self,event): + for listener in self.event_listener_list: + listener.deal_event(event) + + def emit(self,event): + self.state_system.emit(event) + + def parse(self): + return self.hp + +class MiracleDamageListener(EventListener): + def deal_event(self,event): + if event.name == "Damage": + if event.parameter_dict["target"] == self.host: + hp_loss = min(self.host.hp,event.parameter_dict["damage"]) + self.host.hp -= event.parameter_dict["damage"] + self.host.emit(Event("MiracleHurt",{ + "source": self.host, + "hp_loss": hp_loss + })) \ No newline at end of file diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/Obstacle.py b/src/agentbench_frame/miracle/official_logic/StateSystem/Obstacle.py new file mode 100644 index 0000000..9aa6910 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/Obstacle.py @@ -0,0 +1,62 @@ +class Obstacle: + def __init__(self,name,pos): + self.type = name + self.pos = pos + self.allow_flying = name == "Abyss" + self.allow_ground = False + + def parse(self): + return { + "type": self.type, + "pos": self.pos, + "allow_flying": self.allow_flying, + "allow_ground": self.allow_ground + } + +ABYSS_INIT_LIST = [ + (0,0,0), + (-1,0,1), + (0,-1,1), + (1,-1,0), + (1,0,-1), + (0,1,-1), + (-1,1,0), + + (-2,-1,3), + (-1,-2,3), + (-2,-2,4), + (-3,-2,5), + + (-4,-4,8), + (-5,-4,9), + (-4,-5,9), + (-5,-5,10), + (-6,-5,11), + + (1,2,-3), + (2,1,-3), + (2,2,-4), + (3,2,-5), + + (4,4,-8), + (5,4,-9), + (4,5,-9), + (5,5,-10), + (6,5,-11), + + (5,8,-13), + (6,7,-13), + (7,6,-13), + (8,5,-13), + (6,8,-14), + (7,7,-14), + (8,6,-14), + + (-5,-8,13), + (-6,-7,13), + (-7,-6,13), + (-8,-5,13), + (-6,-8,14), + (-7,-7,14), + (-8,-6,14) +] \ No newline at end of file diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/Player.py b/src/agentbench_frame/miracle/official_logic/StateSystem/Player.py new file mode 100644 index 0000000..5804c98 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/Player.py @@ -0,0 +1,110 @@ +from StateSystem.EventListener import EventListener +from StateSystem.CreatureCapacity import CreatureCapacity +from StateSystem.Artifact import HolyLightArtifact, SalamanderShieldArtifact +from StateSystem.UnitData import CREATURE_CAPACITY_LEVEL_UP_TURN + +class Player: + def __init__(self,camp,mana,state_system): + self.camp = camp + self.artifact_list = [] + self.creature_capacity_list = [] + self.newly_summoned_id_list = [] + self.max_mana = mana + self.mana = mana + self.state_system = state_system + self.event_listener_list = [] + self.score = 0 + + self.add_event_listener(RefreshListener()) + self.add_event_listener(IntoCoolDownListener()) + self.add_event_listener(SummonListener()) + self.add_event_listener(ActivateArtifactListener()) + self.add_event_listener(ScoreListener()) + self.add_event_listener(CreatureCapacityLevelUpListener()) + + def add_event_listener(self,listener): + listener.host = self + self.event_listener_list.append(listener) + + def deal_event(self,event): + for listener in self.event_listener_list: + listener.deal_event(event) + + def emit(self,event): + self.state_system.emit(event) + + def parse(self): + return [ + [artifact.parse() for artifact in self.artifact_list], # artifact + self.mana, + self.max_mana, + [capacity.parse() for capacity in self.creature_capacity_list], + self.newly_summoned_id_list + ] + +class RefreshListener(EventListener): + def deal_event(self,event): + if event.name == "Refresh" and event.parameter_dict["camp"] == self.host.camp: + if self.host.max_mana < 12: + # on the 4kth turn camp=1 player + # on the 4k+1st turn camp=0 player + if event.parameter_dict["turn"] % 4 == 1 - self.host.camp: + self.host.max_mana += 1 + self.host.mana = self.host.max_mana + for capacity in self.host.creature_capacity_list: + capacity.cool_down() + for artifact in self.host.artifact_list: + artifact.cool_down() + self.host.newly_summoned_id_list = [] + +class IntoCoolDownListener(EventListener): + ''' + A creature dies + ''' + def deal_event(self,event): + if event.name == "Death": + source = event.parameter_dict["source"] + if source.camp == self.host.camp: + for capacity in self.host.creature_capacity_list: + if capacity.type == source.type: + capacity.new_cool_down(source.level) + # print("Player {}'s creature {}(ID: {}) starts cooling down for {} turns.".format( + # self.host.camp, + # source.name, + # source.id, + # source.cool_down + # )) + +class SummonListener(EventListener): + def deal_event(self,event): + if event.name == "Spawn": + source = event.parameter_dict["source"] + if source.camp == self.host.camp: + self.host.mana -= source.cost + for capacity in self.host.creature_capacity_list: + if capacity.type == source.type: + capacity.summon() + self.host.newly_summoned_id_list.append(source.id) + +class ActivateArtifactListener(EventListener): + def deal_event(self,event): + if event.name == "ActivateArtifact": + if event.parameter_dict["camp"] == self.host.camp: + for artifact in self.host.artifact_list: + if artifact.name == event.parameter_dict["name"]: + artifact.activate(event.parameter_dict["target"]) + # print("Player {} activate {} !!!".format(self.host.camp,artifact.name)) + return + +class ScoreListener(EventListener): + def deal_event(self,event): + if event.name == "MiracleHurt" and event.parameter_dict["source"].camp != self.host.camp: + self.host.score += event.parameter_dict["hp_loss"] * 1000 + if event.name == "Death" and event.parameter_dict["source"].camp != self.host.camp: + self.host.score += event.parameter_dict["source"].level + +class CreatureCapacityLevelUpListener(EventListener): + def deal_event(self,event): + if event.name == "Refresh" and event.parameter_dict["turn"] in CREATURE_CAPACITY_LEVEL_UP_TURN: + for item in self.host.creature_capacity_list: + item.duplicate_level_up() \ No newline at end of file diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/StateSystem.py b/src/agentbench_frame/miracle/official_logic/StateSystem/StateSystem.py new file mode 100644 index 0000000..0875010 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/StateSystem.py @@ -0,0 +1,279 @@ +from StateSystem.EventHeap import EventHeap +from StateSystem.Event import Event +from StateSystem.EventListener import EventListener +from StateSystem.Unit import * +from StateSystem.Map import * +from StateSystem.Player import Player +from StateSystem.Miracle import Miracle +from StateSystem.Obstacle import * +from StateSystem.Barrack import * +from StateSystem.CreatureCapacity import * +from StateSystem.Artifact import gen_artifact_by_name,Inferno + +class StateSystem: + def __init__(self): + self.map = Map() + self.event_heap = EventHeap() + self.player_list = [Player(0,1,self),Player(1,2,self)] + self.turn_count = 1 + self.current_player_id = 0 + self.map.miracle_list = [ + Miracle(0,30,(-7,7,0),[ + (-8,6,2), + (-7,6,1), + (-6,6,0), + (-6,7,-1), + (-6,8,-2) + ],self), + Miracle(1,30,(7,-7,0),[ + (8,-6,-2), + (7,-6,-1), + (6,-6,0), + (6,-7,1), + (6,-8,2) + ],self) + ] + self.map.obstacle_list = [Obstacle("Abyss",ob_pos) for ob_pos in ABYSS_INIT_LIST] + self.map.obstacle_list += [Obstacle("Miracle",(-7,7,0)), Obstacle("Miracle",(7,-7,0))] + self.map.barrack_list = [Barrack(br[0],br[1],br[2]) for br in BARRACK_INIT_LIST] + self.event_listener_list = [] + + self.add_event_listener(SummonListener()) + self.add_event_listener(CheckDeathListener()) + self.add_event_listener(CheckBarrackListener()) + self.add_event_listener(TurnStartListener()) + self.add_event_listener(ChangeCurrentPlayerListener()) + self.add_event_listener(GameStartListener()) + + def add_event_listener(self,listener): + listener.host = self + self.event_listener_list.append(listener) + + def deal_event(self,event): + for listener in self.event_listener_list: + listener.deal_event(event) + + def emit(self,event): + self.event_heap.append(event) + + def start_event_processing(self): + while self.event_heap.len(): + current_event = self.event_heap.pop() + for player in self.player_list: + player.deal_event(current_event) + for unit in self.map.unit_list: + unit.deal_event(current_event) + for miracle in self.map.miracle_list: + miracle.deal_event(current_event) + self.deal_event(current_event) + # Check Death + new_unit_list = [] + for unit in self.map.unit_list: + if not unit.death_flag: + new_unit_list.append(unit) + self.map.unit_list = new_unit_list + + def parse(self): + return { + "map": self.map.parse(), + "players": [player.parse() for player in self.player_list] + } + + def get_map(self): + return self.map + + def get_units(self): + return self.map.unit_list + + def get_unit_at(self,pos,flying = None): + return self.map.get_unit_at(pos,flying) + + def get_unit_by_id(self,id): + return self.map.get_unit_by_id(id) + + def get_player_by_id(self,id): + for player in self.player_list: + if player.camp == id: + return player + return None + + def get_artifact_by_id(self,id): + for player in self.player_list: + for artifact in player.artifact_list: + if artifact.id == id: + return artifact + return None + + def get_summon_pos_list(self,player_camp): + result = [item for item in self.get_miracle_by_id(player_camp).summon_pos_list] + for barrack in self.get_barracks(player_camp): + result += barrack.summon_pos_list + return result + + def get_barracks(self,player_camp): + return [barrack + for barrack in self.map.barrack_list + if barrack.camp == player_camp] + + def get_obstacles(self): + return self.map.get_obstacles() + + def get_ground_obstacles(self): + return self.map.get_ground_obstacles() + + def get_flying_obstacles(self): + return self.map.get_flying_obstacles() + + def get_miracle_by_id(self,player_camp): + return self.map.get_miracle_by_id(player_camp) + + def get_player_score(self,camp=None): + if camp == None: + return [player.score for player in self.player_list] + return self.player_list[camp].score + +class SummonListener(EventListener): + ''' + Only State System register this listener + ''' + def deal_event(self,event): + if event.name == "Summon": + unit = None + if event.parameter_dict["type"] == "Archer": + unit = Archer( + event.parameter_dict["camp"], + event.parameter_dict["level"], + event.parameter_dict["pos"], + self.host + ) + elif event.parameter_dict["type"] == "Swordsman": + unit = Swordsman( + event.parameter_dict["camp"], + event.parameter_dict["level"], + event.parameter_dict["pos"], + self.host + ) + elif event.parameter_dict["type"] == "BlackBat": + unit = BlackBat( + event.parameter_dict["camp"], + event.parameter_dict["level"], + event.parameter_dict["pos"], + self.host + ) + elif event.parameter_dict["type"] == "Priest": + unit = Priest( + event.parameter_dict["camp"], + event.parameter_dict["level"], + event.parameter_dict["pos"], + self.host + ) + elif event.parameter_dict["type"] == "VolcanoDragon": + unit = VolcanoDragon( + event.parameter_dict["camp"], + event.parameter_dict["level"], + event.parameter_dict["pos"], + self.host + ) + elif event.parameter_dict["type"] == "Inferno": + unit = Inferno( + event.parameter_dict["camp"], + event.parameter_dict["level"], + event.parameter_dict["pos"], + self.host, + event.parameter_dict["artifact_host"] + ) + elif event.parameter_dict["type"] == "FrostDragon": + unit = FrostDragon( + event.parameter_dict["camp"], + event.parameter_dict["level"], + event.parameter_dict["pos"], + self.host + ) + if unit: + self.host.map.add_unit(unit) + self.host.emit(Event("Spawn",{ + "source": unit, + "pos": unit.pos + })) + self.host.emit(Event("UpdateRingBuff",priority = 3)) + # print("{} (ID: {}) spawns at {}".format( + # unit.name, + # unit.id, + # unit.pos + # )) + +class CheckDeathListener(EventListener): + ''' + Only State System register this listener + ''' + def deal_event(self,event): + if event.name == "CheckDeath": + for unit in self.host.map.unit_list: + if unit.hp <= 0 and not unit.death_flag: + unit.death_flag = True + self.host.emit(Event("Death", { + "source": unit + })) + # print("{} (ID: {}) is announced to be dead.".format( + # unit.name, + # unit.id + # )) + +class CheckBarrackListener(EventListener): + ''' + Only State System register this listener + ''' + def deal_event(self,event): + if event.name == "CheckBarrack": + for barrack in self.host.map.barrack_list: + for unit in self.host.map.unit_list: + if unit.pos == barrack.pos and unit.flying == False: + barrack.camp = unit.camp + break + +class TurnStartListener(EventListener): + ''' + Only State System register this listener + ''' + def deal_event(self,event): + if event.name == "TurnStart": + self.host.emit(Event("Refresh",{ + "camp": self.host.player_list[self.host.current_player_id].camp, + "turn": self.host.turn_count + },4)) + self.host.emit(Event("CheckBarrack",{},4)) + self.host.emit(Event("NewTurn",{},4)) + +class ChangeCurrentPlayerListener(EventListener): + ''' + Only State System register this listener + ''' + def deal_event(self,event): + if event.name == "TurnEnd": + self.host.turn_count += 1 + self.host.current_player_id += 1 + self.host.current_player_id %= len(self.host.player_list) + # print("Current Player changed to {}".format( + # self.host.player_list[self.host.current_player_id].camp + # )) + +class GameStartListener(EventListener): + ''' + Only State System register this listener + ''' + def deal_event(self,event): + if event.name == "GameStart": + camp = event.parameter_dict["camp"] + player = self.host.get_player_by_id(camp) + player.creature_capacity_list = [ + CreatureCapacity(name) \ + for name in event.parameter_dict["cards"]["creatures"] + ] + player.artifact_list = [ + gen_artifact_by_name(name,camp,self.host) \ + for name in event.parameter_dict["cards"]["artifacts"] + ] + for index, item in enumerate(self.host.player_list): + if item.camp == player: + self.host.player_list[index] = player + break diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/Unit.py b/src/agentbench_frame/miracle/official_logic/StateSystem/Unit.py new file mode 100644 index 0000000..8600fb1 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/Unit.py @@ -0,0 +1,192 @@ +''' +Definition of unit classes +''' +from .EventListener import * +from .UnitData import UNIT_DATA,UNIT_NAME_PARSED + +UNIT_ID = 3 + +class Unit: + def __init__(self,camp,name,level,pos,state_system): + global UNIT_ID + self.id = UNIT_ID + UNIT_ID += 1 + self.camp = camp + self.level = level + self.type = name + self.name = name + " (Level " + str(level) + ")" + self.cost = UNIT_DATA[name]["cost"][level-1] + self.atk = UNIT_DATA[name]["atk"][level-1] + self.max_hp = UNIT_DATA[name]["hp"][level-1] + self.hp = self.max_hp + self.atk_range = UNIT_DATA[name]["atk_range"][level-1] + self.max_move = UNIT_DATA[name]["max_move"][level-1] + self.cool_down = UNIT_DATA[name]["cool_down"][level-1] + self.pos = pos + self.flying = UNIT_DATA[name]["flying"] + self.atk_flying = UNIT_DATA[name]["atk_flying"] + self.agility = UNIT_DATA[name]["agility"] + self.holy_shield = UNIT_DATA[name]["holy_shield"] + self.can_atk = False + self.can_move = False + + self.death_flag = False + + self.buff_list = [] + + self.state_system = state_system + self.event_listener_list = [] + + self.add_event_listener(RefreshMoveAtkListener()) + self.add_event_listener(OneMoveListener()) + self.add_event_listener(DamageListener()) + self.add_event_listener(AttackListener()) + self.add_event_listener(MoveListener()) + self.add_event_listener(AttackBackListener()) + self.add_event_listener(HealListener()) + self.add_event_listener(HolyShieldAddListener()) + self.add_event_listener(HolyShieldBreakListener()) + + def __str__(self): + return '''{} + ID: {} + Camp: {} + Cost: {} + Atk: {} + HP: {}/{} + Atk Range: {} + Max Move: {} + Cool Down:{} + Pos: {} + Holy Shield: {} + Can Move: {} + Can Attack: {}'''.format( + self.name, + self.id, + self.camp, + self.cost, + self.atk, + self.hp, + self.max_hp, + self.atk_range, + self.max_move, + self.cool_down, + self.pos, + self.holy_shield, + self.can_move, + self.can_atk + ) + + def parse(self): + return [ + self.id, + self.camp, + UNIT_NAME_PARSED[self.type], + self.cost, + self.atk, + self.max_hp, + self.hp, + self.atk_range, + self.max_move, + self.cool_down, + self.pos, + self.level, + int(self.flying), + int(self.atk_flying), + int(self.agility), + int(self.holy_shield), + int(self.can_atk), + int(self.can_move) + ] + + def add_event_listener(self,listener): + listener.host = self + self.event_listener_list.append(listener) + + def deal_event(self,event): + for listener in self.event_listener_list: + listener.deal_event(event) + for buff in self.buff_list: + buff.deal_event(event) + + def emit(self,event): + self.state_system.emit(event) + +class Archer(Unit): + def __init__(self,camp,level,pos,state_system): + name = "Archer" + Unit.__init__( + self, + camp, + name, + level, + pos, + state_system + ) + +class Swordsman(Unit): + def __init__(self,camp,level,pos,state_system): + name = "Swordsman" + Unit.__init__( + self, + camp, + name, + level, + pos, + state_system + ) + +class BlackBat(Unit): + def __init__(self,camp,level,pos,state_system): + name = "BlackBat" + Unit.__init__( + self, + camp, + name, + level, + pos, + state_system + ) + +class Priest(Unit): + def __init__(self,camp,level,pos,state_system): + name = "Priest" + Unit.__init__( + self, + camp, + name, + level, + pos, + state_system + ) + self.priest_buff_list = [] + + self.add_event_listener(PriestHealListener()) + + self.add_event_listener(PriestAtkListener()) + +class VolcanoDragon(Unit): + def __init__(self,camp,level,pos,state_system): + name = "VolcanoDragon" + Unit.__init__( + self, + camp, + name, + level, + pos, + state_system + ) + + self.add_event_listener(VolcanoDragonAtkListener()) + +class FrostDragon(Unit): + def __init__(self,camp,level,pos,state_system): + name = "FrostDragon" + Unit.__init__( + self, + camp, + name, + level, + pos, + state_system + ) \ No newline at end of file diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/UnitData.py b/src/agentbench_frame/miracle/official_logic/StateSystem/UnitData.py new file mode 100644 index 0000000..3af2a21 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/StateSystem/UnitData.py @@ -0,0 +1,20 @@ +import json +import os + +currentPath = os.path.dirname(__file__) + +DATA = json.load(open(currentPath + "/Data.json","r")) + +CREATURE_CAPACITY_LEVEL_UP_TURN = DATA["CreatureCapacityLevelUpTurn"] + +UNIT_DATA = DATA["UnitData"] + +ARTIFACTS = DATA["Artifacts"] + +UNIT_NAME_PARSED = DATA["UnitNameParsed"] + +ARTIFACT_NAME_PARSED = DATA["ArtifactNameParsed"] + +ARTIFACT_STATE_PARSED = DATA["ArtifactStateParsed"] + +ARTIFACT_TARGET_PARSED = DATA["ArtifactTargetParsed"] \ No newline at end of file diff --git a/src/agentbench_frame/miracle/official_logic/StateSystem/__init__.py b/src/agentbench_frame/miracle/official_logic/StateSystem/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agentbench_frame/miracle/official_logic/logic.md b/src/agentbench_frame/miracle/official_logic/logic.md new file mode 100644 index 0000000..8de8a7d --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/logic.md @@ -0,0 +1,262 @@ +# 游戏逻辑 - 文档 + +[toc] + +## 单位名称对照 + +1. 剑士(Swordsman) +2. 弓箭手(Archer) +3. 黑蝙蝠(BlackBat) +4. 牧师(Priest) +5. 火山之龙(VolcanoDragon) +6. 海洋之龙(FrostDragon) + +## 神器名称对照 + +1. 塞瓦哈拉的圣光之耀(HolyLight) +2. 洛古萨斯的地狱之火(InfernoFlame) + - 衍生的地狱火(Inferno) +3. 马尔瑞恩的阳炎之盾(SalamanderShield) + +## 单位(Unit) + +### 单位的属性 + +1. id:全局唯一的ID,依据召唤顺序生成,召唤顺序靠前的ID小 +2. camp:单位所属阵营,一般情况下为0或者1 +3. level:单位的等级 +4. type:单位的类型 +5. name:单位的名字,组成为“[类型] (level [等级])” +6. cost:单位的召唤花费 +7. atk:单位的攻击力 +8. max_hp:单位的血量上限 +9. hp:单位的血量 +10. atk_range:是一个二元元组,第一个数是单位攻击距离的最小值,第二个是最大值 +11. max_move:是一个二元元组,第一个数是单位移动距离的最小值,第二个是最大值 +12. cool_down:单位死亡后所需的冷却时间 +13. pos:是一个三元元组,表示单位在地图上的坐标 +14. flying:布尔类型,表示单位是否为飞行单位 +15. atk_flying:布尔类型,表示单位是否能攻击飞行单位 +16. agility:布尔类型,表示单位是否具有**迅捷**词条 +17. holy_shield:布尔类型,表示单位是否具有**圣盾** +18. can_move:布尔类型,表示单位是否可以移动 +19. can_atk:布尔类型,表示单位是否可以攻击 + +### 单位具有的默认监听器 + +1. 伤害监听器 +2. 攻击监听器 +3. 移动监听器 +4. 反击监听器 +5. 治疗监听器 +6. 圣盾加持监听器 +7. 圣盾破坏监听器 +8. 刷新攻击移动监听器 +9. 每回合动作限制监听器 + +### 部分单位具有的特殊监听器 + +1. 牧师会根据等级获得治疗光环监听器或者攻击光环监听器 +2. 火山之龙会获得攻击溅射监听器 + +## 事件(Event)与监听器(EventListener) + +事件采取堆的形式存储,每个事件都有一个优先级和时间戳,两件事件中优先级高的事件或者相同优先度先触发的事件会先被广播并由监听器作出反应。 + +默认优先度为0,优先度数字越小越优先执行 + +事件会带有一些参数,以便监听器使用 + +### 监听器列表 + +[监听器名称]([监听事件名称]) + +#### 伤害监听器(Damage) + +一个单位受到伤害,如果该单位有圣盾,伤害置0且破除圣盾,触发**HolyShieldBreak**事件 +目前的伤害类型有:**Attack**,**AttackBack**,**VolcanoDragonSplash**,**InfernoFlameActivate** + +#### Buff获得(BuffAdd) + +一个单位获得Buff +类型:**BaseBuff**,**PriestAtkBuff**,**HolyShield**,**HolyLightAtkBuff**,**SalamanderShieldBuff** + +#### Buff失去(BuffRemove) + +一个单位失去Buff + +#### 攻击监听器(Attack) + +一个单位发动攻击 + +流程如下 + +1. 触发**Attacking**事件,表示发动攻击 +2. 触发**Damage**事件,造成伤害 +3. 触发**Attacked**事件,表示受到攻击 +4. 触发优先度为**4**的**CheckDeath**事件,与整个攻击流程结束后检查单位死亡情况 + +#### 反击监听器(Attacked) + +一个单位发动反击 + +如果本单位可以攻击到伤害来源,则对伤害来源触发**Damage**事件 + +#### 治疗监听器(Heal) + +一个单位受到治疗 + +回血 + +#### 刷新攻击移动监听器(Refresh) + +单位在自己本回合开始时获得可以移动和攻击的属性 + +刚召唤的单位默认不能移动和攻击,因为没有经历过Refresh事件 + +#### 每回合动作限制监听器(Arrive/Attack) + +单位移动完成/发动攻击后会触发并且标记为不可移动/不可攻击,如果没有**迅捷**属性会同时标记为不能攻击/不能移动 + +#### 牧师治疗光环监听器(TurnEnd) + +牧师治疗光环在回合结束发动 + +对光环范围内所有友方单位触发**Heal**事件 + +#### 牧师攻击光环监听器(UpdateRingBuff) + +牧师攻击力光环的更新 + +对新进入光环范围单位加持光环 + +对离开光环范围的单位删除光环 + +#### 火山之龙攻击监听器(Attacked) + +火山之龙攻击的溅射 + +对被攻击的目标周围敌方单位触发**Damage**事件 + +#### 回合开始玩家的刷新监听器(Refresh) + +玩家在回合开始更新自己的法力上限与恢复所有法力值 + +玩家冷却中的生物单元和神器冷却一次 + +玩家的“新召唤”列表清空 + +#### 生物进入冷却的监听器(Death) + +玩家检测到自己的单位死亡,则将其置入冷却状态 + +#### 召唤完成监听器(Spawn) + +玩家召唤一个生物完成后更新自己的法力值、生物单元状况和“新召唤”列表 + +#### 触发神器监听器(ActiveArtifact) + +玩家激活自己的神器 + +#### 状态系统召唤监听器(Summon) + +状态系统召唤一个单位,之后发出**Spawn**事件 + +#### 死亡检查监听器(CheckDeath) + +状态系统检测地图上所有生命小于等于零的生物,对其打上死亡标记并发出**Death**事件 + +在当前事件循环结束后状态系统会把所有被标记死亡的生物删除 + +#### 驻扎点占领监听器(CheckBarrack) + +状态系统更新地图上全部驻扎点的占领情况 + +#### 回合开始监听器(TurnStart) + +状态系统对当前回合玩家发出**Refresh**事件 + +状态系统发出优先度为**4**的**CheckBarrack**事件 + +状态系统发出优先度为**4**的**NewTurn**事件 + +#### 当前回合玩家交换监听器(TurnEnd) + +状态系统切换当前回合玩家 + +#### 游戏开始监听器(GameStart) + +状态系统根据事件参数初始化玩家卡组 + +## 操作合法性检测 + +> 合法性检测中将会依据所列顺序依次进行操作合法性的检测,在进入检测之前会首先验证收到的指令是对当前回合的指令 + +### 游戏开始 + +--- + +#### 选手分别初始化(Init) + +1. 规定选择一张神器卡,三张生物卡 + +2. 生物卡不能有重复 + +3. 生物卡在游戏中存在 + +4. 神器卡在游戏中存在 + +### 游戏中 + +--- + +#### 开始回合(StartRound) + +无合法性检测 + +#### 结束回合(EndRound) + +无合法性检测 + +#### 移动(Move) + +1. 移动的是否为己方生物 +2. 终点是否会和别的生物产生重叠 +3. 是否有从起点到终点的道路 +4. 移动者最大移动距离应大于等于路径长度 +5. 移动者不应是本回合召唤的(除非有特殊词条,目前未加) +6. 移动者本回合未 移动 或 攻击过(除非有特殊词条,目前有迅捷) +7. 不处于一些导致无法移动的状态中(目前未加) + +#### 召唤(出兵)(Summon) + +1. 玩家持有该种生物的生物卡 +2. 生物星级合法 +3. 召唤点在出兵点 +4. 在召唤点召唤不会造成生物单位重叠 +5. 该生物卡牌不处于冷却状态 +6. 法力值大于等于所需消耗 + +#### 攻击(Attack) + +1. 攻击者是否为己方生物 +2. 攻击者攻击力>0 +3. 攻击者非本回合召唤(除非有特殊词条,目前未加) +4. 攻击者本回合未 移动 或 攻击过(除非有特殊词条,目前有迅捷) +5. 攻击者不处于一些导致无法攻击的状态中(目前未加) +6. 被攻击者处于攻击者攻击距离内 +7. 被攻击者生命值>0 +8. 被攻击者为空中生物时攻击者应有 飞行 或 对空 词条 +9. 被攻击者不处于一些导致无法被攻击的状态中(目前未加) + +#### 使用神器(Use) + + 1. 操作的是本方的神器 + 2. 神器不处于再装填状态 + 3. 法力值大于等于所需消耗 + 4. 部分神器的特殊检测 + +#### 投降(Surrender) + +无合法性检测 + diff --git a/src/agentbench_frame/miracle/official_logic/main.py b/src/agentbench_frame/miracle/official_logic/main.py new file mode 100644 index 0000000..c16b3a0 --- /dev/null +++ b/src/agentbench_frame/miracle/official_logic/main.py @@ -0,0 +1,464 @@ +'''游戏主体逻辑 +''' + +import sys +import json +import random +from PlayerLegality.player_legality import Parser +from StateSystem.StateSystem import StateSystem + +DEBUG = False # DEBUG时会生成一个log.txt记录logic收发的信息 +MAX_ROUND = 100 +AI_TIME = 3 +PLAYER_TIME = 300 + + +def logic_convert_byte(data_str, send_goal): + '''传输数据的时候加数据长度作为数据头 + ''' + message_len = len(data_str) + message = message_len.to_bytes(4, byteorder='big', signed=True) + message += send_goal.to_bytes(4, byteorder='big', signed=True) + if isinstance(data_str, str): + message += bytes(data_str, encoding="utf8") + elif isinstance(data_str, bytes): + message += data_str + return message + + +def read_opt(): + '''读取发过来的操作 + ''' + read_buffer = sys.stdin.buffer + data_len = int.from_bytes(read_buffer.read( + 4), byteorder='big', signed=True) + data = read_buffer.read(data_len) + opt = json.loads(data) + return opt + + +def send_end_info(end_info): + '''发送终局信息 + ''' + end_dict = {} + end_dict['state'] = -1 + end_dict['end_info'] = json.dumps(end_info) + sys.stdout.buffer.write(logic_convert_byte(json.dumps(end_dict), -1)) + sys.stdout.flush() + + +def send_init(time, length): + '''发送初始化信息 + ''' + sys.stdout.buffer.write(logic_convert_byte( + json.dumps({"state": 0, "time": time, "length": length}), -1)) + sys.stdout.flush() + + +def send_state(state_dict): + '''发送回合信息 + + Args: + state_dict: dict + ''' + sys.stdout.buffer.write(logic_convert_byte(json.dumps(state_dict), -1)) + sys.stdout.flush() + + +class Game: + '''游戏 神迹之战Miracle + ''' + # pylint: disable=too-many-instance-attributes + + def __init__(self): + '''初始化变量 + ''' + self.media_players = [] # 播放器 + self.audience = [] # 观众 + self.replay = "" # 录像文件存储处 + self.state = 0 # 当前消息回合 + self.listen = 0 # 当前监听的玩家(当前回合玩家) + self._round = -1 # 当前游戏回合 + self.is_end = False # 是否结束 + self.statesystem = StateSystem() + self.parser = Parser(self.statesystem) + self.map_type = random.randint(0, 1) # 地图类型 + self.day_time = random.randint(0, 1) # 地图时间 + + def check_game_end(self): + '''判断游戏是否结束,若结束则结束对局 + ''' + miracle_hp = [self.statesystem.get_miracle_by_id(0).hp, + self.statesystem.get_miracle_by_id(1).hp] + if self._round >= MAX_ROUND or miracle_hp[0] <= 0 or miracle_hp[1] <= 0: + self.end(self.statesystem.get_player_score(0), self.statesystem.get_player_score(1)) + + def change_round(self): + '''切换到下一个玩家,其回合开始 + ''' + self.state += 1 + self.listen = 1 - self.listen + if self.listen in self.media_players: + send_init(PLAYER_TIME, 1024) + else: + send_init(AI_TIME, 1024) + + self._round += 1 + self.parser.set_round(self._round) + + def get_round_ope(self): + '''一个游戏回合(主要阶段)内的操作 + ''' + while not self.is_end: + self.send_game_info() + opt_dict = read_opt() + while opt_dict["player"] == 1 - self.listen: + opt_dict = read_opt() + if opt_dict["player"] == self.listen: + try: + self.parser.parse(opt_dict["content"]) + except Exception as parse_error: + self.send_media_info( + [self._round, -1, 0, 0, 0, 0, 0], self.listen) + if DEBUG: + with open('log.txt', 'a') as logfile: + logfile.write(opt_dict["content"]+',\n\n') + logfile.write(parse_error.__repr__()+'\n\n') + continue + else: + if DEBUG: + with open('log.txt', 'a') as logfile: + logfile.write(opt_dict["content"]+',\n\n') + self.send_media_info() + self.check_game_end() + # 结束回合 / 投降 + special_type = json.loads(opt_dict["content"])["operation_type"] + if special_type == "endround": + break + if special_type == "surrender": + if opt_dict['player'] == 0: + self.end(0, self.statesystem.get_player_score(1) + 1) + else: + self.end(self.statesystem.get_player_score(0) + 1, 0) + # AI异常 + elif opt_dict['player'] == -1: + opt = json.loads(opt_dict['content']) + # AI异常退出 + if opt['error'] == 0: + if opt['player'] == 0: + self.end(0, self.statesystem.get_player_score(1) + 1) + else: + self.end(self.statesystem.get_player_score(0) + 1, 0) + # 超时 + # else: + elif opt['state'] == self.state and opt['player'] == self.listen: + if DEBUG: + with open('log.txt', 'a') as logfile: + logfile.write('timeout!\n\n') + if self.listen == -1: + msg = json.dumps( + {"player": 0 if self.listen == 0 else 1, + "round": self._round, + "operation_type": "endround", + "operation_parameters": {}}) + self.parser.parse(msg) + else: + if opt['player'] == 0: + self.end(0, self.statesystem.get_player_score(1) + 1) + else: + self.end(self.statesystem.get_player_score(0) + 1, 0) + break + + def get_media_info(self, events): + '''把事件数组转为给播放器的整数数组 + + Args: + events: 事件数组 + + Returns: + int list + ''' + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + event_names = ["", "TurnStart", "TurnEnd", "Spawn", "Move", "Attack", + "Damage", "Death", "Heal", "ActivateArtifact", + "GameEnd", "GameStart", "BuffAdd", "BuffRemove", + "Attacking", "Attacked", "Leave", "Arrive", "Summon"] + creature_names = ["", "Swordsman", "Archer", "BlackBat", "Priest", + "VolcanoDragon", "FrostDragon", "Inferno"] + artifact_names = ["", "HolyLight", "SalamanderShield", "InfernoFlame", + "WindBlessing"] + + media_info = [] + for event in events: + if not event.name in event_names: + continue + # round + media_info.append(self._round) + # event + media_info.append(event_names.index(event.name)) + if event.name == "TurnStart" or event.name == "TurnEnd": + # camp + media_info.append(self._round % 2) + elif event.name == "Spawn": + # type + media_info.append(creature_names.index( + event.parameter_dict['source'].type) + 10 * event.parameter_dict['source'].camp) + # level + media_info.append(event.parameter_dict['source'].level) + # posX + media_info.append(event.parameter_dict['pos'][0]) + # posY + media_info.append(event.parameter_dict['pos'][1]) + # id + media_info.append(event.parameter_dict['source'].id) + elif event.name == "Move": + # id + media_info.append(event.parameter_dict['source'].id) + # desX + media_info.append(event.parameter_dict['dest'][0]) + # desY + media_info.append(event.parameter_dict['dest'][1]) + elif event.name == "Leave" or event.name == "Arrive": + # id + media_info.append(event.parameter_dict['source'].id) + # posX + media_info.append(event.parameter_dict['pos'][0]) + # posY + media_info.append(event.parameter_dict['pos'][1]) + elif event.name == "Attack" or event.name == "Attacking" or event.name == "Attacked": + # id1 + media_info.append(event.parameter_dict['source'].id) + # id2 + media_info.append(event.parameter_dict['target'].id) + elif event.name == "Damage": + # id2 + media_info.append(event.parameter_dict['target'].id) + # id1 + media_info.append(event.parameter_dict['source'].id) + # damage + media_info.append(event.parameter_dict['damage']) + # type + damage_type = ["", "Attack", "AttackBack", + "VolcanoDragonSplash", "InfernoFlameActivate"] + media_info.append(damage_type.index( + event.parameter_dict['type'])) + elif event.name == "Death": + # id + media_info.append(event.parameter_dict['source'].id) + elif event.name == "Heal": + # id2 + media_info.append(event.parameter_dict['target'].id) + # id1 + media_info.append(event.parameter_dict['source'].id) + # h + media_info.append(event.parameter_dict['heal']) + elif event.name == "ActivateArtifact": + # camp + media_info.append(event.parameter_dict['camp']) + media_info.append(artifact_names.index( + event.parameter_dict['name']) + 10 * event.parameter_dict['camp']) + if event.parameter_dict['name'] in ["HolyLight", "InfernoFlame", "WindBlessing"]: + media_info.append(event.parameter_dict['target'][0]) + media_info.append(event.parameter_dict['target'][1]) + elif event.parameter_dict['name'] in ["SalamanderShield"]: + media_info.append(0) + media_info.append(0) + media_info.append(event.parameter_dict['target'].id) + elif event.name == "GameStart": + # camp + media_info.append(event.parameter_dict['camp']) + # a0 + media_info.append(artifact_names.index( + event.parameter_dict['cards']["artifacts"][0]) + + 10 * event.parameter_dict['camp']) + # c1 c2 c3 + for creature_name in event.parameter_dict['cards']["creatures"]: + media_info.append(creature_names.index( + creature_name) + 10 * event.parameter_dict['camp']) + elif event.name == "BuffAdd" or event.name == "BuffRemove": + # id0 + media_info.append(event.parameter_dict['source'].id) + # type + buff_names = ["BaseBuff", "PriestAtkBuff", "HolyShield", + "HolyLightAtkBuff", "SalamanderShieldBuff"] + media_info.append(buff_names.index( + event.parameter_dict['type'])) + elif event.name == "Summon": + # type + media_info.append(creature_names.index( + event.parameter_dict['type']) + 10 * event.parameter_dict['camp']) + # level + media_info.append(event.parameter_dict['level']) + # posX + media_info.append(event.parameter_dict['pos'][0]) + # posY + media_info.append(event.parameter_dict['pos'][1]) + if len(media_info) % 7 != 0: + media_info += [0] * (7-len(media_info) % 7) + return media_info + + def send_media_info(self, media_info_list=None, goal=3, new=False): + '''把信息发给播放器或记录于录像文件 + + Args: + media_info: 给播放器的信息整数数组 为空时会直接从事件堆中取 + + goal: 发送的目标 -1表示录像文件 0表示播放器玩家0 1表示播放器玩家1 默认全部发送 + ''' + if new: + with open(self.replay, 'wb') as replay_file: + for media_info in [0, 0, 0, self.map_type, self.day_time, 0, 0]: + replay_file.write( + int(media_info).to_bytes(4, 'big', signed=True)) + return + if not media_info_list: + if not self.statesystem.event_heap.record: + return + media_info_list = self.get_media_info( + self.statesystem.event_heap.record) + self.statesystem.event_heap.record.clear() + if not media_info_list: + return + if goal == 3: + self.send_media_info(media_info_list, -1) + self.send_media_info(media_info_list, 0) + self.send_media_info(media_info_list, 1) + if goal == -1: + with open(self.replay, 'ab') as replay_file: + for media_info in media_info_list: + replay_file.write( + int(media_info).to_bytes(4, 'big', signed=True)) + elif goal in (0, 1): + if goal in self.media_players: + send_state({'state': self.state, 'listen': [self.listen], + 'player': [goal], + 'content': [json.dumps(media_info_list)]}) + + def send_game_info(self): + '''向当前回合的AI发送游戏当前局面信息 + ''' + if self.listen in self.media_players: + return + state_dict = {'state': self.state, 'listen': [self.listen], + 'player': [self.listen], 'content': []} + message = dict() + if self._round == -1: + message = {'camp': 0 if self.listen == 0 else 1} + else: + message = self.statesystem.parse() + message['round'] = self._round + message['camp'] = 0 if self.listen == 0 else 1 + # 前六位表示长度 后面是表示信息的json格式字符串 + message_json = json.dumps(message).replace(" ", "") + json_length = str(len(message_json)) + state_dict['content'] = [ + "0" * (6 - len(json_length)) + json_length + message_json] + send_state(state_dict) + + def init(self): + '''根据玩家列表、录像文件地址进行初始化处理 + + player_list: + [1, 0, 2]表示0号玩家为本地AI或者远程算力连接,1号玩家未正常启动进程,2号玩家是远程连接播放器 + 0: 该玩家进入游戏失败 + 1: 该玩家正常进入游戏,且为评测机本地AI或者远程算力 + 2: 该玩家正常进入游戏,且为远程连接播放器 + ''' + opt_dict = read_opt() + self.replay = opt_dict['replay'] + self.send_media_info(new=True) + player_list = opt_dict['player_list'] + if player_list[0] == 0: + self.end(0, 1) + if player_list[1] == 0: + self.end(1, 0) + for player, status in enumerate(player_list): + if status == 2: + self.media_players.append(player) + + def select_cards(self): + '''玩家选择初始卡组 + ''' + media_players_info = [ + [0, 0, 0, self.map_type, self.day_time, 0, 0], + [0, 0, 1, self.map_type, self.day_time, 0, 0]] + is_players_ready = [False, False] + for player in [0, 1]: + self.state += 1 + self.listen = player + if player in self.media_players: + send_init(PLAYER_TIME, 1024) + self.send_media_info(media_players_info[player], player) + else: + send_init(10, 1024) + self.send_game_info() + opt_dict = read_opt() + while opt_dict["player"] == 1 - player: + opt_dict = read_opt() + if opt_dict["player"] == -1: + error_player = json.loads(opt_dict['content'])["player"] + if error_player == 0: + self.end(0, 1) + else: + self.end(1, 0) + if opt_dict["player"] == player: + try: + self.parser.parse(opt_dict["content"]) + except Exception as parse_error: + if DEBUG: + with open('log.txt', 'a') as logfile: + logfile.write(opt_dict["content"]+',\n\n') + logfile.write(parse_error.__repr__()+'\n\n') + else: + if DEBUG: + with open('log.txt', 'a') as logfile: + logfile.write(opt_dict["content"]+',\n\n') + is_players_ready[player] = True + else: + is_players_ready[json.loads(opt_dict['content'])["player"]] = False + + if player == 0: + if not is_players_ready[0]: + self.end(0, 1) + elif not is_players_ready[1]: + self.end(1, 0) + + def start(self): + '''开始游戏 + ''' + self.init() + self.select_cards() + while not self.is_end: + self.change_round() + self.send_media_info() + self.check_game_end() + self.get_round_ope() + self.send_media_info() + self.check_game_end() + + def end(self, player0_score, player1_score): + '''游戏终局处理 + + Args: + player0_score: 先手玩家得分 + player1_score: 后手玩家得分 + ''' + self.is_end = True + if player0_score == player1_score: + player1_score += 1 + winner = 0 if player0_score > player1_score else 1 + media_info_list = [self._round, 10, winner, 0, 0, 0, 0] + self.send_media_info(media_info_list) + self.send_media_info([-1], -1) + if DEBUG: + with open('log.txt', 'a') as logfile: + logfile.write('player '+str(winner)+' win!'+'\n\n') + end_info = {"0": player0_score, "1": player1_score} + send_end_info(end_info) + sys.exit() + + +if __name__ == '__main__': + game = Game() + game.start() diff --git a/src/agentbench_frame/miracle/protocol.py b/src/agentbench_frame/miracle/protocol.py new file mode 100644 index 0000000..ec96071 --- /dev/null +++ b/src/agentbench_frame/miracle/protocol.py @@ -0,0 +1,97 @@ +"""24_miracle 官方逻辑线协议编解码(自己实现,未改动官方代码)。 + +协议来自官方 `logic/gamecode_logic/main.py`(原样移植于 +`AgentBench/backend_sources/corpus/24_miracle/logic/gamecode_logic`): + +- logic → 评测机: ``int32(len) + int32(target) + payload(json)`` +- 评测机 → logic: ``int32(len) + payload(json)`` +- logic 的选卡/回合消息 ``content[0]`` 为 ``"000000"+长度+json`` 形式 + (6 位十进制长度前缀,前面补零)。 +""" + +from __future__ import annotations + +import json +import os +import select +import time +from typing import Optional, Tuple + +__all__ = [ + "encode_to_logic", + "read_exact", + "read_logic_frame", + "decode_content", + "MAX_FRAME_BYTES", +] + +#: 单帧 payload 上限(防御性限制,官方消息远小于此) +MAX_FRAME_BYTES = 1 << 22 # 4 MiB + + +class ProtocolError(Exception): + """协议错误:帧头非法、长度越界、JSON 解析失败等。""" + + +def encode_to_logic(payload: dict) -> bytes: + """把一条消息编码为发给 logic 的字节流:``int32(len) + json``。 + + 注意:logic 的 ``read_opt()`` 只读 4 字节长度头 + payload, + 与 logic 发送方向(带 target)不对称,这是官方协议本身的设计。 + """ + data = json.dumps(payload).encode("utf-8") + if len(data) > MAX_FRAME_BYTES: + raise ProtocolError(f"payload too large: {len(data)} bytes") + return len(data).to_bytes(4, "big", signed=True) + data + + +def read_exact(stream, size: int, timeout: float, label: str = "stream") -> bytes: + """带超时精确读取 ``size`` 字节;用 select 判定可读,超时抛 TimeoutError。""" + deadline = time.monotonic() + timeout + buf = bytearray() + while len(buf) < size: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"timeout reading {label} ({len(buf)}/{size} bytes)") + ready, _, _ = select.select([stream], [], [], remaining) + if not ready: + raise TimeoutError(f"timeout reading {label} ({len(buf)}/{size} bytes)") + chunk = os.read(stream.fileno(), size - len(buf)) + if not chunk: + raise EOFError(f"{label}: stream closed while reading {size} bytes") + buf += chunk + return bytes(buf) + + +def read_logic_frame( + stream, timeout: float, label: str = "logic" +) -> Tuple[int, dict]: + """从 logic stdout 读一帧,返回 ``(target, payload_dict)``。""" + header = read_exact(stream, 8, timeout, label) + length = int.from_bytes(header[:4], "big", signed=True) + target = int.from_bytes(header[4:], "big", signed=True) + if length < 0 or length > MAX_FRAME_BYTES: + raise ProtocolError(f"invalid frame length: {length}") + raw = read_exact(stream, length, timeout, label) + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: # pragma: no cover - 防御分支 + raise ProtocolError(f"bad json from logic: {exc}") from exc + return target, payload + + +def decode_content(content: list) -> Optional[dict]: + """解析 logic ``content[0]``(``"000000"+长度+json``)为内层消息 dict。 + + 返回 None 表示无法解析(协议异常)。 + """ + if not content or not isinstance(content[0], str): + return None + text = content[0] + if len(text) >= 6 and text[:6].isdigit(): + text = text[6:] + try: + msg = json.loads(text) + except json.JSONDecodeError: + return None + return msg if isinstance(msg, dict) else None diff --git a/src/agentbench_frame/miracle/replay.py b/src/agentbench_frame/miracle/replay.py new file mode 100644 index 0000000..52e8f42 --- /dev/null +++ b/src/agentbench_frame/miracle/replay.py @@ -0,0 +1,152 @@ +"""回放解析(要求 2 配套):官方 `.mrc` 二进制 → 事件时间线 / 统计。 + +格式(与官方 ``gamecode_logic/main.py`` 的 ``get_media_info`` / ``send_media_info`` +逐字核对): +- 头部 7 个 int:``[0, 0, 0, map_type, day_time, 0, 0]``; +- 其后每轮事件流:每个事件编码为 ``(round, event_idx, args...)`` 的 int 序列, + 每轮补齐到 7 的倍数; +- 终局:``(round, 10, winner, 0, 0, 0, 0)``(10 = GameEnd), + 末尾 ``-1`` 结束标记; +- 所有 int 均为 4 字节大端有符号。 +""" + +from __future__ import annotations + +import json +import struct +from dataclasses import dataclass, field +from pathlib import Path + +__all__ = ["EVENT_NAMES", "CREATURE_NAMES", "ARTIFACT_NAMES", "ReplayEvent", + "parse_replay", "load_replay_json", "save_replay_json"] + +#: 官方事件编号表(event_names,索引即编码) +EVENT_NAMES = ["", "TurnStart", "TurnEnd", "Spawn", "Move", "Attack", + "Damage", "Death", "Heal", "ActivateArtifact", + "GameEnd", "GameStart", "BuffAdd", "BuffRemove", + "Attacking", "Attacked", "Leave", "Arrive", "Summon"] + +#: 官方生物编号表(creature_names,索引即编码;camp 用 +10×camp 编码) +CREATURE_NAMES = ["", "Swordsman", "Archer", "BlackBat", "Priest", + "VolcanoDragon", "FrostDragon", "Inferno"] + +#: 官方神器编号表(artifact_names) +ARTIFACT_NAMES = ["", "HolyLight", "SalamanderShield", "InfernoFlame", + "WindBlessing"] + +DAMAGE_TYPES = ["", "Attack", "AttackBack", "VolcanoDragonSplash", + "InfernoFlameActivate"] +BUFF_NAMES = ["BaseBuff", "PriestAtkBuff", "HolyShield", "HolyLightAtkBuff", + "SalamanderShieldBuff"] + + +@dataclass +class ReplayEvent: + """解码后的一条事件:``round``、``type``(事件名)、``args``(参数 int 列表)。""" + + round: int + type: str + args: list = field(default_factory=list) + + def to_dict(self) -> dict: + return {"round": self.round, "type": self.type, "args": list(self.args)} + + +def _decode_event(round_: int, idx: int, args: list) -> ReplayEvent: + name = EVENT_NAMES[idx] if 0 <= idx < len(EVENT_NAMES) else f"UNKNOWN({idx})" + return ReplayEvent(round=round_, type=name, args=list(args)) + + +def _iter_blocks(ints: list): + """把 int 流切成事件块。 + + 官方结构(已用真实 .mrc 验证):整个 body 是连续的 7-int 块序列;一次 + ``send_media_info`` flush 写一批事件(每个事件 ``(round, idx, args...)`` + 连续编码),该批末尾补齐到 7 的倍数。因此: + - 事件头位置 idx ∈ 1..18 → 按参数数消费(同批事件可能连续,不 pad); + - 事件头位置 idx 非法(=0 的 pad 0,或 >18 的异常值)→ 跳过当前 7 边界; + - 末尾单独 ``-1`` 为结束标记。 + """ + arg_counts = { + 1: 1, 2: 1, 3: 5, 4: 3, 5: 2, 6: 4, 7: 1, 8: 3, + 9: 0, 10: 0, 11: 5, 12: 2, 13: 2, 14: 2, 15: 2, 16: 3, 17: 3, 18: 4, + } + i = 0 + n = len(ints) + cur_round = -1 + while i < n: + r = ints[i] + if r == -1 or (i + 1 >= n): + yield ReplayEvent(r, "END", []) + i += 1 + continue + idx = ints[i + 1] + if idx not in arg_counts or (idx in arg_counts and r < cur_round): + # pad 区(flush 组尾对齐 7)或 round 回退(pad 0 被误读为事件头): + # 跳到下一 7 边界 + nxt = ((i // 7) + 1) * 7 + i = nxt if nxt > i else i + 1 + continue + if idx == 10: # GameEnd: [round, 10, winner, 0, 0, 0, 0] + cur_round = max(cur_round, r) + yield ReplayEvent(round=r, type="GameEnd", args=[ints[i + 2]]) + i += 7 + continue + if idx == 9: # ActivateArtifact: camp, name, 然后 2(HolyLight/InfernoFlame/WindBlessing: t0,t1)或 3(SalamanderShield: 0,0,id)个参数 + argc = 5 if (ints[i + 3] % 10) == 2 else 4 + cur_round = max(cur_round, r) + yield ReplayEvent(round=r, type="ActivateArtifact", args=ints[i + 2:i + 2 + argc]) + i += 2 + argc + continue + count = arg_counts[idx] + cur_round = max(cur_round, r) + yield _decode_event(r, idx, ints[i + 2:i + 2 + count]) + i += 2 + count + + +def parse_replay(path) -> list: + """解析 `.mrc` 二进制,返回事件时间线(``list[ReplayEvent]``)。""" + data = Path(path).read_bytes() + assert len(data) % 4 == 0, f"{path}: 文件长度不是 4 的倍数" + ints = list(struct.unpack(f">{len(data) // 4}i", data)) + # 去掉头部 7 个 int([0,0,0,map_type,day_time,0,0]) + header, body = ints[:7], ints[7:] + events = list(_iter_blocks(body)) + return events + + +def summarize(events: list) -> dict: + """事件统计:各类型计数、终局信息、回合跨度。""" + counts: dict = {} + winner = None + end_round = None + rounds = set() + for e in events: + counts[e.type] = counts.get(e.type, 0) + 1 + if e.round >= 0: + rounds.add(e.round) + if e.type == "GameEnd": + winner = e.args[0] if e.args else None + end_round = e.round + return { + "n_events": len(events), + "rounds": [min(rounds), max(rounds)] if rounds else [], + "event_counts": counts, + "winner": winner, + "end_round": end_round, + "ended": any(e.type == "GameEnd" for e in events), + } + + +def save_replay_json(events: list, path) -> None: + """事件时间线落盘 JSON(可与 trace 交叉核对)。""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + for e in events: + f.write(json.dumps(e.to_dict(), ensure_ascii=False) + "\n") + + +def load_replay_json(path) -> list: + with open(path, encoding="utf-8") as f: + return [ReplayEvent(**json.loads(line)) for line in f if line.strip()] diff --git a/src/agentbench_frame/miracle/run_store.py b/src/agentbench_frame/miracle/run_store.py new file mode 100644 index 0000000..71a9f5c --- /dev/null +++ b/src/agentbench_frame/miracle/run_store.py @@ -0,0 +1,214 @@ +"""Miracle Loop 的 Results-compatible Run 存储与预算账本。""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +import time +from datetime import datetime, timezone +from pathlib import Path + +from .loop_config import BudgetConfig, LoopConfig + + +class BudgetExceeded(RuntimeError): + def __init__(self, dimension: str): + super().__init__(f"budget exceeded: {dimension}") + self.dimension = dimension + + +class BudgetLedger: + def __init__(self, limits: BudgetConfig): + self.limits = limits + self.started = time.monotonic() + self.rollouts = 0 + self.episode_reads = 0 + self.decision_reads = 0 + self.prompt_tokens = 0 + self.completion_tokens = 0 + self.total_tokens = 0 + self.api_seconds = 0.0 + self.battle_seconds = 0.0 + + def _ensure(self, dimension: str, value: int | float, limit: int | float) -> None: + if value > limit: + raise BudgetExceeded(dimension) + + def check(self) -> None: + self._ensure("wall_seconds", time.monotonic() - self.started, self.limits.max_wall_seconds) + + def charge_rollout(self, count: int = 1) -> None: + value = self.rollouts + count + self._ensure("rollouts", value, self.limits.max_rollouts) + self.rollouts = value + self.check() + + def charge_read(self, episodes: int, decisions: int) -> None: + ep_value = self.episode_reads + episodes + decision_value = self.decision_reads + decisions + self._ensure("episode_reads", ep_value, self.limits.max_episode_reads) + self._ensure("decision_reads", decision_value, self.limits.max_decision_reads) + self.episode_reads = ep_value + self.decision_reads = decision_value + self.check() + + def charge_usage(self, usage: dict) -> None: + total = self.total_tokens + int(usage.get("total_tokens", 0) or 0) + self._ensure("total_tokens", total, self.limits.max_total_tokens) + self.prompt_tokens += int(usage.get("prompt_tokens", 0) or 0) + self.completion_tokens += int(usage.get("completion_tokens", 0) or 0) + self.total_tokens = total + self.check() + + def charge_context(self, total_tokens: int, limit: int) -> None: + self._ensure("context_tokens", int(total_tokens), int(limit)) + self.check() + + def charge_api_time(self, seconds: float) -> None: + self.api_seconds += max(0.0, float(seconds)) + self.check() + + def charge_battle_time(self, seconds: float) -> None: + self.battle_seconds += max(0.0, float(seconds)) + self.check() + + def snapshot(self) -> dict: + return { + "rollouts": self.rollouts, + "episode_reads": self.episode_reads, + "decision_reads": self.decision_reads, + "prompt_tokens": self.prompt_tokens, + "completion_tokens": self.completion_tokens, + "total_tokens": self.total_tokens, + "api_seconds": round(self.api_seconds, 6), + "battle_seconds": round(self.battle_seconds, 6), + "wall_seconds": round(time.monotonic() - self.started, 6), + } + + +class MiracleRunStore: + def __init__(self, run_dir: Path, run_id: str, config: LoopConfig, created: str): + self.run_dir = run_dir + self.run_id = run_id + self.config = config + self.created = created + self.started_at = time.time() + self.events_path = run_dir / "events.jsonl" + + @classmethod + def create( + cls, + config: LoopConfig, + data_dir: Path | str | None = None, + run_id: str | None = None, + ) -> "MiracleRunStore": + root = Path(data_dir) if data_dir is not None else Path( + os.environ.get("AGENTBENCH_DATA", "agentbench_data") + ) + run_id = run_id or cls._make_run_id() + run_dir = root / "runs" / "24_miracle" / config.agent / run_id + run_dir.mkdir(parents=True, exist_ok=False) + created = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + store = cls(run_dir, run_id, config, created) + store._copy_skills() + store._write_run_toml() + return store + + @staticmethod + def _make_run_id() -> str: + stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M") + digest = hashlib.md5(os.urandom(8)).hexdigest()[:8] + return f"{stamp}_{digest}" + + @staticmethod + def _git_commit() -> str: + try: + return subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + except (OSError, subprocess.SubprocessError): + return "" + + def _copy_skills(self) -> None: + root = Path(__file__).resolve().parents[3] + target = self.run_dir / "skills" + target.mkdir() + for name in ("miracle-harness", "miracle-replay-reader"): + shutil.copyfile( + root / "skills" / name / "SKILL.md", + target / f"{name}.SKILL.md", + ) + + def _write_run_toml(self, summary: dict | None = None) -> None: + cfg = self.config.public_dict() + lines = [ + "[run]", + f'run_id = "{self.run_id}"', + 'game = "24_miracle"', + f'agent = "{self.config.agent}"', + 'type = "rule_iter"', + f'created = "{self.created}"', + f'git_commit = "{self._git_commit()}"', + f"started_at = {self.started_at}", + "", + "[config]", + f'opponent = "{self.config.opponent}"', + f'model = "{self.config.llm.model}"', + f"max_iterations = {self.config.budget.max_iterations}", + f"max_rollouts = {self.config.budget.max_rollouts}", + ] + if summary is not None: + lines[8:8] = [ + f"finished_at = {time.time()}", + f"total_steps = {int(summary.get('total_steps', 0))}", + f"total_episodes = {int(summary.get('total_episodes', 0))}", + ] + self._write_text_atomic(self.run_dir / "run.toml", "\n".join(lines) + "\n") + self.write_json_atomic(self.run_dir / "config.json", cfg) + + def iteration_dir(self, index: int) -> Path: + path = self.run_dir / "iterations" / f"iteration-{index:04d}" + path.mkdir(parents=True, exist_ok=True) + return path + + def write_event(self, event: str, **fields) -> None: + row = {"event": event, "timestamp": time.time(), **fields} + with self.events_path.open("a", encoding="utf-8") as stream: + stream.write(json.dumps(row, ensure_ascii=False) + "\n") + stream.flush() + + def write_json_atomic(self, path: Path | str, value) -> None: + self._write_text_atomic( + Path(path), json.dumps(value, ensure_ascii=False, indent=2) + "\n", + ) + + @staticmethod + def _write_text_atomic(path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + ".tmp") + with temporary.open("w", encoding="utf-8") as stream: + stream.write(value) + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(path) + + def finish(self, summary: dict) -> None: + wall_seconds = float(summary.get("wall_seconds", time.time() - self.started_at)) + complete = { + "run_id": self.run_id, + "game": "24_miracle", + "agent": self.config.agent, + "run_type": "rule_iter", + "created": self.created, + "git_commit": self._git_commit(), + "wall_hours": round(wall_seconds / 3600, 6), + **summary, + } + self.write_json_atomic(self.run_dir / "summary.json", complete) + self._write_run_toml(complete) + self.write_event("run_finished", status=complete.get("status", "complete")) diff --git a/src/agentbench_frame/miracle/score.py b/src/agentbench_frame/miracle/score.py new file mode 100644 index 0000000..7a277d1 --- /dev/null +++ b/src/agentbench_frame/miracle/score.py @@ -0,0 +1,97 @@ +"""Miracle 版本对齐的性能分、gain 与预算 AUC。""" + +from __future__ import annotations + + +def aggregate_score(episodes: list[dict], candidate_camp_by_episode: dict[str, int]) -> dict: + measured_scores = [] + wins = 0 + counts = {"normal": 0, "timeout": 0, "failed": 0, "missing": 0} + preserved = [] + for episode in episodes: + row = dict(episode) + episode_id = str(row.get("episode_id", "")) + camp = candidate_camp_by_episode.get(episode_id) + scores = row.get("scores") + normal = row.get("terminated_by") == "normal" and not row.get("errors") + if camp not in (0, 1) or not isinstance(scores, (list, tuple)) or len(scores) < 2: + counts["missing"] += 1 + row["candidate_score"] = None + elif normal: + value = float(scores[camp]) + measured_scores.append(value) + wins += int(row.get("winner") == camp) + counts["normal"] += 1 + row["candidate_score"] = value + elif row.get("terminated_by") == "timeout": + counts["timeout"] += 1 + row["candidate_score"] = None + else: + counts["failed"] += 1 + row["candidate_score"] = None + row["candidate_camp"] = camp + preserved.append(row) + total = len(episodes) + measured = len(measured_scores) + return { + "mean_score": round(sum(measured_scores) / measured, 6) if measured else None, + "win_rate": round(wins / measured, 6) if measured else None, + "completion_rate": measured / total if total else 0.0, + "counts": counts, + "episodes": preserved, + } + + +def trapezoid_auc(points: list[dict], x_key: str, y_key: str) -> dict: + valid = sorted( + ( + (float(point[x_key]), float(point[y_key])) + for point in points + if point.get(x_key) is not None and point.get(y_key) is not None + ), + key=lambda item: item[0], + ) + if len(valid) < 2: + return {"value": None, "reason": "insufficient_points", "n_points": len(valid)} + value = sum( + (right_x - left_x) * (left_y + right_y) / 2 + for (left_x, left_y), (right_x, right_y) in zip(valid, valid[1:]) + ) + return {"value": round(value, 6), "reason": None, "n_points": len(valid)} + + +def build_score_curve(iterations: list[dict]) -> dict: + ordered = sorted(iterations, key=lambda item: int(item["iteration"])) + raw = ordered[0].get("score") if ordered else None + points = [] + for item in ordered: + budget = item.get("budget", {}) + evo = item.get("score") + point = { + "iteration": int(item["iteration"]), + "version": item.get("version"), + "status": item.get("status"), + "raw": raw, + "evo": evo, + "gain": round(evo - raw, 6) if evo is not None and raw is not None else None, + "win_rate": item.get("win_rate"), + "completion_rate": item.get("completion_rate"), + "rollouts": budget.get("rollouts", 0), + "total_tokens": budget.get("total_tokens", 0), + "episode_reads": budget.get("episode_reads", 0), + "decision_reads": budget.get("decision_reads", 0), + "wall_seconds": budget.get("wall_seconds", 0), + } + points.append(point) + axes = { + "iteration": "iteration", + "rollout": "rollouts", + "total_token": "total_tokens", + "episode_read": "episode_reads", + "wall_time": "wall_seconds", + } + return { + "metric": "official_score", + "points": points, + "auc": {name: trapezoid_auc(points, key, "evo") for name, key in axes.items()}, + } diff --git a/src/agentbench_frame/miracle/strategy_loader.py b/src/agentbench_frame/miracle/strategy_loader.py new file mode 100644 index 0000000..aa414ed --- /dev/null +++ b/src/agentbench_frame/miracle/strategy_loader.py @@ -0,0 +1,85 @@ +"""不可变策略源码快照及 CandidateAgent 加载。""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +from .agent_bridge import MiracleAgent + + +class StrategyValidationError(RuntimeError): + def __init__(self, stage: str, reason: str): + super().__init__(f"{stage}: {reason}") + self.stage = stage + self.reason = reason + + +def save_source(path: Path | str, source: str) -> None: + path = Path(path) + if path.exists(): + if path.read_text(encoding="utf-8") == source: + return + raise StrategyValidationError("snapshot", f"refusing to overwrite {path}") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8") + + +def load_candidate(path: Path | str, module_key: str) -> MiracleAgent: + path = Path(path) + source = path.read_text(encoding="utf-8") + try: + compile(source, str(path), "exec") + except (SyntaxError, ValueError) as exc: + raise StrategyValidationError("compile", str(exc)) from exc + try: + spec = importlib.util.spec_from_file_location(module_key, path) + if spec is None or spec.loader is None: + raise ImportError("could not create module spec") + module = importlib.util.module_from_spec(spec) + sys.modules[module_key] = module + spec.loader.exec_module(module) + except Exception as exc: + sys.modules.pop(module_key, None) + raise StrategyValidationError("import", repr(exc)) from exc + candidate_class = getattr(module, "CandidateAgent", None) + if not isinstance(candidate_class, type) or not issubclass(candidate_class, MiracleAgent): + raise StrategyValidationError( + "class", "source must define CandidateAgent(MiracleAgent)", + ) + try: + return candidate_class() + except Exception as exc: + raise StrategyValidationError("instantiate", repr(exc)) from exc + + +def validate_candidate(agent: MiracleAgent) -> None: + try: + cards = agent.choose_cards(0) + except Exception as exc: + raise StrategyValidationError("choose_cards", repr(exc)) from exc + if not isinstance(cards, dict): + raise StrategyValidationError("choose_cards", "result must be an object") + artifacts = cards.get("artifacts") + creatures = cards.get("creatures") + if not isinstance(artifacts, list) or len(artifacts) != 1: + raise StrategyValidationError("choose_cards", "exactly one artifact is required") + if not isinstance(creatures, list) or len(creatures) != 3: + raise StrategyValidationError("choose_cards", "exactly three creatures are required") + obs = { + "camp": 0, + "round": 1, + "map": {"units": [], "miracles": [30, 30], "barracks": [2, 2, 2, 2]}, + "players": [[[], 0, 1, [], []], [[], 0, 1, [], []]], + } + try: + action = agent.act(obs) + except Exception as exc: + raise StrategyValidationError("action_shape", repr(exc)) from exc + if not isinstance(action, dict): + raise StrategyValidationError("action_shape", "action must be an object") + if not isinstance(action.get("operation_type"), str): + raise StrategyValidationError("action_shape", "operation_type must be a string") + if not isinstance(action.get("operation_parameters"), dict): + raise StrategyValidationError("action_shape", "operation_parameters must be an object") diff --git a/tests/miracle/test_cli.py b/tests/miracle/test_cli.py new file mode 100644 index 0000000..a71adc3 --- /dev/null +++ b/tests/miracle/test_cli.py @@ -0,0 +1,79 @@ +"""Miracle CLI 的唯一用户入口契约。""" + +import pytest + +from agentbench_frame.miracle import agent_bridge +from agentbench_frame.miracle.cli import build_parser + + +def test_match_accepts_dynamically_registered_agent_names(): + class LlmV1(agent_bridge.EndRoundAgent): + name = "llm_v1" + + agent_bridge.AGENTS["llm_v1"] = LlmV1 + parser = build_parser() + + try: + args = parser.parse_args([ + "match", "--agent0", "llm_v1", "--agent1", "sample", + ]) + finally: + agent_bridge.AGENTS.pop("llm_v1") + + assert args.agent0 == "llm_v1" + assert args.agent1 == "sample" + + +def test_match_has_one_output_directory(tmp_path): + parser = build_parser() + output_dir = tmp_path / "match-output" + + args = parser.parse_args([ + "match", + "--agent0", "sample", + "--agent1", "endround", + "--seed", "23", + "--output-dir", str(output_dir), + "--tag", "candidate-v3", + ]) + + assert args.seed == 23 + assert args.output_dir == output_dir + assert args.tag == "candidate-v3" + + +def test_cli_only_exposes_four_core_commands(): + parser = build_parser() + + assert set(parser._subparsers._group_actions[0].choices) == {"match", "replay", "ig", "loop"} + + +def test_ig_has_one_output_directory_and_explicit_versions(tmp_path): + parser = build_parser() + args = parser.parse_args([ + "ig", "--trace", str(tmp_path / "x.trace.jsonl"), + "--old", "endround", "--new", "sample", "--camp", "0", + "--iteration", "2", "--output-dir", str(tmp_path / "ig"), + ]) + + assert args.old == "endround" + assert args.new == "sample" + assert args.iteration == 2 + assert args.output_dir == tmp_path / "ig" + + +def test_legacy_agent_flag_is_not_an_alias(): + parser = build_parser() + + with pytest.raises(SystemExit): + parser.parse_args(["match", "--agent", "sample"]) + + +def test_loop_has_one_config_and_optional_data_directory(tmp_path): + args = build_parser().parse_args([ + "loop", "--config", str(tmp_path / "loop.toml"), + "--data-dir", str(tmp_path / "results"), + ]) + + assert args.config == tmp_path / "loop.toml" + assert args.data_dir == tmp_path / "results" diff --git a/tests/miracle/test_decision_space.py b/tests/miracle/test_decision_space.py new file mode 100644 index 0000000..1ed7725 --- /dev/null +++ b/tests/miracle/test_decision_space.py @@ -0,0 +1,55 @@ +"""Miracle 决策空间的稳定动作契约。""" + +from agentbench_frame.miracle.decision_space import Action, action_mask + + +def _obs(*, mana=0, units=None, capacities=None, miracles=None): + return { + "map": { + "units": units or [], + "miracles": miracles or [30, 30], + "barracks": [-1, -1, -1, -1], + }, + "players": [ + [[], mana, 12, capacities or [], []], + [[], 0, 12, [], []], + ], + "round": 4, + "camp": 0, + } + + +def test_action_signature_is_stable_across_parameter_order(): + left = Action("move", {"mover": 7, "position": [1, -1, 0]}) + right = Action("move", {"position": [1, -1, 0], "mover": 7}) + + assert left.signature() == right.signature() + + +def test_action_mask_always_contains_endround_and_surrender(): + actions = action_mask(_obs(), camp=0) + + assert Action("endround", {}) in actions + assert Action("surrender", {}) in actions + + +def test_action_mask_enumerates_affordable_summons_for_deck_capacity(): + # type 0 = Archer;available_count=1;mana 只够 level 1。 + actions = action_mask(_obs(mana=2, capacities=[[0, 1, []]]), camp=0) + + summons = [a for a in actions if a.type == "summon"] + assert len(summons) == 5 + assert {tuple(a.params["position"]) for a in summons} == { + (-8, 6, 2), (-7, 6, 1), (-6, 6, 0), (-6, 7, -1), (-6, 8, -2), + } + assert {a.params["type"] for a in summons} == {"Archer"} + assert {a.params["level"] for a in summons} == {1} + + +def test_action_mask_enumerates_attacks_in_range(): + mine = [9, 0, 1, 2, 2, 2, 2, [1, 1], 3, 2, [0, 0, 0], 1, 0, 0, 0, 0, 1, 1] + enemy = [12, 1, 0, 2, 1, 2, 2, [3, 4], 3, 4, [1, -1, 0], 1, 0, 1, 0, 0, 1, 1] + + attacks = [a for a in action_mask(_obs(units=[mine, enemy]), 0) if a.type == "attack"] + + assert Action("attack", {"attacker": 9, "target": 12}) in attacks diff --git a/tests/miracle/test_ig.py b/tests/miracle/test_ig.py new file mode 100644 index 0000000..0f95acc --- /dev/null +++ b/tests/miracle/test_ig.py @@ -0,0 +1,141 @@ +"""确定性 Miracle 策略的严格 KL 状态与曲线契约。""" + +import json + +from agentbench_frame.miracle.decision_space import Action +from agentbench_frame.miracle.ig import ( + aggregate_episode, + build_ig_curve, + compare_agents_on_trace, + compare_deterministic, + save_episode_ig, + save_ig_curve, +) + + +END = Action("endround", {}) +MOVE = Action("move", {"mover": 7, "position": [1, -1, 0]}) + + +def test_same_deterministic_action_has_zero_strict_kl(): + row = compare_deterministic("obs-1", END, END, support=[END, MOVE]) + + assert row["status"] == "unchanged" + assert row["kl"] == 0.0 + assert row["missing_reason"] is None + + +def test_changed_deterministic_action_is_strict_kl_infinite(): + row = compare_deterministic("obs-1", END, MOVE, support=[END, MOVE]) + + assert row["status"] == "infinite" + assert row["kl"] is None + assert row["missing_reason"] == "support_expansion" + + +def test_action_outside_legal_support_is_missing_not_kl(): + row = compare_deterministic("obs-1", END, MOVE, support=[END]) + + assert row["status"] == "missing" + assert row["missing_reason"] == "action_outside_support" + + +def test_episode_aggregation_keeps_status_ratios(): + rows = [ + compare_deterministic("a", END, END, [END, MOVE]), + compare_deterministic("b", END, END, [END, MOVE]), + compare_deterministic("c", END, MOVE, [END, MOVE]), + compare_deterministic("d", END, MOVE, [END]), + ] + + result = aggregate_episode(rows, episode_id="ep-7", iteration=3) + + assert result["finite_kl_mean"] == 0.0 + assert result["unchanged_ratio"] == 0.5 + assert result["infinite_ratio"] == 0.25 + assert result["missing_ratio"] == 0.25 + assert result["counts"] == {"unchanged": 2, "infinite": 1, "missing": 1} + + +def test_curve_preserves_baseline_and_null_finite_kl(tmp_path): + ep = aggregate_episode( + [compare_deterministic("a", END, MOVE, [END, MOVE])], + episode_id="ep-1", iteration=1, + ) + curve = build_ig_curve([ep], versions={0: "baseline", 1: "candidate-v1"}) + + assert curve["points"][0]["iteration"] == 0 + assert curve["points"][0]["status"] == "baseline" + assert curve["points"][1]["finite_kl_mean"] is None + assert curve["points"][1]["infinite_ratio"] == 1.0 + + out = tmp_path / "ig-curve.json" + save_ig_curve(curve, out) + assert json.loads(out.read_text(encoding="utf-8")) == curve + + +def _trace_row(seq, obs): + content = "000000" + json.dumps(obs) + return { + "seq": seq, + "kind": "from_logic", + "payload": {"listen": [obs["camp"]], "content": [content]}, + } + + +def test_real_trace_is_replayed_through_old_and_new_agents(tmp_path): + obs = {"camp": 0, "round": 3, "map": {"units": [], "barracks": []}, + "players": [[[], 0, 0, [], []], [[], 0, 0, [], []]]} + trace = tmp_path / "match.trace.jsonl" + trace.write_text(json.dumps(_trace_row(7, obs)) + "\n", encoding="utf-8") + + class Old: + def act(self, observation): + return {"operation_type": "endround", "operation_parameters": {}} + + class New: + def act(self, observation): + return {"operation_type": "surrender", "operation_parameters": {}} + + result = compare_agents_on_trace( + trace, Old(), New(), camp=0, iteration=1, + old_version="old", new_version="new", + ) + + assert result["source_trace"] == str(trace.resolve()) + assert result["old_version"] == "old" + assert result["new_version"] == "new" + assert result["n_decisions"] == 1 + assert result["infinite_ratio"] == 1.0 + assert result["decisions"][0]["trace_seq"] == 7 + + +def test_malformed_agent_action_is_missing_with_reason(tmp_path): + obs = {"camp": 0, "round": 1, "map": {"units": [], "barracks": []}, + "players": [[[], 0, 0, [], []], [[], 0, 0, [], []]]} + trace = tmp_path / "bad.trace.jsonl" + trace.write_text(json.dumps(_trace_row(1, obs)) + "\n", encoding="utf-8") + + class Good: + def act(self, observation): + return {"operation_type": "endround", "operation_parameters": {}} + + class Bad: + def act(self, observation): + return {"wrong": "shape"} + + result = compare_agents_on_trace( + trace, Good(), Bad(), camp=0, iteration=2, + old_version="good", new_version="bad", + ) + + assert result["missing_ratio"] == 1.0 + assert result["decisions"][0]["missing_reason"] == "new_action_invalid" + + +def test_episode_save_uses_iteration_directory(tmp_path): + episode = aggregate_episode([], episode_id="ep-1", iteration=4) + path = save_episode_ig(episode, tmp_path) + + assert path == tmp_path / "iteration-0004" / "ep-1.json" + assert json.loads(path.read_text(encoding="utf-8"))["iteration"] == 4 diff --git a/tests/miracle/test_llm_client.py b/tests/miracle/test_llm_client.py new file mode 100644 index 0000000..4ae7897 --- /dev/null +++ b/tests/miracle/test_llm_client.py @@ -0,0 +1,197 @@ +import json +import threading +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from agentbench_frame.miracle.llm_client import ChatCompletionsClient, LLMRequestError +from agentbench_frame.miracle.loop_config import LLMConfig + + +@contextmanager +def _server(response, status=200): + captured = {} + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers["Content-Length"]) + captured.update({ + "path": self.path, + "headers": dict(self.headers), + "body": json.loads(self.rfile.read(length)), + }) + body = json.dumps(response).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + return + + httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{httpd.server_port}", captured + finally: + httpd.shutdown() + thread.join() + + +def _response(content): + return { + "choices": [{"message": {"content": content}}], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + } + + +def _config(base_url): + return LLMConfig( + base_url, "TEST_LLM_KEY", "mock-model", + max_tokens=123, reasoning_effort="low", stream=False, + ) + + +@contextmanager +def _sse_server(events): + captured = {} + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers["Content-Length"]) + captured["body"] = json.loads(self.rfile.read(length)) + body = "".join(events).encode() + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + return + + httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{httpd.server_port}", captured + finally: + httpd.shutdown() + thread.join() + + +def test_calls_chat_completions_and_parses_complete_source(monkeypatch): + content = json.dumps({"analysis": "improve", "strategy_code": "class CandidateAgent: pass"}) + monkeypatch.setenv("TEST_LLM_KEY", "secret-token") + with _server(_response(content)) as (base_url, captured): + proposal = ChatCompletionsClient(_config(base_url)).propose_strategy( + [{"role": "user", "content": "go"}] + ) + + assert captured["path"] == "/v1/chat/completions" + assert captured["headers"]["Authorization"] == "Bearer secret-token" + assert captured["headers"]["User-Agent"] == "AgentBenchFramework/0.1" + assert captured["body"]["model"] == "mock-model" + assert captured["body"]["max_tokens"] == 123 + assert captured["body"]["reasoning_effort"] == "low" + assert proposal.strategy_code == "class CandidateAgent: pass" + assert proposal.usage["total_tokens"] == 30 + assert proposal.normalized_fence is False + + +def test_accepts_one_fenced_json_object(): + content = '```json\n{"analysis":"a","strategy_code":"class CandidateAgent: pass"}\n```' + with _server(_response(content)) as (base_url, _): + proposal = ChatCompletionsClient(_config(base_url)).propose_strategy([]) + + assert proposal.normalized_fence is True + + +def test_malformed_proposal_has_stage_and_raw_response(): + response = _response("not-json") + with _server(response) as (base_url, _): + with pytest.raises(LLMRequestError) as raised: + ChatCompletionsClient(_config(base_url)).propose_strategy([]) + + assert raised.value.stage == "proposal_json" + assert raised.value.raw_response["choices"] == response["choices"] + assert raised.value.raw_response["usage"] == response["usage"] + assert raised.value.raw_response["stream"] is False + assert raised.value.usage["total_tokens"] == 30 + assert raised.value.latency_seconds >= 0 + + +def test_http_error_never_exposes_api_key(monkeypatch): + monkeypatch.setenv("TEST_LLM_KEY", "never-print-this") + with _server({"error": "denied"}, status=401) as (base_url, _): + with pytest.raises(LLMRequestError) as raised: + ChatCompletionsClient(_config(base_url)).propose_strategy([]) + + assert raised.value.stage == "request" + assert "never-print-this" not in str(raised.value) + + +def _data(value): + return "data: " + json.dumps(value) + "\n\n" + + +def test_stream_reconstructs_reasoning_content_usage_and_telemetry(): + events = [ + ": heartbeat\n\n", + _data({"id": "chat-1", "model": "mock-model", "choices": [{ + "delta": {"role": "assistant", "reasoning_content": "think "}, + "finish_reason": None, + }]}), + _data({"choices": [{"delta": {"reasoning_content": "done"}, + "finish_reason": None}]}), + _data({"choices": [{"delta": {"content": '{"analysis":"a",'}, + "finish_reason": None}]}), + _data({"choices": [{"delta": {"content": '"strategy_code":"code"}'}, + "finish_reason": "stop"}], + "usage": {"prompt_tokens": 3, "completion_tokens": 4, "total_tokens": 7}}), + "data: [DONE]\n\n", + ] + with _sse_server(events) as (base_url, captured): + config = LLMConfig(base_url, "", "mock-model", stream=True) + proposal = ChatCompletionsClient(config).propose_strategy([]) + + assert captured["body"]["stream"] is True + assert captured["body"]["stream_options"] == {"include_usage": True} + assert "max_tokens" not in captured["body"] + assert proposal.analysis == "a" + assert proposal.strategy_code == "code" + assert proposal.usage["total_tokens"] == 7 + assert proposal.raw_response["stream"] is True + assert proposal.raw_response["chunk_count"] == 4 + assert proposal.raw_response["usage_missing"] is False + assert proposal.raw_response["choices"][0]["message"]["reasoning_content"] == "think done" + + +def test_stream_malformed_chunk_preserves_partial_response(): + events = [ + _data({"choices": [{"delta": {"content": "partial"}, "finish_reason": None}]}), + "data: {bad-json}\n\n", + ] + with _sse_server(events) as (base_url, _): + with pytest.raises(LLMRequestError) as raised: + ChatCompletionsClient(LLMConfig(base_url, "", "mock", stream=True)).propose_strategy([]) + + assert raised.value.stage == "stream_chunk_json" + assert raised.value.raw_response["choices"][0]["message"]["content"] == "partial" + + +def test_stream_eof_before_done_is_explicitly_incomplete(): + events = [_data({ + "choices": [{"delta": {"content": "partial"}, "finish_reason": "length"}], + "usage": {"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5}, + })] + with _sse_server(events) as (base_url, _): + with pytest.raises(LLMRequestError) as raised: + ChatCompletionsClient(LLMConfig(base_url, "", "mock", stream=True)).propose_strategy([]) + + assert raised.value.stage == "stream_incomplete" + assert raised.value.usage["total_tokens"] == 5 + assert raised.value.raw_response["choices"][0]["finish_reason"] == "length" diff --git a/tests/miracle/test_loop.py b/tests/miracle/test_loop.py new file mode 100644 index 0000000..b219cb2 --- /dev/null +++ b/tests/miracle/test_loop.py @@ -0,0 +1,193 @@ +import json +from pathlib import Path + +from agentbench_frame.miracle.host import MatchResult +from agentbench_frame.miracle.llm_client import StrategyProposal +from agentbench_frame.miracle.loop import build_messages, run_loop +from agentbench_frame.miracle.loop_config import LoopConfig + + +INITIAL = ''' +from agentbench_frame.miracle.agent_bridge import MiracleAgent +class CandidateAgent(MiracleAgent): + def choose_cards(self, camp): + return {"artifacts":["HolyLight"],"creatures":["Archer","Swordsman","BlackBat"]} + def act(self, obs): + return {"operation_type":"endround","operation_parameters":{}} +''' + +EVOLVED = INITIAL.replace('"endround"', '"surrender"') + + +def _config(tmp_path, *, iterations=1): + (tmp_path / "initial.py").write_text(INITIAL, encoding="utf-8") + path = tmp_path / "loop.toml" + path.write_text(f''' +agent = "tested_llm" +initial_strategy = "initial.py" +opponent = "endround" +[llm] +base_url = "http://localhost:1" +api_key_env = "KEY" +model = "mock" +[evaluation] +seeds = [11] +seats = [0] +[budget] +max_iterations = {iterations} +max_rollouts = 4 +max_episode_reads = 1 +max_decision_reads = 5 +max_total_tokens = 100 +max_wall_seconds = 60 +''', encoding="utf-8") + return LoopConfig.from_toml(path) + + +def _config_with_context_limit(tmp_path, limit): + config = _config(tmp_path) + path = tmp_path / "loop.toml" + text = path.read_text(encoding="utf-8").replace( + 'model = "mock"', f'model = "mock"\nmax_context_tokens = {limit}', + ).replace("max_total_tokens = 100", "max_total_tokens = 1000") + path.write_text(text, encoding="utf-8") + return LoopConfig.from_toml(path) + + +def _trace_row(obs): + return {"seq": 1, "kind": "from_logic", "payload": { + "listen": [obs["camp"]], "content": ["000000" + json.dumps(obs)], + }} + + +def _fake_match(agent0, agent1, *, replay_dir, seed, tag, **kwargs): + replay_dir = Path(replay_dir) + replay_dir.mkdir(parents=True, exist_ok=True) + obs = {"camp": 0, "round": 1, "map": {"units": [], "barracks": []}, + "players": [[[], 0, 0, [], []], [[], 0, 0, [], []]]} + action = agent0.act(obs)["operation_type"] + score = 10 if action == "surrender" else 1 + replay = replay_dir / f"{tag}.mrc" + trace = replay_dir / f"{tag}.mrc.trace.jsonl" + replay.write_bytes(b"real-ish-replay") + trace.write_text(json.dumps(_trace_row(obs)) + "\n", encoding="utf-8") + return MatchResult(0, (score, 0), 1, str(replay), str(trace), 0.01, "normal", []) + + +class FakeClient: + def propose_strategy(self, messages): + raw = {"choices": [{"message": {"content": "saved"}}]} + return StrategyProposal( + "improve", EVOLVED, + {"prompt_tokens": 12, "completion_tokens": 8, "total_tokens": 20}, + {"model": "mock", "messages": messages}, raw, 0.02, False, + ) + + +def test_build_messages_contains_contract_skills_source_evidence_and_budget(): + messages = build_messages( + INITIAL, {"harness": "HARNESS", "replay": "REPLAY"}, + [{"episode_id": "ep", "observations": [{"round": 1}]}], + {"score": 1}, {"total_tokens": 0}, + ) + + assert [message["role"] for message in messages] == ["system", "user"] + combined = "\n".join(message["content"] for message in messages) + assert "CandidateAgent" in combined + assert "HARNESS" in combined and "REPLAY" in combined + assert "strategy_code" in combined + + +def test_run_loop_saves_baseline_candidate_curves_and_usage(tmp_path): + run_dir = run_loop( + _config(tmp_path), client=FakeClient(), match_runner=_fake_match, + data_dir=tmp_path / "results", run_id="fixed-run", + ) + + assert (run_dir / "iterations/iteration-0000/strategy.py").exists() + assert (run_dir / "iterations/iteration-0001/candidate.py").exists() + assert (run_dir / "iterations/iteration-0001/strategy.py").exists() + assert (run_dir / "iterations/iteration-0001/llm_request.json").exists() + score_curve = json.loads((run_dir / "score_curve.json").read_text()) + ig_curve = json.loads((run_dir / "ig_curve.json").read_text()) + summary = json.loads((run_dir / "summary.json").read_text()) + + assert score_curve["points"][0]["evo"] == 1.0 + assert score_curve["points"][1]["evo"] == 10.0 + assert score_curve["points"][1]["gain"] == 9.0 + assert ig_curve["points"][1]["status"] == "measured" + assert summary["total_tokens"] == 20 + assert summary["final_gain"] == 9.0 + events = (run_dir / "events.jsonl").read_text() + assert "llm_request_started" in events and "llm_request_finished" in events + + +class InvalidSourceClient(FakeClient): + def propose_strategy(self, messages): + proposal = super().propose_strategy(messages) + return StrategyProposal( + proposal.analysis, "class CandidateAgent(:", proposal.usage, + proposal.request_body, proposal.raw_response, proposal.latency_seconds, + ) + + +def test_invalid_candidate_is_preserved_without_advancing_strategy(tmp_path): + run_dir = run_loop( + _config(tmp_path), client=InvalidSourceClient(), match_runner=_fake_match, + data_dir=tmp_path / "results", run_id="failed-run", + ) + + failed = json.loads((run_dir / "iterations/iteration-0001/iteration.json").read_text()) + assert failed["status"] == "failed" + assert failed["failure_stage"] == "compile" + assert (run_dir / "iterations/iteration-0001/candidate.py").exists() + assert not (run_dir / "iterations/iteration-0001/strategy.py").exists() + assert json.loads((run_dir / "summary.json").read_text())["failure_counts"]["compile"] == 1 + + +class FailThenSucceedClient(FakeClient): + def __init__(self): + self.calls = 0 + + def propose_strategy(self, messages): + self.calls += 1 + proposal = super().propose_strategy(messages) + if self.calls == 1: + return StrategyProposal( + proposal.analysis, "class CandidateAgent(:", proposal.usage, + proposal.request_body, proposal.raw_response, proposal.latency_seconds, + ) + return proposal + + +def test_later_iteration_can_continue_after_failed_update(tmp_path): + run_dir = run_loop( + _config(tmp_path, iterations=2), client=FailThenSucceedClient(), + match_runner=_fake_match, data_dir=tmp_path / "results", run_id="resume-run", + ) + + second = json.loads((run_dir / "iterations/iteration-0002/iteration.json").read_text()) + assert second["status"] == "accepted" + + +class ContextHeavyClient(FakeClient): + def propose_strategy(self, messages): + proposal = super().propose_strategy(messages) + return StrategyProposal( + proposal.analysis, proposal.strategy_code, + {"prompt_tokens": 60, "completion_tokens": 41, "total_tokens": 101}, + proposal.request_body, proposal.raw_response, proposal.latency_seconds, + ) + + +def test_context_limit_preserves_real_usage_and_rejects_candidate(tmp_path): + run_dir = run_loop( + _config_with_context_limit(tmp_path, 100), client=ContextHeavyClient(), + match_runner=_fake_match, data_dir=tmp_path / "results", run_id="context-run", + ) + + iteration = json.loads((run_dir / "iterations/iteration-0001/iteration.json").read_text()) + summary = json.loads((run_dir / "summary.json").read_text()) + assert iteration["status"] == "failed" + assert iteration["failure_stage"] == "context_tokens" + assert summary["total_tokens"] == 101 diff --git a/tests/miracle/test_loop_config.py b/tests/miracle/test_loop_config.py new file mode 100644 index 0000000..1be5e6c --- /dev/null +++ b/tests/miracle/test_loop_config.py @@ -0,0 +1,99 @@ +from pathlib import Path + +import pytest + +from agentbench_frame.miracle.loop_config import LoopConfig + + +def _write_config(tmp_path: Path, *, opponent="sample", max_iterations=1) -> Path: + initial = tmp_path / "initial.py" + initial.write_text("# initial\n", encoding="utf-8") + path = tmp_path / "loop.toml" + path.write_text( + f'''agent = "tested_llm" +initial_strategy = "initial.py" +opponent = "{opponent}" + +[llm] +base_url = "http://127.0.0.1:8123" +api_key_env = "TEST_LLM_KEY" +model = "mock-model" +reasoning_effort = "low" + +[evaluation] +seeds = [11] +seats = [0] + +[budget] +max_iterations = {max_iterations} +max_rollouts = 4 +max_episode_reads = 1 +max_decision_reads = 200 +max_total_tokens = 10000 +max_wall_seconds = 600 +''', + encoding="utf-8", + ) + return path + + +def test_loads_minimal_loop_config(tmp_path): + cfg = LoopConfig.from_toml(_write_config(tmp_path)) + + assert cfg.agent == "tested_llm" + assert cfg.initial_strategy == (tmp_path / "initial.py").resolve() + assert cfg.evaluation.seeds == (11,) + assert cfg.evaluation.seats == (0,) + assert cfg.budget.max_iterations == 1 + assert cfg.llm.temperature == 0.0 + assert cfg.llm.max_tokens is None + assert cfg.llm.timeout_seconds == 120.0 + assert cfg.llm.reasoning_effort == "low" + assert cfg.llm.stream is True + assert cfg.llm.max_context_tokens == 1_000_000 + + +@pytest.mark.parametrize( + ("opponent", "max_iterations", "message"), + [("not_registered", 1, "opponent"), ("sample", 0, "max_iterations")], +) +def test_rejects_unknown_opponent_and_nonpositive_budget( + tmp_path, opponent, max_iterations, message, +): + with pytest.raises(ValueError, match=message): + LoopConfig.from_toml( + _write_config(tmp_path, opponent=opponent, max_iterations=max_iterations) + ) + + +def test_public_config_never_resolves_or_contains_api_secret(tmp_path, monkeypatch): + monkeypatch.setenv("TEST_LLM_KEY", "top-secret-value") + cfg = LoopConfig.from_toml(_write_config(tmp_path)) + + public = cfg.public_dict() + + assert public["llm"]["api_key_env"] == "TEST_LLM_KEY" + assert "top-secret-value" not in repr(public) + + +def test_rejects_missing_initial_strategy(tmp_path): + path = _write_config(tmp_path) + (tmp_path / "initial.py").unlink() + + with pytest.raises(ValueError, match="initial_strategy"): + LoopConfig.from_toml(path) + + +def test_allows_explicit_nonstreaming_and_generation_limit(tmp_path): + path = _write_config(tmp_path) + text = path.read_text(encoding="utf-8").replace( + 'reasoning_effort = "low"', + 'reasoning_effort = "low"\nstream = false\nmax_tokens = 1234\nmax_context_tokens = 2000000', + ) + path.write_text(text, encoding="utf-8") + + cfg = LoopConfig.from_toml(path) + + assert cfg.llm.stream is False + assert cfg.llm.max_tokens == 1234 + assert cfg.llm.max_context_tokens == 2_000_000 diff --git a/tests/miracle/test_loop_e2e.py b/tests/miracle/test_loop_e2e.py new file mode 100644 index 0000000..2a43690 --- /dev/null +++ b/tests/miracle/test_loop_e2e.py @@ -0,0 +1,117 @@ +import json +import threading +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from agentbench_frame.miracle.cli import main + + +INITIAL = ''' +from agentbench_frame.miracle.agent_bridge import EndRoundAgent +class CandidateAgent(EndRoundAgent): + pass +''' + +EVOLVED = ''' +from agentbench_frame.miracle.agent_bridge import SampleAgent +class CandidateAgent(SampleAgent): + pass +''' + + +@contextmanager +def _mock_openai(): + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers["Content-Length"]) + json.loads(self.rfile.read(length)) + content = json.dumps({"analysis": "Use the working sample policy.", + "strategy_code": EVOLVED}) + response = json.dumps({ + "choices": [{"message": {"content": content}}], + "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}, + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(response))) + self.end_headers() + self.wfile.write(response) + + def log_message(self, format, *args): + return + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + thread.join() + + +def test_cli_completes_real_official_logic_iteration(tmp_path, monkeypatch, capsys): + initial = tmp_path / "initial.py" + initial.write_text(INITIAL, encoding="utf-8") + monkeypatch.setenv("MIRACLE_E2E_KEY", "must-not-be-saved") + with _mock_openai() as base_url: + config = tmp_path / "loop.toml" + config.write_text(f''' +agent = "e2e_llm" +initial_strategy = "initial.py" +opponent = "endround" +[llm] +base_url = "{base_url}" +api_key_env = "MIRACLE_E2E_KEY" +model = "mock" +stream = false +[evaluation] +seeds = [11] +seats = [0] +[budget] +max_iterations = 1 +max_rollouts = 2 +max_episode_reads = 1 +max_decision_reads = 500 +max_total_tokens = 100 +max_wall_seconds = 120 +''', encoding="utf-8") + assert main(["loop", "--config", str(config), + "--data-dir", str(tmp_path / "results")]) == 0 + + output = json.loads(capsys.readouterr().out) + run_dir = next((tmp_path / "results/runs/24_miracle/e2e_llm").iterdir()) + assert output["run_dir"] == str(run_dir.resolve()) + strategy0 = (run_dir / "iterations/iteration-0000/strategy.py").read_text() + strategy1 = (run_dir / "iterations/iteration-0001/strategy.py").read_text() + assert strategy0 != strategy1 + + for index in (0, 1): + episode_dir = run_dir / f"iterations/iteration-{index:04d}/episodes" + result = json.loads(next(episode_dir.glob("*.json")).read_text()) + assert result["terminated_by"] == "normal" + assert result["errors"] == [] + assert next(episode_dir.glob("*.mrc")).stat().st_size > 0 + assert next(episode_dir.glob("*.trace.jsonl")).stat().st_size > 0 + + score = json.loads((run_dir / "score_curve.json").read_text()) + ig = json.loads((run_dir / "ig_curve.json").read_text()) + summary = json.loads((run_dir / "summary.json").read_text()) + assert score["points"][1]["evo"] > score["points"][0]["evo"] + assert ig["points"][0]["status"] == "baseline" + assert ig["points"][1]["status"] == "measured" + assert summary["final_gain"] > 0 + assert summary["total_tokens"] == 30 + assert "wall_hours" in summary + assert (run_dir / "run.toml").is_file() + + all_text = "\n".join( + path.read_text(encoding="utf-8", errors="replace") + for path in run_dir.rglob("*") if path.is_file() and path.suffix != ".mrc" + ) + assert "must-not-be-saved" not in all_text + events = [json.loads(line)["event"] for line in (run_dir / "events.jsonl").read_text().splitlines()] + assert { + "battle_started", "battle_finished", "llm_request_started", + "llm_request_finished", "iteration_finished", "run_finished", + } <= set(events) diff --git a/tests/miracle/test_replay.py b/tests/miracle/test_replay.py new file mode 100644 index 0000000..b61f261 --- /dev/null +++ b/tests/miracle/test_replay.py @@ -0,0 +1,85 @@ +""".mrc → 事件时间线,并与同局 trace 交叉核对。""" + +import json + +import pytest + +from agentbench_frame.miracle import EndRoundAgent, SampleAgent, run_match +from agentbench_frame.miracle.replay import ( + EVENT_NAMES, + parse_replay, + save_replay_json, + load_replay_json, + summarize, +) + + +@pytest.fixture(scope="module") +def full_match_replay(tmp_path_factory): + out = tmp_path_factory.mktemp("miracle-replay") + result = run_match(SampleAgent(), EndRoundAgent(), replay_dir=out, seed=11) + assert result.terminated_by == "normal" + assert result.errors == [] + return result.replay_path + + +def _ops_from_trace(trace_path): + rows = [json.loads(line) for line in open(trace_path, encoding="utf-8")] + return [json.loads(r["payload"]["content"]) for r in rows if r["kind"] == "to_logic"] + + +def test_event_name_tables_match_official(): + # 与官方 gamecode_logic/main.py 的事件表逐字一致 + assert EVENT_NAMES[3] == "Spawn" and EVENT_NAMES[10] == "GameEnd" and EVENT_NAMES[18] == "Summon" + assert len(EVENT_NAMES) == 19 + + +def test_parse_full_match_matches_trace_operations(full_match_replay): + events = parse_replay(full_match_replay) + s = summarize(events) + + assert s["ended"] is True + assert s["winner"] == 0 + assert s["end_round"] >= 1 + + # 事件与 trace 操作分布对齐(官方 1 个操作 → 1 个主事件) + ops = _ops_from_trace(full_match_replay + ".trace.jsonl") + from collections import Counter + counts = Counter(o["operation_type"] for o in ops) + assert s["event_counts"]["Summon"] == counts["summon"] + assert s["event_counts"]["Move"] == counts["move"] + assert s["event_counts"]["Attack"] == counts["attack"] + assert s["event_counts"]["TurnEnd"] == counts["endround"] + assert s["event_counts"]["GameStart"] == counts["init"] + + +def test_game_end_and_round_span(full_match_replay): + events = parse_replay(full_match_replay) + game_ends = [e for e in events if e.type == "GameEnd"] + assert len(game_ends) == 1 + assert game_ends[0].args[0] in (0, 1) # winner + rounds = [e.round for e in events if e.round >= 0] + assert max(rounds) == game_ends[0].round + + +def test_no_unsync_on_full_match(full_match_replay): + events = parse_replay(full_match_replay) + assert not [e for e in events if e.type in ("UNSYNC", "UNKNOWN")], "存在未同步事件" + + +def test_events_round_monotonic(full_match_replay): + events = [e for e in parse_replay(full_match_replay) if e.round >= 0] + prev = -1 + for e in events: + assert e.round >= prev + prev = e.round + + +def test_save_and_load_json_roundtrip(tmp_path, full_match_replay): + events = parse_replay(full_match_replay) + out = tmp_path / "events.jsonl" + save_replay_json(events, out) + reloaded = load_replay_json(out) + assert len(reloaded) == len(events) + assert reloaded[0].type == events[0].type + assert reloaded[-1].type == "END" diff --git a/tests/miracle/test_run_store.py b/tests/miracle/test_run_store.py new file mode 100644 index 0000000..eafc638 --- /dev/null +++ b/tests/miracle/test_run_store.py @@ -0,0 +1,95 @@ +import json +import tomllib + +import pytest + +from agentbench_frame.miracle.loop_config import LoopConfig +from agentbench_frame.miracle.run_store import BudgetExceeded, BudgetLedger, MiracleRunStore + + +def _config(tmp_path): + initial = tmp_path / "initial.py" + initial.write_text("# strategy\n", encoding="utf-8") + config = tmp_path / "loop.toml" + config.write_text(''' +agent = "tested_llm" +initial_strategy = "initial.py" +opponent = "sample" +[llm] +base_url = "http://localhost:1" +api_key_env = "KEY" +model = "model" +[evaluation] +seeds = [11] +seats = [0] +[budget] +max_iterations = 1 +max_rollouts = 2 +max_episode_reads = 1 +max_decision_reads = 5 +max_total_tokens = 30 +max_wall_seconds = 60 +''', encoding="utf-8") + return LoopConfig.from_toml(config) + + +def test_creates_results_compatible_run_and_copies_skills(tmp_path): + config = _config(tmp_path) + store = MiracleRunStore.create(config, data_dir=tmp_path / "results", run_id="run-fixed") + + assert store.run_dir == tmp_path / "results/runs/24_miracle/tested_llm/run-fixed" + assert store.iteration_dir(0).name == "iteration-0000" + assert (store.run_dir / "skills/miracle-harness.SKILL.md").is_file() + assert (store.run_dir / "skills/miracle-replay-reader.SKILL.md").is_file() + meta = tomllib.loads((store.run_dir / "run.toml").read_text(encoding="utf-8")) + assert meta["run"]["type"] == "rule_iter" + assert not (store.run_dir / "summary.json").exists() + + store.write_event("iteration_started", iteration=0) + store.finish({"status": "complete", "total_tokens": 0, + "total_steps": 3, "total_episodes": 1, "win_rate": 0.0}) + + event = json.loads((store.run_dir / "events.jsonl").read_text().splitlines()[0]) + assert event["event"] == "iteration_started" + summary = json.loads((store.run_dir / "summary.json").read_text()) + assert summary["status"] == "complete" + assert "wall_hours" in summary + final_meta = tomllib.loads((store.run_dir / "run.toml").read_text(encoding="utf-8")) + assert final_meta["run"]["total_steps"] == 3 + assert final_meta["run"]["total_episodes"] == 1 + assert not list(store.run_dir.rglob("*.tmp")) + + +def test_budget_ledger_charges_exact_limits_then_rejects_next(tmp_path): + ledger = BudgetLedger(_config(tmp_path).budget) + ledger.charge_rollout(2) + ledger.charge_read(episodes=1, decisions=5) + ledger.charge_usage({"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}) + ledger.charge_api_time(1.25) + ledger.charge_battle_time(2.5) + + snapshot = ledger.snapshot() + assert snapshot["rollouts"] == 2 + assert snapshot["episode_reads"] == 1 + assert snapshot["decision_reads"] == 5 + assert snapshot["total_tokens"] == 30 + assert snapshot["api_seconds"] == 1.25 + assert snapshot["battle_seconds"] == 2.5 + + with pytest.raises(BudgetExceeded) as raised: + ledger.charge_rollout() + assert raised.value.dimension == "rollouts" + + +def test_budget_rejects_each_bounded_counter(tmp_path): + config = _config(tmp_path).budget + cases = [ + (lambda ledger: ledger.charge_read(2, 0), "episode_reads"), + (lambda ledger: ledger.charge_read(0, 6), "decision_reads"), + (lambda ledger: ledger.charge_usage({"total_tokens": 31}), "total_tokens"), + ] + for charge, dimension in cases: + ledger = BudgetLedger(config) + with pytest.raises(BudgetExceeded) as raised: + charge(ledger) + assert raised.value.dimension == dimension diff --git a/tests/miracle/test_score.py b/tests/miracle/test_score.py new file mode 100644 index 0000000..eb12b00 --- /dev/null +++ b/tests/miracle/test_score.py @@ -0,0 +1,43 @@ +from agentbench_frame.miracle.score import aggregate_score, build_score_curve, trapezoid_auc + + +def test_aggregate_score_is_seat_aligned_and_preserves_failures(): + episodes = [ + {"episode_id": "a", "scores": [10, 3], "winner": 0, + "terminated_by": "normal", "errors": []}, + {"episode_id": "b", "scores": [4, 20], "winner": 1, + "terminated_by": "normal", "errors": []}, + {"episode_id": "c", "scores": [0, 0], "winner": 1, + "terminated_by": "host_error", "errors": ["bad"]}, + ] + result = aggregate_score(episodes, {"a": 0, "b": 1, "c": 0}) + + assert result["mean_score"] == 15.0 + assert result["win_rate"] == 1.0 + assert result["completion_rate"] == 2 / 3 + assert result["counts"] == {"normal": 2, "timeout": 0, "failed": 1, "missing": 0} + assert len(result["episodes"]) == 3 + + +def test_curve_uses_fixed_raw_and_keeps_negative_gain(): + curve = build_score_curve([ + {"iteration": 0, "version": "v0", "status": "baseline", "score": 10, + "win_rate": 0.0, "completion_rate": 1.0, "budget": {}}, + {"iteration": 1, "version": "v1", "status": "accepted", "score": 20, + "win_rate": 1.0, "completion_rate": 1.0, "budget": {}}, + {"iteration": 2, "version": "v2", "status": "accepted", "score": 5, + "win_rate": 0.0, "completion_rate": 1.0, "budget": {}}, + ]) + + assert [point["raw"] for point in curve["points"]] == [10, 10, 10] + assert [point["gain"] for point in curve["points"]] == [0, 10, -5] + + +def test_trapezoid_auc_and_insufficient_points(): + measured = trapezoid_auc([ + {"x": 0, "y": 10}, {"x": 2, "y": 20}, {"x": 5, "y": 10}, + ], "x", "y") + missing = trapezoid_auc([{"x": 0, "y": 10}], "x", "y") + + assert measured == {"value": 75.0, "reason": None, "n_points": 3} + assert missing == {"value": None, "reason": "insufficient_points", "n_points": 1} diff --git a/tests/miracle/test_smoke.py b/tests/miracle/test_smoke.py new file mode 100644 index 0000000..90691ad --- /dev/null +++ b/tests/miracle/test_smoke.py @@ -0,0 +1,106 @@ +"""Phase 1 验收:官方逻辑零改动子进程运行 + 规则策略对战闭环。 + +覆盖: +- 线协议编解码往返 +- 双 endround:100 回合平局(scores=(0,1)、winner=1、normal 终局) +- sample vs endround:正常终局、replay/trace 落盘、行动类型齐全、无卡死/超时 +""" + +import json + +import pytest + +from agentbench_frame.miracle import EndRoundAgent, SampleAgent, run_match +from agentbench_frame.miracle.protocol import ( + decode_content, + encode_to_logic, + read_logic_frame, +) + + +def _ops_of(trace_path): + ops = [] + for line in open(trace_path, encoding="utf-8"): + row = json.loads(line) + if row["kind"] == "to_logic" and row["payload"].get("player") in (0, 1): + inner = json.loads(row["payload"]["content"]) + ops.append(inner.get("operation_type")) + return ops + + +class TestProtocol: + def test_roundtrip(self): + payload = {"player_list": [1, 1], "replay": "/tmp/x.mrc"} + raw = encode_to_logic(payload) + assert raw[:4] == b"\x00\x00\x00" + bytes([len(raw) - 4]) + data = raw[4:] + assert json.loads(data) == payload + + def test_decode_content_parse(self): + payload = {"state": 5, "listen": [0], "content": ["000277" + json.dumps({"a": 1})]} + assert decode_content(payload["content"]) == {"a": 1} + + +class TestFullMatch: + def test_separate_trace_directory_is_created(self, tmp_path): + r = run_match( + EndRoundAgent(), EndRoundAgent(), + replay_dir=tmp_path / "replays", + trace_dir=tmp_path / "nested" / "traces", + seed=7, + ) + assert r.terminated_by == "normal" + assert (tmp_path / "nested" / "traces").is_dir() + + def test_host_error_is_not_masked_by_unbound_result(self, tmp_path, monkeypatch): + from agentbench_frame.miracle.host import MiracleHost + + def fail_run(self, replay_path): + raise RuntimeError("host failed before producing a result") + + monkeypatch.setattr(MiracleHost, "run", fail_run) + with pytest.raises(RuntimeError, match="host failed before producing a result"): + run_match( + EndRoundAgent(), EndRoundAgent(), replay_dir=tmp_path, seed=7, + ) + + def test_endround_pair_hits_round_cap(self, tmp_path): + r = run_match( + EndRoundAgent(), EndRoundAgent(), + replay_dir=tmp_path, seed=7, tag="t_endround", + ) + assert r.terminated_by == "normal" + assert r.errors == [] + assert r.rounds >= 99 # 100 回合封顶 + # 官方终局语义:平局时后手 +1 分、winner=1 + assert r.scores == (0, 1) + assert r.winner == 1 + import os + assert os.path.exists(r.replay_path) + assert os.path.getsize(r.replay_path) > 0 + assert os.path.exists(r.trace_path) + + def test_sample_vs_endround_finishes_and_acts(self, tmp_path): + r = run_match( + SampleAgent(), EndRoundAgent(), + replay_dir=tmp_path, seed=11, tag="t_sample", + ) + assert r.terminated_by == "normal", r.errors + assert r.errors == [] + ops = _ops_of(r.trace_path) + kinds = set(ops) + # 规则策略应产生召唤/移动/攻击/endround(攻破神迹或打满 100 回合) + assert "summon" in kinds + assert "move" in kinds + assert "attack" in kinds + assert "endround" in kinds + # 整局无卡死:若中途触发防卡死 timeout,terminated_by 会是 timeout + import os + assert os.path.getsize(r.replay_path) > 0 + + def test_sample_vs_sample_reproducible(self, tmp_path): + r1 = run_match(SampleAgent(), SampleAgent(), replay_dir=tmp_path, seed=3, tag="t_ss") + r2 = run_match(SampleAgent(), SampleAgent(), replay_dir=tmp_path, seed=3, tag="t_ss2") + assert r1.scores == r2.scores + assert r1.winner == r2.winner + assert r1.terminated_by == r2.terminated_by == "normal" diff --git a/tests/miracle/test_strategy_loader.py b/tests/miracle/test_strategy_loader.py new file mode 100644 index 0000000..4e32372 --- /dev/null +++ b/tests/miracle/test_strategy_loader.py @@ -0,0 +1,78 @@ +import pytest + +from agentbench_frame.miracle.strategy_loader import ( + StrategyValidationError, + load_candidate, + save_source, + validate_candidate, +) + + +VALID_SOURCE = ''' +from agentbench_frame.miracle.agent_bridge import MiracleAgent + +class CandidateAgent(MiracleAgent): + def choose_cards(self, camp): + return {"artifacts": ["HolyLight"], "creatures": ["Archer", "Swordsman", "BlackBat"]} + + def act(self, obs): + return {"operation_type": "endround", "operation_parameters": {}} +''' + + +def test_saves_and_loads_separate_strategy_snapshots(tmp_path): + first = tmp_path / "v0.py" + second = tmp_path / "v1.py" + save_source(first, VALID_SOURCE) + save_source(second, VALID_SOURCE.replace("endround", "surrender")) + + agent0 = load_candidate(first, "candidate_v0") + agent1 = load_candidate(second, "candidate_v1") + + validate_candidate(agent0) + validate_candidate(agent1) + assert type(agent0).__module__ != type(agent1).__module__ + assert agent0.act({})["operation_type"] == "endround" + assert agent1.act({})["operation_type"] == "surrender" + + +def test_snapshot_refuses_different_overwrite(tmp_path): + path = tmp_path / "strategy.py" + save_source(path, VALID_SOURCE) + save_source(path, VALID_SOURCE) + + with pytest.raises(StrategyValidationError) as raised: + save_source(path, VALID_SOURCE + "\n# changed") + + assert raised.value.stage == "snapshot" + + +@pytest.mark.parametrize( + ("source", "stage"), + [ + ("class CandidateAgent(:\n", "compile"), + ("VALUE = 1\n", "class"), + ], +) +def test_reports_source_validation_stage(tmp_path, source, stage): + path = tmp_path / f"{stage}.py" + save_source(path, source) + + with pytest.raises(StrategyValidationError) as raised: + load_candidate(path, f"candidate_{stage}") + + assert raised.value.stage == stage + + +def test_rejects_bad_action_shape(tmp_path): + path = tmp_path / "bad.py" + save_source(path, VALID_SOURCE.replace( + 'return {"operation_type": "endround", "operation_parameters": {}}', + 'return {"wrong": "shape"}', + )) + agent = load_candidate(path, "candidate_bad_action") + + with pytest.raises(StrategyValidationError) as raised: + validate_candidate(agent) + + assert raised.value.stage == "action_shape"