phantom-grid / game /session.py
unity4ar's picture
Ship Phantom Grid Docker Space
d2e6f94 verified
Raw
History Blame Contribute Delete
11.4 kB
from __future__ import annotations
import random
from datetime import datetime
from config import load_settings
from grid_map.graph_loader import all_junction_ids, legal_moves_from
from .notice_engine import create_lookout_notice
from .police_actions import create_edge_block, create_junction_block, create_mode_block
from .rules import checks_remaining_this_turn
from .save_load import game_dir_for, save_game_state
from .state import CulpritState, GameState, JunctionCheck, PlacedTactic, PoliceBlock, WitnessBatch
from .story_engine import initialize_case_story, story_reveal
from .turn_engine import advance_turn
from .win_conditions import apply_junction_check
from .witness_engine import answer_witness_question, generate_witness_batch
TACTIC_LIMITS = {
"roadblock": 3,
"junction_lockdown": 3,
"patrol_unit": 2,
"search_team": 2,
"lookout_board": 2,
}
def new_game(
initial_description: str,
starting_junction: int | None = None,
use_model: bool = False,
case_profile: dict | None = None,
) -> GameState:
settings = load_settings()
if starting_junction is None:
starting_junction = random.choice(all_junction_ids())
previous_candidates = [move.destination for move in legal_moves_from(starting_junction) if move.destination != starting_junction]
last_seen_junction = random.choice(previous_candidates) if previous_candidates else starting_junction
game_id = datetime.now().strftime("game_%Y%m%d_%H%M%S_%f")
state = GameState(
game_id=game_id,
turn_number=1,
max_turns=settings.max_turns,
phase="commissioner_action",
initial_description=initial_description.strip(),
culprit=CulpritState(
current_junction=starting_junction,
current_disguise=initial_description.strip(),
remaining_disguise_changes=settings.starting_disguise_changes,
),
last_seen_junction=last_seen_junction,
case_profile=dict(case_profile or {}),
game_log=[{"turn_number": 1, "kind": "new_game", "message": f"New investigation opened at turn 1."}],
effective_context_length=settings.llamacpp_context_length,
last_notice_text=initial_description.strip(),
)
initialize_case_story(state, use_model=use_model)
persist(state)
return state
def issue_notice(state: GameState, text: str, anchor_junction: int | None = None) -> tuple[GameState, WitnessBatch]:
notice = create_lookout_notice(state, text, anchor_junction=anchor_junction)
state.notices.append(notice)
batch = generate_witness_batch(state, notice)
state.witness_batches.append(batch)
state.last_notice_text = text.strip()
state.game_log.append(
{
"turn_number": state.turn_number,
"kind": "notice",
"message": f"{notice.notice_id}: {batch.total_witnesses} witnesses responded.",
}
)
persist(state)
return state, batch
def check_junction(state: GameState, junction_id: int) -> tuple[GameState, str]:
remaining = checks_remaining_this_turn(state.turn_number, state.junction_checks)
if remaining <= 0:
return state, "No junction checks remain this turn."
result = apply_junction_check(state, junction_id)
check_id = f"check_t{state.turn_number:03d}_{len(state.junction_checks) + 1:03d}"
state.junction_checks.append(JunctionCheck(check_id=check_id, turn_number=state.turn_number, junction_id=junction_id, result=result))
visible = "Culprit found. Commissioner wins." if result == "culprit_found" else "No confirmed sighting."
state.game_log.append({"turn_number": state.turn_number, "kind": "junction_check", "message": f"Checked Junction {junction_id}: {visible}"})
persist(state)
return state, visible
def add_block(
state: GameState,
block_type: str,
junction_id: int | None = None,
from_junction: int | None = None,
to_junction: int | None = None,
mode: str | None = None,
turns: int = 1,
) -> tuple[GameState, str]:
settings = load_settings()
if len(state.active_blocks) >= settings.max_active_blocks:
return state, "Maximum active blocks already reached."
block_id = f"block_t{state.turn_number:03d}_{len(state.active_blocks) + 1:03d}"
block = _make_block(block_id, state.turn_number, block_type, junction_id, from_junction, to_junction, mode, turns)
state.active_blocks.append(block)
message = describe_block(block)
state.game_log.append({"turn_number": state.turn_number, "kind": "block", "message": message})
persist(state)
return state, message
def question_witness(state: GameState, witness_id: str, question: str, use_model: bool = False) -> tuple[GameState, str]:
witness = find_witness(state, witness_id)
if witness is None:
return state, "Witness not found."
answer = answer_witness_question(witness, question, state.turn_number, use_model=use_model)
if witness_id not in state.viewed_witness_ids:
state.viewed_witness_ids.append(witness_id)
state.game_log.append({"turn_number": state.turn_number, "kind": "witness_question", "message": f"Questioned {witness_id}."})
persist(state)
return state, answer
def place_tactic(
state: GameState,
tactic_type: str,
junction_id: int,
x: int,
y: int,
layer: str | None = None,
) -> tuple[GameState, str]:
if tactic_type not in TACTIC_LIMITS:
return state, "Unknown tactic."
if _remaining_tactic_count(state, tactic_type) <= 0:
return state, f"No {tactic_type.replace('_', ' ')} units remain."
if junction_id not in all_junction_ids():
return state, "Choose a valid map junction."
tactic_id = f"tactic_t{state.turn_number:03d}_{len(state.placed_tactics) + 1:03d}"
linked_block_id = None
message = f"Placed {tactic_type.replace('_', ' ')} at Junction {junction_id}."
if tactic_type == "roadblock":
moves = legal_moves_from(junction_id, [block.__dict__ for block in state.active_blocks])
layer_mode = layer if layer in {"taxi", "bus", "subway"} else None
candidates = [move for move in moves if not move.blocked]
if layer_mode is not None:
scoped = [move for move in candidates if move.mode == layer_mode]
if not scoped:
return state, f"No open {layer_mode} route at Junction {junction_id}."
candidates = scoped
open_move = candidates[0] if candidates else None
if open_move is None:
return state, "No open route is available for a roadblock here."
state, message = add_block(
state,
"edge_block",
from_junction=junction_id,
to_junction=open_move.destination,
mode=open_move.mode,
turns=2,
)
linked_block_id = state.active_blocks[-1].block_id if state.active_blocks else None
elif tactic_type == "junction_lockdown":
state, message = add_block(state, "junction_block", junction_id=junction_id, turns=2)
linked_block_id = state.active_blocks[-1].block_id if state.active_blocks else None
state.placed_tactics.append(
PlacedTactic(
tactic_id=tactic_id,
tactic_type=tactic_type,
turn_created=state.turn_number,
junction_id=junction_id,
x=int(x),
y=int(y),
linked_block_id=linked_block_id,
)
)
state.game_log.append({"turn_number": state.turn_number, "kind": "tactic", "message": message})
persist(state)
return state, message
def remove_tactic(state: GameState, tactic_id: str) -> tuple[GameState, str]:
tactic = next((item for item in state.placed_tactics if item.tactic_id == tactic_id), None)
if tactic is None:
return state, "Tactic not found."
state.placed_tactics = [item for item in state.placed_tactics if item.tactic_id != tactic_id]
if tactic.linked_block_id:
state.active_blocks = [block for block in state.active_blocks if block.block_id != tactic.linked_block_id]
message = f"Removed {tactic.tactic_type.replace('_', ' ')} from Junction {tactic.junction_id}."
state.game_log.append({"turn_number": state.turn_number, "kind": "tactic_removed", "message": message})
persist(state)
return state, message
def end_turn(state: GameState, use_model: bool = False) -> tuple[GameState, str]:
message = advance_turn(state, use_model=use_model)
persist(state)
return state, message
def persist(state: GameState) -> None:
save_game_state(game_dir_for(state.game_id), state)
def update_notes(state: GameState, notes: str) -> GameState:
state.user_notes = notes[:20000]
persist(state)
return state
def finalize_game(state: GameState, reason: str) -> dict:
if not state.result:
state.result = "abandoned" if reason in {"stopped", "restarted"} else reason
state.finalized_reason = reason
state.phase = "complete"
state.game_log.append({"turn_number": state.turn_number, "kind": "game_finalized", "message": f"Case finalized: {reason}."})
persist(state)
return story_reveal(state)
def find_witness(state: GameState, witness_id: str):
for batch in state.witness_batches:
for witness in batch.witnesses:
if witness.witness_id == witness_id:
return witness
return None
def _remaining_tactic_count(state: GameState, tactic_type: str) -> int:
placed = sum(1 for tactic in state.placed_tactics if tactic.tactic_type == tactic_type)
return max(TACTIC_LIMITS[tactic_type] - placed, 0)
def describe_block(block: PoliceBlock) -> str:
if block.block_type == "edge_block":
mode = f" by {block.mode}" if block.mode else ""
return f"Blocked edge {block.from_junction} -> {block.to_junction}{mode} for {block.turns_remaining} turn(s)."
if block.block_type == "mode_block":
scope = f" near Junction {block.junction_id}" if block.junction_id else ""
return f"Blocked {block.mode}{scope} for {block.turns_remaining} turn(s)."
return f"Blocked Junction {block.junction_id} for {block.turns_remaining} turn(s)."
def _make_block(
block_id: str,
turn_number: int,
block_type: str,
junction_id: int | None,
from_junction: int | None,
to_junction: int | None,
mode: str | None,
turns: int,
) -> PoliceBlock:
if block_type == "edge_block":
if from_junction is None or to_junction is None:
raise ValueError("edge_block requires from_junction and to_junction")
return create_edge_block(block_id, turn_number, from_junction, to_junction, mode, turns)
if block_type == "mode_block":
if not mode:
raise ValueError("mode_block requires mode")
return create_mode_block(block_id, turn_number, mode, junction_id, turns)
if block_type == "junction_block":
if junction_id is None:
raise ValueError("junction_block requires junction_id")
return create_junction_block(block_id, turn_number, junction_id, turns)
raise ValueError(f"Unknown block type: {block_type}")