Add event log
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
21
src/pirats/templates/event_log_snippet.html
Normal file
21
src/pirats/templates/event_log_snippet.html
Normal 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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user