"""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) # --------------------------------------------------------------------------- # Zonas # --------------------------------------------------------------------------- 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 # --------------------------------------------------------------------------- # Validación: el código rechaza lo que el LLM alucine # --------------------------------------------------------------------------- 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}" # --------------------------------------------------------------------------- # Leyes de valor # --------------------------------------------------------------------------- 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 # el resto no cambia 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) # 3 -> 4 gs.enact({"type": "multiply_value", "zone": "center", "factor": 2}, BLUE) # 4 -> 8 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 # --------------------------------------------------------------------------- # Colocación y choques # --------------------------------------------------------------------------- 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)) # choque, modo por defecto: disputada 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)) # --------------------------------------------------------------------------- # Puntuación con varias leyes activas # --------------------------------------------------------------------------- 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) # rojo: (0,0) y (0,1) adyacentes; azul: (0,2) y (2,2) gs.place_both((0, 0), (0, 2)) gs.place_both((0, 1), (2, 2)) scores = gs.score_round() # rojo: esquina 2*2=4 + borde 1 + 1 par adyacente = 6 assert scores[RED] == 6 # azul: esquinas 4 + 4 = 8, penalización -2 por (0,2) en row_top = 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 # --------------------------------------------------------------------------- # Pergamino: máximo 3 leyes # --------------------------------------------------------------------------- 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) # con derogación sí entra 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 # --------------------------------------------------------------------------- # Duende (rival simple) # --------------------------------------------------------------------------- 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(): # Normal difficulty gs_normal = GameState(difficulty="normal") # At round 1, unknown corner cells (like 0,0) should perceive default value 2.0 assert gs_normal.perceived_value(0, 0) == 2.0 # Go to round 3 gs_normal.start_round(board=BASE_BOARD) # round 2 gs_normal.start_round(board=BASE_BOARD) # round 3 # Center cell is 3.0 in BASE_BOARD, and should be known starting round 3 assert gs_normal.perceived_value(1, 1) == 3.0 # Hard difficulty gs_hard = GameState(difficulty="hard") # Should know all values from round 1 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() # Opponent 0 is Lesky (rounds=5, difficulty=normal) assert session.opponent.name == "Lesky" assert session.opponent.rounds == 5 assert session.opponent.difficulty == "normal" # Advance to Opponent 1: The Monster (rounds=2, difficulty=hard) 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() # Cheat: give red player some cleverness points to buy stuff session.gs.astucia[RED] = 5 # 1. Scout opponent 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 # 2. Reveal center cell 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 # 3. Repeal active law 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 # Failing due to cost 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 # Fulfill NoCenter 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() # Initially no history assert session.round_over_taunt() == "Hehe..." # RED wins — Lesky should be angry/bitter session.gs.commit_round_scores({RED: 5.0, BLUE: 2.0}) taunt_red_win = session.round_over_taunt() assert taunt_red_win # non-empty # Lesky is angry — should not congratulate assert "Congratulations" not in taunt_red_win # BLUE wins — Lesky should be smug session.gs.commit_round_scores({RED: 1.0, BLUE: 6.0}) taunt_blue_win = session.round_over_taunt() assert taunt_blue_win # non-empty 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 # Center (1,1) is center, corners are (0,0), (0,2), (2,0), (2,2) # Edges are (0,1), (1,0), (1,2), (2,1) gs.occupancy[0][1] = RED gs.occupancy[1][0] = RED gs.occupancy[0][0] = RED # Corner gs.occupancy[1][1] = RED # Center count, why = obj.check(gs, RED) assert count == 2 assert "2 edge(s) occupied" in why # Test AI weighting gs.objectives[BLUE] = obj # Corner cell (0,0) value: perceived value = 2.0 (fog of war) # Edge cell (0,1) value: perceived value = 2.0 (fog of war) # payoff corner = 2.0 # payoff edge = 2.0 + 1.5 = 3.5 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 # Case 1: No adjacent chips # Set occupancy (0, 0) and (2, 2) which are not adjacent 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 # Case 2: Adjacent chips # (0, 0) and (0, 1) are orthogonally adjacent gs.occupancy[0][1] = RED count2, why2 = obj.check(gs, RED) assert count2 == 0 assert "1 adjacent pair(s) ✗" in why2 # Test AI Weighting gs.objectives[BLUE] = obj # Clear the board first n = gs.board_size gs.occupancy = [[None] * n for _ in range(n)] # Set one BLUE chip in (0, 0) gs.occupancy[0][0] = BLUE # Cell (0, 1) is adjacent to (0, 0), so it should have a penalty of -3.0 # Cell (2, 2) is not adjacent to (0, 0), so no penalty payoff_adjacent = gs.perceived_payoff(0, 1) payoff_separated = gs.perceived_payoff(2, 2) # The default/perceived value for empty cells in round 1 under fog of war is 2.0. # payoff_adjacent should be 2.0 - 3.0 = -1.0 # payoff_separated should be 2.0 assert payoff_adjacent == -1.0 assert payoff_separated == 2.0 def test_relative_clash_resolution(): gs = GameState(starting_laws=False) # mine_wins by RED (player) -> red_wins law_red = gs.enact({"type": "clash_resolution", "mode": "mine_wins"}, RED) assert law_red.effects[0]["mode"] == "red_wins" # mine_wins by BLUE (goblin) -> blue_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) # opponent_wins by RED -> blue_wins law_opp_red = gs2.enact({"type": "clash_resolution", "mode": "opponent_wins"}, RED) assert law_opp_red.effects[0]["mode"] == "blue_wins" # opponent_wins by BLUE -> red_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() # Inject mock pregenerated taunts session.pregenerated_taunts = { "win": "I won, look at my skills!", "loss": "How did I lose?!", "tie": "A tie, lucky mortal!" } # 1. Test loss taunt (RED won the round) session.gs.commit_round_scores({RED: 5.0, BLUE: 2.0}) assert session.round_over_taunt() == "How did I lose?!" # 2. Test win taunt (BLUE won the round) session.gs.commit_round_scores({RED: 1.0, BLUE: 6.0}) assert session.round_over_taunt() == "I won, look at my skills!" # 3. Test tie taunt (Tie round) 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() # Inject mock pregenerated law 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 # Run _goblin_legislate session._goblin_legislate() # Verify the law was enacted from the pregenerated cache assert len(session.gs.scroll.laws) == 3 # 2 starting laws + 1 mock law 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() # Simulate a background task that takes some time to pregenerate taunts 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}) # Player won, so goblin got "loss" session._taunts_task = asyncio.create_task(fake_taunts_task()) # Calling the async method should wait for the task and return the pregenerated taunt 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() # Simulate a background task that hangs or takes too long 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}) # Player won, so goblin got "loss" session._taunts_task = asyncio.create_task(fake_taunts_task()) # Calling the async method should time out after 1.2s and return a fallback taunt immediately # (instead of waiting 5s or calling the LLM) taunt = await session.round_over_taunt_async() assert taunt != "Slooow loss!" assert len(taunt) > 0 # Clean up the hanging task session._taunts_task.cancel() def test_vocabulary_board_size(): from game import llm # 1. Check 3x3 vocabulary 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 # 2. Check 4x4 vocabulary 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 # 1. Clean JSON with "line" with patch("game.llm._chat", return_value='{"line": "Stay back, mortal!"}'): assert llm.lesky_chat("hello", "context") == "Stay back, mortal!" # 2. Clean JSON with alternative key "message" with patch("game.llm._chat", return_value='{"message": "I see you."}'): assert llm.lesky_chat("hello", "context") == "I see you." # 3. Clean JSON with arbitrary key with patch("game.llm._chat", return_value='{"arbitrary": "cryptic wisdom"}'): assert llm.lesky_chat("hello", "context") == "cryptic wisdom" # 4. Malformed JSON with "line" (regex fallback) with patch("game.llm._chat", return_value='{"line": "almost valid json"'): assert llm.lesky_chat("hello", "context") == "almost valid json" # 5. Raw text response (plain text fallback) with patch("game.llm._chat", return_value="just raw text"): assert llm.lesky_chat("hello", "context") == "just raw text"