diff --git a/src/pirats/crud_base.py b/src/pirats/crud_base.py index 9c1ef71..50536b3 100644 --- a/src/pirats/crud_base.py +++ b/src/pirats/crud_base.py @@ -2,7 +2,7 @@ import json import random from typing import List, Optional from sqlmodel import Session -from .models import Game, Player, Obstacle +from .models import Game, Player, Obstacle, GameEvent from . import cards # --- Card and Hand Utilities --- @@ -179,4 +179,12 @@ def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) - db.add(player) db.commit() db.refresh(player) + + add_game_event(db, game_id, f"{name} joined the crew!") + return player + +def add_game_event(db: Session, game_id: str, message: str): + event = GameEvent(game_id=game_id, message=message) + db.add(event) + db.commit() diff --git a/src/pirats/crud_character.py b/src/pirats/crud_character.py index afab53f..f52c168 100644 --- a/src/pirats/crud_character.py +++ b/src/pirats/crud_character.py @@ -2,7 +2,7 @@ import json import random from sqlmodel import Session from .models import Game, Player -from .crud_base import get_player, get_game +from .crud_base import get_player, get_game, add_game_event # --- Character Creation Operations --- @@ -105,6 +105,7 @@ def trigger_technique_swap(db: Session, game: Game): game.phase = "swap_techniques" db.add(game) db.commit() + add_game_event(db, game.id, "Phase changed: Swap Techniques") success = True break @@ -118,6 +119,7 @@ def trigger_technique_swap(db: Session, game: Game): game.phase = "swap_techniques" db.add(game) db.commit() + add_game_event(db, game.id, "Phase changed: Swap Techniques") def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str): player = get_player(db, player_id) @@ -171,3 +173,4 @@ def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: s game.phase = "scene_setup" db.add(game) db.commit() + add_game_event(db, game.id, "Phase changed: Scene Setup") diff --git a/src/pirats/crud_scene.py b/src/pirats/crud_scene.py index 1493c6f..bb7d8e1 100644 --- a/src/pirats/crud_scene.py +++ b/src/pirats/crud_scene.py @@ -6,7 +6,8 @@ from .models import Game, Player, Obstacle from . import cards from .crud_base import ( get_player, get_game, get_player_hand, set_player_hand, - get_game_deck, set_game_deck, calculate_max_hand_size, draw_cards_for_player + get_game_deck, set_game_deck, calculate_max_hand_size, draw_cards_for_player, + add_game_event ) # --- Scene Setup Operations --- @@ -145,6 +146,8 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]: db.add(game) db.commit() + add_game_event(db, game.id, f"Scene {game.current_scene_number} has started! {num_obstacles_to_draw} obstacles drawn.") + return True, "Scene started successfully!" # --- Scene Gameplay Operations --- @@ -245,7 +248,7 @@ def play_card_on_obstacle( drawn = draw_cards_for_player(db, game, player, 1) if drawn: drew_card = drawn[0] - details += f" (Drew back {cards.parse_card(drew_card)['display']} due to matching suit colors!)" + details += " (Drew a card back due to matching suit colors!)" # 3. Update Obstacle Table & Played Card list played_list = json.loads(obstacle.played_cards) @@ -296,6 +299,9 @@ def play_card_on_obstacle( db.add(player) db.commit() + event_msg = f"{player.name} played {played_parsed['display']} on '{obstacle.title}'. {details}" + add_game_event(db, game.id, event_msg) + res_dict = { "success": success, "details": details, @@ -307,6 +313,22 @@ def play_card_on_obstacle( return True, "Card played successfully.", res_dict +def evaluate_hand_sizes(db: Session, game_id: str): + """ + Recalculates max hand size for all players in the game. + If a player's current hand size is less than their max hand size, + draws cards to fill it up immediately. + """ + game = get_game(db, game_id) + if not game: + return + + for player in game.players: + max_size = calculate_max_hand_size(player, game.players) + current_hand = get_player_hand(player) + if len(current_hand) < max_size: + draw_cards_for_player(db, game, player, max_size - len(current_hand)) + def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, status: bool): """Toggles Personal or Crew objectives and adjusts states/Ranks accordingly.""" if obj_type.startswith("crew_"): @@ -353,6 +375,13 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s player.rank = max(1, player.rank) db.add(player) db.commit() + + if rank_diff != 0: + evaluate_hand_sizes(db, game_id) + + obj_name = obj_type.replace('_', ' ').title() + status_text = "completed" if status else "unmarked" + add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.") def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]: obstacle = db.get(Obstacle, obstacle_id) diff --git a/src/pirats/crud_upkeep.py b/src/pirats/crud_upkeep.py index 0a5e8fe..c99f1ca 100644 --- a/src/pirats/crud_upkeep.py +++ b/src/pirats/crud_upkeep.py @@ -5,7 +5,7 @@ from sqlmodel import Session, select from .models import Game, Player, Vote from .crud_base import ( get_player, get_game, get_player_hand, set_player_hand, - get_game_deck, set_game_deck, draw_cards_for_player + get_game_deck, set_game_deck, draw_cards_for_player, add_game_event ) # --- Between Scenes Operations --- @@ -40,6 +40,8 @@ def end_scene_and_transition(db: Session, game_id: str): db.add(p) db.add(game) db.commit() + + add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.") def process_between_scenes_votes(db: Session, game: Game): """ @@ -66,6 +68,7 @@ def process_between_scenes_votes(db: Session, game: Game): if winner: winner.rank += 1 db.add(winner) + add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.") # Clear all votes for v in votes: @@ -90,11 +93,13 @@ def transition_to_deep_upkeep(db: Session, game_id: str): if has_resting_deep: game.phase = "deep_upkeep" + add_game_event(db, game.id, "Phase changed: Deep Upkeep") else: # Fallback in case there were no Deep players # Just advance directly to scene_setup game.current_scene_number += 1 game.phase = "scene_setup" + add_game_event(db, game.id, "Phase changed: Scene Setup") for p in game.players: p.role = None p.is_ready = False @@ -146,6 +151,7 @@ def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]): # Transition to scene_setup! game.current_scene_number += 1 game.phase = "scene_setup" + add_game_event(db, game.id, "Phase changed: Scene Setup") for p in game.players: p.is_ready = False p.role = None diff --git a/src/pirats/main.py b/src/pirats/main.py index 6a3449e..118a71d 100644 --- a/src/pirats/main.py +++ b/src/pirats/main.py @@ -2,13 +2,14 @@ from typing import Optional from fastapi import FastAPI, Request, Form, Depends, HTTPException, status from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.staticfiles import StaticFiles -from sqlmodel import Session +from sqlmodel import Session, select import uvicorn from pathlib import Path from . import crud from .database import create_db_and_tables, get_session from .templates import templates, BASE_DIR +from .models import GameEvent app = FastAPI(title="Rats with Gats Remote Play") @@ -74,15 +75,46 @@ def player_dashboard(request: Request, game_id: str, player_id: str, db: Session # --- HTMX Core Polling Route --- @app.get("/game/{game_id}/player/{player_id}/phase-check") -def phase_check_route(game_id: str, player_id: str, current: str, db: Session = Depends(get_session)): +def phase_check_route(game_id: str, player_id: str, current: str, rank: Optional[int] = None, needs_name: Optional[bool] = None, hand_len: Optional[int] = None, db: Session = Depends(get_session)): game = crud.get_game(db, game_id) if not game: raise HTTPException(status_code=404, detail="Game not found") + + player = crud.get_player(db, player_id) + if not player: + raise HTTPException(status_code=404, detail="Player not found") + + state_changed = False if game.phase != current: - # Phase changed! Trigger page reload on client via HTMX header + state_changed = True + elif rank is not None and player.rank != rank: + state_changed = True + elif needs_name is not None and player.needs_name != needs_name: + state_changed = True + elif hand_len is not None: + import json + current_hand = json.loads(player.hand_cards) + if len(current_hand) != hand_len: + state_changed = True + + if state_changed: + # Phase or state changed! Trigger page reload on client via HTMX header return HTMLResponse(status_code=200, headers={"HX-Trigger": "phase-changed"}) return HTMLResponse(status_code=204) # No content, no reload +@app.get("/game/{game_id}/player/{player_id}/events", response_class=HTMLResponse) +def get_events_snippet(request: Request, game_id: str, player_id: str, db: Session = Depends(get_session)): + game = crud.get_game(db, game_id) + if not game: + raise HTTPException(status_code=404, detail="Game not found") + + events = db.exec(select(GameEvent).where(GameEvent.game_id == game_id).order_by(GameEvent.timestamp.asc())).all() + + return templates.TemplateResponse("event_log_snippet.html", { + "request": request, + "events": events + }) + # --- Register Modular Phase Routers --- from .phase_view import router as phase_view_router diff --git a/src/pirats/models.py b/src/pirats/models.py index 3fccc47..0f282cf 100644 --- a/src/pirats/models.py +++ b/src/pirats/models.py @@ -1,3 +1,4 @@ +import time import uuid from typing import Optional, List from sqlmodel import Field, SQLModel, Relationship @@ -17,6 +18,7 @@ class Game(SQLModel, table=True): players: List["Player"] = Relationship(back_populates="game", cascade_delete=True) obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True) votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True) + events: List["GameEvent"] = Relationship(back_populates="game", cascade_delete=True) class Player(SQLModel, table=True): id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) @@ -90,3 +92,11 @@ class Vote(SQLModel, table=True): nominated_player_id: str = Field(...) game: Optional[Game] = Relationship(back_populates="votes") + +class GameEvent(SQLModel, table=True): + id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) + game_id: str = Field(foreign_key="game.id", index=True) + timestamp: float = Field(default_factory=time.time) + message: str = Field(...) + + game: Optional[Game] = Relationship(back_populates="events") diff --git a/src/pirats/routes_lobby.py b/src/pirats/routes_lobby.py index c59767d..c13f39c 100644 --- a/src/pirats/routes_lobby.py +++ b/src/pirats/routes_lobby.py @@ -16,6 +16,7 @@ def lobby_start_character_creation(game_id: str, db: Session = Depends(get_sessi game.phase = "character_creation" db.add(game) db.commit() + crud.add_game_event(db, game.id, "Phase changed: Character Creation") return HTMLResponse(status_code=204) # Return No Content, polling redirects @router.get("/game/{game_id}/lobby/players", response_class=HTMLResponse) diff --git a/src/pirats/routes_scene.py b/src/pirats/routes_scene.py index 5736154..f0ea135 100644 --- a/src/pirats/routes_scene.py +++ b/src/pirats/routes_scene.py @@ -177,6 +177,7 @@ def bonus_rank_up_route( db.add(target) db.add(player) db.commit() + crud.evaluate_hand_sizes(db, game_id) return HTMLResponse(status_code=204) # Pi-Rat becomes a ghost diff --git a/src/pirats/static/css/components.css b/src/pirats/static/css/components.css index 8e58749..e3abba0 100644 --- a/src/pirats/static/css/components.css +++ b/src/pirats/static/css/components.css @@ -193,3 +193,109 @@ .glow-effect:hover { filter: brightness(1.2); } + +/* --- Event Log --- */ +.event-log-fab { + position: fixed; + bottom: 20px; + right: 20px; + width: 60px; + height: 60px; + border-radius: 50%; + background: var(--glass-bg); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--gold); + color: var(--gold); + font-size: 1.5rem; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5), 0 0 10px rgba(212, 175, 55, 0.2); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1000; + transition: var(--transition-smooth); +} + +.event-log-fab:hover { + transform: scale(1.1); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6), 0 0 15px rgba(212, 175, 55, 0.4); +} + +.event-log-panel { + position: fixed; + bottom: 90px; + right: 20px; + width: 350px; + max-width: calc(100vw - 40px); + height: 400px; + max-height: calc(100vh - 120px); + background: var(--glass-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--glass-border); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); + display: flex; + flex-direction: column; + z-index: 999; + transition: transform 0.3s ease, opacity 0.3s ease; +} + +.event-log-panel.hidden { + transform: translateY(20px) scale(0.95); + opacity: 0; + pointer-events: none; +} + +.event-log-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.event-log-header h3 { + margin: 0; + font-family: var(--font-heading); + font-size: 1.1rem; + color: var(--gold); +} + +.event-log-container { + flex: 1; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.event-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.event-item { + font-size: 0.9rem; + background: rgba(0, 0, 0, 0.3); + padding: 0.75rem; + border-radius: 8px; + border-left: 3px solid var(--neon-cyan); +} + +.event-time { + display: block; + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: 0.25rem; +} + +.event-msg { + color: var(--text-primary); +} diff --git a/src/pirats/static/css/scene-play.css b/src/pirats/static/css/scene-play.css index 2a29996..c0a969f 100644 --- a/src/pirats/static/css/scene-play.css +++ b/src/pirats/static/css/scene-play.css @@ -268,3 +268,10 @@ cursor: pointer; font-size: 0.95rem; } + +.hand-flex { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-start; +} diff --git a/src/pirats/templates/dashboard.html b/src/pirats/templates/dashboard.html index c2fc4e0..2292e77 100644 --- a/src/pirats/templates/dashboard.html +++ b/src/pirats/templates/dashboard.html @@ -22,4 +22,56 @@
Contacting the math-magic spirits...
+ + + + + + + {% endblock %} diff --git a/src/pirats/templates/event_log_snippet.html b/src/pirats/templates/event_log_snippet.html new file mode 100644 index 0000000..afdd7f5 --- /dev/null +++ b/src/pirats/templates/event_log_snippet.html @@ -0,0 +1,21 @@ +{% if events %} +