Add event log

This commit is contained in:
2026-06-11 10:20:14 -07:00
parent 1b8452bf56
commit 29860061c4
13 changed files with 285 additions and 9 deletions

View File

@@ -2,7 +2,7 @@ import json
import random import random
from typing import List, Optional from typing import List, Optional
from sqlmodel import Session from sqlmodel import Session
from .models import Game, Player, Obstacle from .models import Game, Player, Obstacle, GameEvent
from . import cards from . import cards
# --- Card and Hand Utilities --- # --- 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.add(player)
db.commit() db.commit()
db.refresh(player) db.refresh(player)
add_game_event(db, game_id, f"{name} joined the crew!")
return player 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()

View File

@@ -2,7 +2,7 @@ import json
import random import random
from sqlmodel import Session from sqlmodel import Session
from .models import Game, Player 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 --- # --- Character Creation Operations ---
@@ -105,6 +105,7 @@ def trigger_technique_swap(db: Session, game: Game):
game.phase = "swap_techniques" game.phase = "swap_techniques"
db.add(game) db.add(game)
db.commit() db.commit()
add_game_event(db, game.id, "Phase changed: Swap Techniques")
success = True success = True
break break
@@ -118,6 +119,7 @@ def trigger_technique_swap(db: Session, game: Game):
game.phase = "swap_techniques" game.phase = "swap_techniques"
db.add(game) db.add(game)
db.commit() 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): def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: str, king: str):
player = get_player(db, player_id) 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" game.phase = "scene_setup"
db.add(game) db.add(game)
db.commit() db.commit()
add_game_event(db, game.id, "Phase changed: Scene Setup")

View File

