Spaces:
Sleeping
Sleeping
Download game/test_engine.py from build-small-hackathon/Leskys_Shop: direct link, hf CLI and curl.
- Browser
- Download file 20.1 kB
-
https://huggingface.co/spaces/build-small-hackathon/Leskys_Shop/resolve/main/game/test_engine.py
- Command line
-
hf download hf://spaces/build-small-hackathon/Leskys_Shop/game/test_engine.py
-
curl -L -o test_engine.py https://huggingface.co/spaces/build-small-hackathon/Leskys_Shop/resolve/main/game/test_engine.py
20.1 kB
| """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 | |
| 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!" | |
| 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!" | |
| 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" | |