| """Tests del motor. Ejecutar desde la raíz: `pytest game/test_engine.py`."""
|
|
|
| import random
|
|
|
| import pytest
|
|
|
| from game.engine import (
|
| BASE_BOARD, RED, BLUE, DISPUTED, SPLIT,
|
| GameState as _RealGameState, validate_effect, zone_to_cells, goblin_choose,
|
| )
|
|
|
| def GameState(*args, **kwargs):
|
| kwargs.setdefault("starting_laws", False)
|
| kwargs.setdefault("board", BASE_BOARD)
|
| return _RealGameState(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
| def test_zonas_basicas():
|
| assert zone_to_cells("corners") == {(0, 0), (0, 2), (2, 0), (2, 2)}
|
| assert zone_to_cells("center") == {(1, 1)}
|
| assert zone_to_cells("cell(1,2)") == {(1, 2)}
|
| assert zone_to_cells("CELL( 0 , 0 )") == {(0, 0)}
|
| assert zone_to_cells("cell(3,0)") is None
|
| assert zone_to_cells("la_luna") is None
|
|
|
|
|
|
|
|
|
|
|
|
|
| def test_efecto_valido():
|
| ok, _ = validate_effect({"type": "multiply_value", "zone": "corners", "factor": 2})
|
| assert ok
|
|
|
|
|
| @pytest.mark.parametrize("efecto,motivo", [
|
| ({"type": "win_game"}, "tipo desconocido"),
|
| ({"type": "set_score", "owner": "red", "value": 999}, "victoria directa"),
|
| ({"type": "multiply_value", "zone": "la_luna", "factor": 2}, "zona inválida"),
|
| ({"type": "multiply_value", "zone": "corners", "factor": 10}, "factor fuera de rango"),
|
| ({"type": "add_value", "zone": "all", "amount": -7}, "amount fuera de rango"),
|
| ({"type": "set_value", "zone": "center", "value": 50}, "value fuera de rango"),
|
| ({"type": "forbid_placement", "zone": "all", "owner": "red"}, "prohibir todo"),
|
| ({"type": "forbid_placement", "zone": "center", "owner": "el_gato"}, "dueño inválido"),
|
| ({"type": "clash_resolution", "mode": "yo_gano_siempre"}, "modo inválido"),
|
| ("no soy un dict", "no es objeto"),
|
| ])
|
| def test_efectos_invalidos_rechazados(efecto, motivo):
|
| ok, reason = validate_effect(efecto)
|
| assert not ok, f"debería rechazarse ({motivo}), pero pasó: {reason}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| def test_multiply_corners_duplica_esquinas():
|
| gs = GameState()
|
| gs.enact({"type": "multiply_value", "zone": "corners", "factor": 2}, RED)
|
| for (r, c) in zone_to_cells("corners"):
|
| assert gs.effective_value(r, c) == BASE_BOARD[r][c] * 2
|
|
|
| assert gs.effective_value(1, 1) == 3
|
| assert gs.effective_value(0, 1) == 1
|
|
|
|
|
| def test_leyes_se_aplican_en_orden():
|
| gs = GameState()
|
| gs.enact({"type": "add_value", "zone": "center", "amount": 1}, RED)
|
| gs.enact({"type": "multiply_value", "zone": "center", "factor": 2}, BLUE)
|
| assert gs.effective_value(1, 1) == 8
|
|
|
|
|
| def test_owner_relativo_se_normaliza():
|
| gs = GameState()
|
| law = gs.enact({"type": "forbid_placement", "zone": "center", "owner": "opponent"}, RED)
|
| assert law.effect["owner"] == BLUE
|
|
|
|
|
|
|
|
|
|
|
|
|
| def test_colocacion_normal_y_choque_disputado():
|
| gs = GameState()
|
| gs.place_both((0, 0), (2, 2))
|
| assert gs.occupancy[0][0] == RED
|
| assert gs.occupancy[2][2] == BLUE
|
| gs.place_both((1, 1), (1, 1))
|
| assert gs.occupancy[1][1] == DISPUTED
|
|
|
|
|
| def test_clash_red_wins_y_split():
|
| gs = GameState()
|
| gs.enact({"type": "clash_resolution", "mode": "red_wins"}, RED)
|
| gs.place_both((1, 1), (1, 1))
|
| assert gs.occupancy[1][1] == RED
|
|
|
| gs2 = GameState()
|
| gs2.enact({"type": "clash_resolution", "mode": "split"}, BLUE)
|
| gs2.place_both((1, 1), (1, 1))
|
| assert gs2.occupancy[1][1] == SPLIT
|
| scores = gs2.score_round()
|
| assert scores[RED] == 1.5 and scores[BLUE] == 1.5
|
|
|
|
|
| def test_forbid_bloquea_colocacion():
|
| gs = GameState()
|
| gs.enact({"type": "forbid_placement", "zone": "center", "owner": "blue"}, BLUE)
|
| assert (1, 1) not in gs.legal_cells(RED)
|
| assert (1, 1) in gs.legal_cells(BLUE)
|
| with pytest.raises(ValueError):
|
| gs.place_both((1, 1), (0, 0))
|
|
|
|
|
|
|
|
|
|
|
|
|
| def test_puntuacion_con_varias_leyes():
|
| gs = GameState()
|
| gs.enact({"type": "multiply_value", "zone": "corners", "factor": 2}, RED)
|
| gs.enact({"type": "penalty_zone", "zone": "row_top", "owner": "yellow", "amount": -2}, RED)
|
| gs.enact({"type": "bonus_adjacency", "owner": "blue", "amount": 1}, RED)
|
|
|
|
|
| gs.place_both((0, 0), (0, 2))
|
| gs.place_both((0, 1), (2, 2))
|
|
|
| scores = gs.score_round()
|
|
|
| assert scores[RED] == 6
|
|
|
| assert scores[BLUE] == 6
|
|
|
|
|
| def test_disputada_no_puntua():
|
| gs = GameState()
|
| gs.place_both((1, 1), (1, 1))
|
| scores = gs.score_round()
|
| assert scores[RED] == 0 and scores[BLUE] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
| def test_pergamino_maximo_3():
|
| gs = GameState(starting_laws=False)
|
| for i in range(3):
|
| gs.enact({"type": "add_value", "zone": "center", "amount": 1}, RED)
|
| assert len(gs.scroll.laws) == 3
|
| with pytest.raises(ValueError):
|
| gs.enact({"type": "add_value", "zone": "center", "amount": 1}, RED)
|
|
|
| gs.enact({"type": "multiply_value", "zone": "all", "factor": 0}, BLUE, repeal_index=0)
|
| assert len(gs.scroll.laws) == 3
|
| assert gs.scroll.laws[-1].effects[0]["type"] == "multiply_value"
|
|
|
|
|
| def test_enact_rechaza_efecto_invalido():
|
| gs = GameState()
|
| with pytest.raises(ValueError):
|
| gs.enact({"type": "win_game"}, RED)
|
| assert len(gs.scroll.laws) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
| def test_duende_elige_casilla_legal():
|
| gs = GameState(rng=random.Random(42))
|
| gs.enact({"type": "forbid_placement", "zone": "center", "owner": "yellow"}, RED)
|
| for _ in range(20):
|
| cell = goblin_choose(gs)
|
| assert cell in gs.legal_cells(BLUE)
|
|
|
|
|
| def test_deteccion_de_mentira():
|
| gs = GameState()
|
| real = {"type": "multiply_value", "zone": "corners", "factor": 0}
|
| anunciado = {"type": "multiply_value", "zone": "corners", "factor": 2}
|
| law = gs.enact(real, BLUE, "¡Duplico las esquinas, palabra de duende!",
|
| announced_effect=anunciado)
|
| assert law.is_lie
|
| honesta = gs.enact({"type": "add_value", "zone": "center", "amount": 1}, RED)
|
| assert not honesta.is_lie
|
|
|
|
|
| def test_fog_of_war_difficulty():
|
|
|
| gs_normal = GameState(difficulty="normal")
|
|
|
| assert gs_normal.perceived_value(0, 0) == 2.0
|
|
|
|
|
| gs_normal.start_round(board=BASE_BOARD)
|
| gs_normal.start_round(board=BASE_BOARD)
|
|
|
| assert gs_normal.perceived_value(1, 1) == 3.0
|
|
|
|
|
| gs_hard = GameState(difficulty="hard")
|
|
|
| assert gs_hard.perceived_value(0, 0) == BASE_BOARD[0][0]
|
| assert gs_hard.perceived_value(1, 1) == BASE_BOARD[1][1]
|
| assert gs_hard.perceived_value(2, 2) == BASE_BOARD[2][2]
|
|
|
|
|
| def test_campaign_difficulty_rules():
|
| from game.campaign import Session
|
| import sys
|
|
|
| sys.argv.append("--sin-llm")
|
| try:
|
| session = Session()
|
|
|
| assert session.opponent.name == "Lesky"
|
| assert session.opponent.rounds == 5
|
| assert session.opponent.difficulty == "normal"
|
|
|
|
|
| session.next_opponent()
|
| assert session.opponent.name == "The Monster"
|
| assert session.opponent.rounds == 2
|
| assert session.opponent.difficulty == "hard"
|
|
|
| assert session.gs.difficulty == "hard"
|
| finally:
|
| sys.argv.remove("--sin-llm")
|
|
|
|
|
| def test_cleverness_shop_actions():
|
| from game.campaign import Session
|
| from game import RED
|
|
|
| session = Session()
|
|
|
| session.gs.astucia[RED] = 5
|
|
|
|
|
| assert not session.gs.scouted_opponent
|
| assert session.opponent_mission_hint == "Unknown"
|
|
|
| ok = session.buy_scout()
|
| assert ok
|
| assert session.gs.scouted_opponent
|
| assert session.opponent_mission_hint != "Unknown"
|
| assert session.gs.astucia[RED] == 4
|
|
|
|
|
| assert (1, 1) not in session.gs.revealed
|
| ok2 = session.buy_reveal_center()
|
| assert ok2
|
| assert (1, 1) in session.gs.revealed
|
| assert session.gs.astucia[RED] == 3
|
|
|
|
|
| initial_laws_count = len(session.gs.scroll.laws)
|
| assert initial_laws_count == 2
|
|
|
| ok3 = session.buy_repeal_law(0)
|
| assert ok3
|
| assert len(session.gs.scroll.laws) == 1
|
| assert session.gs.astucia[RED] == 1
|
|
|
|
|
| ok4 = session.buy_repeal_law(0)
|
| assert not ok4
|
| assert len(session.gs.scroll.laws) == 1
|
| assert session.gs.astucia[RED] == 1
|
|
|
|
|
| def test_dynamic_secret_objectives():
|
| gs = GameState()
|
| from game.engine import _ObjNoCenter, RED, BLUE
|
| obj = _ObjNoCenter()
|
| gs.objectives[RED] = obj
|
|
|
|
|
| gs.occupancy[0][0] = RED
|
| gs.occupancy[0][1] = BLUE
|
|
|
| count, why = obj.check(gs, RED)
|
| assert count == 1
|
|
|
| scores = {RED: 1.0, BLUE: 1.0}
|
| gs.commit_round_scores(scores)
|
|
|
| assert gs.astucia[RED] == 1
|
| assert gs.objectives[RED] is not None
|
| assert gs.objectives[RED].key != "no_center"
|
|
|
|
|
| def test_opponent_round_taunts():
|
| from game.campaign import Session
|
| from game import RED, BLUE
|
|
|
| session = Session()
|
|
|
| assert session.round_over_taunt() == "Hehe..."
|
|
|
|
|
| session.gs.commit_round_scores({RED: 5.0, BLUE: 2.0})
|
| taunt_red_win = session.round_over_taunt()
|
| assert taunt_red_win
|
|
|
| assert "Congratulations" not in taunt_red_win
|
|
|
|
|
| session.gs.commit_round_scores({RED: 1.0, BLUE: 6.0})
|
| taunt_blue_win = session.round_over_taunt()
|
| assert taunt_blue_win
|
|
|
|
|
| def test_goblin_taunt_no_llm():
|
| """round_over_taunt() uses fast fallback — no LLM call needed."""
|
| from game.campaign import Session
|
| from game import RED, BLUE
|
|
|
| session = Session()
|
| session.set_player_name("Iker")
|
|
|
| session.gs.commit_round_scores({RED: 5.0, BLUE: 2.0})
|
| taunt = session.round_over_taunt()
|
| assert isinstance(taunt, str) and len(taunt) > 0
|
|
|
|
|
| def test_edge_control_objective():
|
| gs = GameState()
|
| from game.engine import _ObjEdgeControl, RED, BLUE
|
| obj = _ObjEdgeControl()
|
| gs.objectives[RED] = obj
|
|
|
|
|
|
|
| gs.occupancy[0][1] = RED
|
| gs.occupancy[1][0] = RED
|
| gs.occupancy[0][0] = RED
|
| gs.occupancy[1][1] = RED
|
|
|
| count, why = obj.check(gs, RED)
|
| assert count == 2
|
| assert "2 edge(s) occupied" in why
|
|
|
|
|
| gs.objectives[BLUE] = obj
|
|
|
|
|
|
|
|
|
| payoff_corner = gs.perceived_payoff(0, 0)
|
| payoff_edge = gs.perceived_payoff(0, 1)
|
| assert payoff_edge == payoff_corner + 1.5
|
|
|
|
|
| def test_no_adjacency_objective():
|
| gs = GameState()
|
| from game.engine import _ObjNoAdjacency, RED, BLUE
|
| obj = _ObjNoAdjacency()
|
| gs.objectives[RED] = obj
|
|
|
|
|
|
|
| gs.occupancy[0][0] = RED
|
| gs.occupancy[2][2] = RED
|
|
|
| count, why = obj.check(gs, RED)
|
| assert count == 2
|
| assert "no adjacent chips ✓" in why
|
|
|
|
|
|
|
| gs.occupancy[0][1] = RED
|
| count2, why2 = obj.check(gs, RED)
|
| assert count2 == 0
|
| assert "1 adjacent pair(s) ✗" in why2
|
|
|
|
|
| gs.objectives[BLUE] = obj
|
|
|
| n = gs.board_size
|
| gs.occupancy = [[None] * n for _ in range(n)]
|
|
|
| gs.occupancy[0][0] = BLUE
|
|
|
|
|
| payoff_adjacent = gs.perceived_payoff(0, 1)
|
| payoff_separated = gs.perceived_payoff(2, 2)
|
|
|
|
|
|
|
| assert payoff_adjacent == -1.0
|
| assert payoff_separated == 2.0
|
|
|
|
|
| def test_relative_clash_resolution():
|
| gs = GameState(starting_laws=False)
|
|
|
| law_red = gs.enact({"type": "clash_resolution", "mode": "mine_wins"}, RED)
|
| assert law_red.effects[0]["mode"] == "red_wins"
|
|
|
|
|
| law_blue = gs.enact({"type": "clash_resolution", "mode": "mine_wins"}, BLUE)
|
| assert law_blue.effects[0]["mode"] == "blue_wins"
|
|
|
| gs2 = GameState(starting_laws=False)
|
|
|
| law_opp_red = gs2.enact({"type": "clash_resolution", "mode": "opponent_wins"}, RED)
|
| assert law_opp_red.effects[0]["mode"] == "blue_wins"
|
|
|
|
|
| law_opp_blue = gs2.enact({"type": "clash_resolution", "mode": "opponent_wins"}, BLUE)
|
| assert law_opp_blue.effects[0]["mode"] == "red_wins"
|
|
|
|
|
| def test_pregenerated_taunts():
|
| from game.campaign import Session
|
| from game import RED, BLUE
|
|
|
| session = Session()
|
|
|
| session.pregenerated_taunts = {
|
| "win": "I won, look at my skills!",
|
| "loss": "How did I lose?!",
|
| "tie": "A tie, lucky mortal!"
|
| }
|
|
|
|
|
| session.gs.commit_round_scores({RED: 5.0, BLUE: 2.0})
|
| assert session.round_over_taunt() == "How did I lose?!"
|
|
|
|
|
| session.gs.commit_round_scores({RED: 1.0, BLUE: 6.0})
|
| assert session.round_over_taunt() == "I won, look at my skills!"
|
|
|
|
|
| session.gs.commit_round_scores({RED: 3.0, BLUE: 3.0})
|
| assert session.round_over_taunt() == "A tie, lucky mortal!"
|
|
|
|
|
| def test_pregenerated_opponent_laws():
|
| from game.campaign import Session
|
| from game import BLUE
|
|
|
| session = Session()
|
|
|
| mock_law = {
|
| "narration": "Mock rule enacted!",
|
| "announced_effect": {"type": "add_value", "zone": "center", "amount": 1},
|
| "real_effect": {"type": "add_value", "zone": "center", "amount": 1}
|
| }
|
| session.pregenerated_law = mock_law
|
|
|
|
|
| session._goblin_legislate()
|
|
|
|
|
| assert len(session.gs.scroll.laws) == 3
|
| assert session.gs.scroll.laws[-1].announcement == "Mock rule enacted!"
|
|
|
|
|
| @pytest.mark.anyio
|
| async def test_pregenerated_tasks_async():
|
| from game.campaign import Session
|
| from game import RED, BLUE
|
| import asyncio
|
|
|
| session = Session()
|
|
|
|
|
| async def fake_taunts_task():
|
| await asyncio.sleep(0.1)
|
| session.pregenerated_taunts = {"win": "Slow win!", "loss": "Slow loss!", "tie": "Slow tie!"}
|
|
|
| session.gs.commit_round_scores({RED: 5.0, BLUE: 2.0})
|
| session._taunts_task = asyncio.create_task(fake_taunts_task())
|
|
|
|
|
| taunt = await session.round_over_taunt_async()
|
| assert taunt == "Slow loss!"
|
|
|
|
|
| @pytest.mark.anyio
|
| async def test_pregenerated_tasks_async_timeout():
|
| from game.campaign import Session
|
| from game import RED, BLUE
|
| import asyncio
|
|
|
| session = Session()
|
|
|
|
|
| async def fake_taunts_task():
|
| await asyncio.sleep(5.0)
|
| session.pregenerated_taunts = {"win": "Slooow win!", "loss": "Slooow loss!", "tie": "Slooow tie!"}
|
|
|
| session.gs.commit_round_scores({RED: 5.0, BLUE: 2.0})
|
| session._taunts_task = asyncio.create_task(fake_taunts_task())
|
|
|
|
|
|
|
| taunt = await session.round_over_taunt_async()
|
| assert taunt != "Slooow loss!"
|
| assert len(taunt) > 0
|
|
|
|
|
| session._taunts_task.cancel()
|
|
|
|
|
| def test_vocabulary_board_size():
|
| from game import llm
|
|
|
|
|
| vocab_3 = llm.get_vocabulary("Lesky", 3)
|
| assert '"cell(r,c)" con r,c en 0..2' in vocab_3
|
| assert "row_mid" in vocab_3
|
|
|
|
|
| vocab_4 = llm.get_vocabulary("Lesky", 4)
|
| assert '"cell(r,c)" con r,c en 0..3' in vocab_4
|
| assert "central 2x2 area" in vocab_4
|
|
|
|
|
| def test_lesky_chat_parsing():
|
| from game import llm
|
| from unittest.mock import patch
|
|
|
|
|
| with patch("game.llm._chat", return_value='{"line": "Stay back, mortal!"}'):
|
| assert llm.lesky_chat("hello", "context") == "Stay back, mortal!"
|
|
|
|
|
| with patch("game.llm._chat", return_value='{"message": "I see you."}'):
|
| assert llm.lesky_chat("hello", "context") == "I see you."
|
|
|
|
|
| with patch("game.llm._chat", return_value='{"arbitrary": "cryptic wisdom"}'):
|
| assert llm.lesky_chat("hello", "context") == "cryptic wisdom"
|
|
|
|
|
| with patch("game.llm._chat", return_value='{"line": "almost valid json"'):
|
| assert llm.lesky_chat("hello", "context") == "almost valid json"
|
|
|
|
|
| with patch("game.llm._chat", return_value="just raw text"):
|
| assert llm.lesky_chat("hello", "context") == "just raw text"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|