@@ -6,7 +6,8 @@ from .models import Game, Player, Obstacle
from . import cards from . import cards
from .crud_base import ( from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand, 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 --- # --- Scene Setup Operations ---
@@ -145,6 +146,8 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
db.add(game) db.add(game)
db.commit() 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!" return True, "Scene started successfully!"
# --- Scene Gameplay Operations --- # --- Scene Gameplay Operations ---
@@ -245,7 +248,7 @@ def play_card_on_obstacle(
drawn = draw_cards_for_player(db, game, player, 1) drawn = draw_cards_for_player(db, game, player, 1)
if drawn: if drawn:
drew_card = drawn[0] 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 # 3. Update Obstacle Table & Played Card list
played_list = json.loads(obstacle.played_cards) played_list = json.loads(obstacle.played_cards)
@@ -296,6 +299,9 @@ def play_card_on_obstacle(
db.add(player) db.add(player)
db.commit() 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 = { res_dict = {
"success": success, "success": success,
"details": details, "details": details,
@@ -307,6 +313,22 @@ def play_card_on_obstacle(
return True, "Card played successfully.", res_dict 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): 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.""" """Toggles Personal or Crew objectives and adjusts states/Ranks accordingly."""
if obj_type.startswith("crew_"): 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) player.rank = max(1, player.rank)
db.add(player) db.add(player)
db.commit() 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]: def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]:
obstacle = db.get(Obstacle, obstacle_id) obstacle = db.get(Obstacle, obstacle_id)

View File

@@ -5,7 +5,7 @@ from sqlmodel import Session, select
from .models import Game, Player, Vote from .models import Game, Player, Vote
from .crud_base import ( from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand, 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 --- # --- Between Scenes Operations ---
@@ -40,6 +40,8 @@ def end_scene_and_transition(db: Session, game_id: str):
db.add(p) db.add(p)
db.add(game) db.add(game)
db.commit() 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): def process_between_scenes_votes(db: Session, game: Game):
""" """
@@ -66,6 +68,7 @@ def process_between_scenes_votes(db: Session, game: Game):
if winner: if winner:
winner.rank += 1 winner.rank += 1
db.add(winner) 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 # Clear all votes
for v in votes: for v in votes:
@@ -90,11 +93,13 @@ def transition_to_deep_upkeep(db: Session, game_id: str):
if has_resting_deep: if has_resting_deep:
game.phase = "deep_upkeep" game.phase = "deep_upkeep"
add_game_event(db, game.id, "Phase changed: Deep Upkeep")
else: else:
# Fallback in case there were no Deep players # Fallback in case there were no Deep players
# Just advance directly to scene_setup # Just advance directly to scene_setup
game.current_scene_number += 1 game.current_scene_number += 1
game.phase = "scene_setup" game.phase = "scene_setup"
add_game_event(db, game.id, "Phase changed: Scene Setup")
for p in game.players: for p in game.players:
p.role = None p.role = None
p.is_ready = False 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! # Transition to scene_setup!
game.current_scene_number += 1 game.current_scene_number += 1
game.phase = "scene_setup" game.phase = "scene_setup"
add_game_event(db, game.id, "Phase changed: Scene Setup")
for p in game.players: for p in game.players:
p.is_ready = False p.is_ready = False
p.role = None p.role = None

View File

@@ -2,13 +2,14 @@ from typing import Optional
from fastapi import FastAPI, Request, Form, Depends, HTTPException, status from fastapi import FastAPI, Request, Form, Depends, HTTPException, status
from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlmodel import Session from sqlmodel import Session, select
import uvicorn import uvicorn
from pathlib import Path from pathlib import Path
from . import crud from . import crud
from .database import create_db_and_tables, get_session from .database import create_db_and_tables, get_session
from .templates import templates, BASE_DIR from .templates import templates, BASE_DIR
from .models import GameEvent
app = FastAPI(title="Rats with Gats Remote Play") 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 --- # --- HTMX Core Polling Route ---
@app.get("/game/{game_id}/player/{player_id}/phase-check") @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) game = crud.get_game(db, game_id)
if not game: if not game:
raise HTTPException(status_code=404, detail="Game not found") 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: 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=200, headers={"HX-Trigger": "phase-changed"})
return HTMLResponse(status_code=204) # No content, no reload 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 --- # --- Register Modular Phase Routers ---
from .phase_view import router as phase_view_router from .phase_view import router as phase_view_router

View File

@@ -1,3 +1,4 @@
import time
import uuid import uuid
from typing import Optional, List from typing import Optional, List
from sqlmodel import Field, SQLModel, Relationship 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) players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
obstacles: List["Obstacle"] = 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) 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): class Player(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=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(...) nominated_player_id: str = Field(...)
game: Optional[Game] = Relationship(back_populates="votes") 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")

View File

@@ -16,6 +16,7 @@ def lobby_start_character_creation(game_id: str, db: Session = Depends(get_sessi
game.phase = "character_creation" game.phase = "character_creation"
db.add(game) db.add(game)
db.commit() db.commit()
crud.add_game_event(db, game.id, "Phase changed: Character Creation")
return HTMLResponse(status_code=204) # Return No Content, polling redirects return HTMLResponse(status_code=204) # Return No Content, polling redirects
@router.get("/game/{game_id}/lobby/players", response_class=HTMLResponse) @router.get("/game/{game_id}/lobby/players", response_class=HTMLResponse)

View File

@@ -177,6 +177,7 @@ def bonus_rank_up_route(
db.add(target) db.add(target)
db.add(player) db.add(player)
db.commit() db.commit()
crud.evaluate_hand_sizes(db, game_id)
return HTMLResponse(status_code=204) return HTMLResponse(status_code=204)
# Pi-Rat becomes a ghost # Pi-Rat becomes a ghost

View File

@@ -193,3 +193,109 @@
.glow-effect:hover { .glow-effect:hover {
filter: brightness(1.2); 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);
}

View File

@@ -268,3 +268,10 @@
cursor: pointer; cursor: pointer;
font-size: 0.95rem; font-size: 0.95rem;
} }
.hand-flex {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
}

View File

@@ -22,4 +22,56 @@
<p>Contacting the math-magic spirits...</p> <p>Contacting the math-magic spirits...</p>
</div> </div>
</div> </div>
<!-- Floating Event Log -->
<button id="event-log-fab" class="fab event-log-fab" onclick="toggleEventLog()" title="Game Events">
📜
</button>
<div id="event-log-panel" class="event-log-panel hidden">
<div class="event-log-header">
<h3>Game Events</h3>
<button onclick="toggleEventLog()" class="btn btn-danger btn-sm">×</button>
</div>
<div id="event-log-container" class="event-log-container"
hx-get="/game/{{ game.id }}/player/{{ player.id }}/events"
hx-trigger="every 3s, refresh-events from:body"
hx-swap="innerHTML">
<div class="text-center text-muted"><small>Loading events...</small></div>
</div>
</div>
<script>
function toggleEventLog() {
const panel = document.getElementById('event-log-panel');
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) {
document.body.dispatchEvent(new Event('refresh-events'));
// Initially scroll to bottom if opening for the first time
setTimeout(() => {
const el = document.getElementById('event-log-container');
el.scrollTop = el.scrollHeight;
}, 100);
}
}
// Scroll preservation logic
document.body.addEventListener('htmx:beforeSwap', function(evt) {
if (evt.detail.target.id === 'event-log-container') {
const el = evt.detail.target;
// Only scroll to bottom if they are currently at the bottom (within a small pixel margin)
const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 25;
el.dataset.wasAtBottom = isAtBottom;
}
});
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'event-log-container') {
const el = evt.detail.target;
if (el.dataset.wasAtBottom === 'true') {
el.scrollTop = el.scrollHeight;
}
}
});
</script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,21 @@
{% if events %}
<ul class="event-list">
{% for event in events %}
<li class="event-item">
<span class="event-time" data-timestamp="{{ event.timestamp }}"></span>
<span class="event-msg">{{ event.message }}</span>
</li>
{% endfor %}
</ul>
<script>
document.querySelectorAll('.event-time[data-timestamp]').forEach(el => {
if (!el.textContent) {
const ts = parseFloat(el.dataset.timestamp);
const d = new Date(ts * 1000);
el.textContent = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
});
</script>
{% else %}
<div class="text-center text-muted"><small>No events yet.</small></div>
{% endif %}

View File

@@ -331,7 +331,7 @@
</div> </div>
<!-- Hidden phase-check trigger --> <!-- Hidden phase-check trigger -->
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}" <div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}&rank={{ player.rank }}&needs_name={{ player.needs_name }}&hand_len={{ hand|length }}"
hx-trigger="every 2s" hx-trigger="every 2s"
hx-swap="none" hx-swap="none"
style="display:none;"></div> style="display:none;"></div>