Leskys_Shop / game /test_engine.py
siigrid's picture
first push
9a097d5
Raw
History Blame Contribute Delete
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
@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"