from __future__ import annotations import jax import jax.numpy as jnp import numpy as np from lost_cities_jax.engine import board_score, reset_from_order, step from lost_cities_jax.human_play import ( append_human_log, evaluate_agent_policy, human_deck_order, parse_human_action, play_one_game, prompt_human_action, render_public_state, session_human_seats, summarize_logs, ) from lost_cities_jax.opponents import discard_only_action from lost_cities_jax.ppo import ( ActorCritic, JaxPPOConfig, NetworkConfig, OpponentConfig, PPOHyperConfig, RunConfig, create_train_state, ) def tiny_cfg(tmp_path) -> JaxPPOConfig: return JaxPPOConfig( run=RunConfig(experiment_name="human-play-test", artifact_root=str(tmp_path)), opponent=OpponentConfig(name="discard_only"), network=NetworkConfig(hidden_size=32, num_layers=1), ppo=PPOHyperConfig(batch_games=4, rollout_steps=16, epochs=1, minibatches=2), ) def test_illegal_input_reprompts_with_reason() -> None: state = reset_from_order(jnp.asarray(list(range(60)), dtype=jnp.int8)) inputs = iter(["play R2 draw R", "play R2 draw deck"]) outputs: list[str] = [] action = prompt_human_action( state, 0, input_fn=lambda _prompt: next(inputs), output_fn=outputs.append, ) assert action == parse_human_action("play R2 draw deck", state, 0) assert any("Illegal move" in line for line in outputs) assert any("discard pile is empty" in line for line in outputs) def test_public_renderer_does_not_show_opponent_hand_or_deck_order() -> None: p0 = [0, 3, 4, 5, 6, 7, 8, 9] p1 = [12, 15, 16, 17, 18, 19, 20, 21] rest = [card for card in range(60) if card not in {*p0, *p1}] state = reset_from_order(jnp.asarray(p0 + p1 + rest, dtype=jnp.int8)) rendered = render_public_state(state, 0) assert "R2" in rendered assert "G2" not in rendered assert "G6" not in rendered assert str(p0 + p1 + rest) not in rendered def test_duplicate_session_swaps_seats() -> None: assert session_human_seats(0, duplicate=True) == [0, 1] assert session_human_seats(1, duplicate=True) == [1, 0] assert session_human_seats(1, duplicate=False) == [1] def test_human_play_summary_groups_duplicate_sets(tmp_path) -> None: base = { "schema": "lost-cities-jax-human-play-v1", "session_id": "s", "duplicate_set_id": "set-a", "moves": [ {"ply": 0, "ai_value": 0.1, "action_text": "play R2 draw deck"}, {"ply": 1, "ai_value": 0.6, "action_text": "discard G2 draw deck"}, ], "ai_opened_colors": 3, "game_length": 44, } append_human_log(tmp_path, {**base, "duplicate_game_index": 1, "human_score_diff": 10}) append_human_log(tmp_path, {**base, "duplicate_game_index": 2, "human_score_diff": -4}) summary = summarize_logs(tmp_path) assert summary["games"] == 2 assert summary["duplicate_sets"] == 1 assert summary["human_duplicate_set_diff_mean"] == 6.0 assert summary["ai_opened_colors_mean"] == 3.0 assert summary["value_swings_top10"][0]["abs_delta"] == 0.5 def test_human_play_bot_path_matches_direct_engine_for_ten_games(tmp_path) -> None: cfg = tiny_cfg(tmp_path) train_state = create_train_state(cfg, jax.random.PRNGKey(7)) agent = (train_state.params, ActorCritic(cfg.network.hidden_size, cfg.network.num_layers)) for index in range(10): deck_order = human_deck_order(991, index) record = play_one_game( cfg=cfg, agent=agent, deck_order=deck_order, human_seat=index % 2, session_id=f"test-{index}", duplicate_set_id=None, duplicate_game_index=None, deck_seed=991, deck_index=index, input_fn=lambda _prompt: "", output_fn=lambda _line: None, human_action_fn=_discard_only_human, ) direct_scores, direct_actions = _direct_game(cfg, agent, deck_order, index % 2) assert record["actions"] == direct_actions assert record["final_scores"]["p0"] == float(direct_scores[0]) assert record["final_scores"]["p1"] == float(direct_scores[1]) def _discard_only_human(state, player: int) -> int: return int( discard_only_action(state, jnp.asarray(player, dtype=jnp.int32), jax.random.PRNGKey(0)) ) def _direct_game( cfg, agent, deck_order: list[int], human_seat: int ) -> tuple[np.ndarray, list[int]]: params, model = agent state = reset_from_order(jnp.asarray(deck_order, dtype=jnp.int8)) actions = [] while not bool(state.done): player = int(state.to_move) if player == human_seat: action = _discard_only_human(state, player) else: action = evaluate_agent_policy(cfg, params, model, state, player).action actions.append(action) state, _, _ = step(state, jnp.asarray(action, dtype=jnp.int32)) return np.asarray(board_score(state), dtype=np.float32), actions