This commit is contained in:
ryan
2026-01-18 16:22:43 -05:00
parent 96716d95b6
commit e431ba45e9
9 changed files with 235 additions and 5 deletions

View File

@@ -113,13 +113,16 @@ class Game(db.Model):
is_active = db.Column(db.Boolean, default=False) # Only one game active at a time is_active = db.Column(db.Boolean, default=False) # Only one game active at a time
current_question_index = db.Column(db.Integer, default=0) # Track current question current_question_index = db.Column(db.Integer, default=0) # Track current question
is_template = db.Column(db.Boolean, default=False) # Mark as reusable template is_template = db.Column(db.Boolean, default=False) # Mark as reusable template
current_turn_team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) # Track whose turn it is
# Relationships # Relationships
teams = db.relationship('Team', back_populates='game', cascade='all, delete-orphan') teams = db.relationship('Team', back_populates='game', cascade='all, delete-orphan',
foreign_keys='Team.game_id')
game_questions = db.relationship('GameQuestion', back_populates='game', game_questions = db.relationship('GameQuestion', back_populates='game',
cascade='all, delete-orphan', cascade='all, delete-orphan',
order_by='GameQuestion.order') order_by='GameQuestion.order')
scores = db.relationship('Score', back_populates='game', cascade='all, delete-orphan') scores = db.relationship('Score', back_populates='game', cascade='all, delete-orphan')
current_turn_team = db.relationship('Team', foreign_keys=[current_turn_team_id], post_update=True)
@classmethod @classmethod
def get_active(cls): def get_active(cls):
@@ -141,7 +144,9 @@ class Game(db.Model):
'is_active': self.is_active, 'is_active': self.is_active,
'current_question_index': self.current_question_index, 'current_question_index': self.current_question_index,
'total_questions': len(self.game_questions), 'total_questions': len(self.game_questions),
'is_template': self.is_template 'is_template': self.is_template,
'current_turn_team_id': self.current_turn_team_id,
'current_turn_team_name': self.current_turn_team.name if self.current_turn_team else None
} }
if include_questions: if include_questions:

View File

@@ -302,3 +302,25 @@ def seek_audio(game_id):
return jsonify({'message': f'Audio seeked to {position}s'}), 200 return jsonify({'message': f'Audio seeked to {position}s'}), 200
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@bp.route('/game/<int:game_id>/advance-turn', methods=['POST'])
@require_auth
def advance_turn(game_id):
"""Advance to the next team's turn"""
game = Game.query.get_or_404(game_id)
try:
next_team = game_service.advance_turn(game, socketio)
if next_team:
return jsonify({
'message': 'Turn advanced',
'current_turn_team_id': next_team.id,
'current_turn_team_name': next_team.name
}), 200
else:
return jsonify({'error': 'No teams in game'}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500

View File

@@ -1,7 +1,12 @@
from backend.models import db, Game, Score from backend.models import db, Game, Score, Team
from flask_socketio import emit from flask_socketio import emit
def get_ordered_teams(game):
"""Get teams sorted by ID for consistent turn ordering"""
return sorted(game.teams, key=lambda t: t.id)
def get_game_state(game): def get_game_state(game):
"""Get current game state with all necessary information""" """Get current game state with all necessary information"""
current_question = game.get_current_question() current_question = game.get_current_question()
@@ -12,7 +17,9 @@ def get_game_state(game):
'current_question_index': game.current_question_index, 'current_question_index': game.current_question_index,
'total_questions': len(game.game_questions), 'total_questions': len(game.game_questions),
'is_active': game.is_active, 'is_active': game.is_active,
'teams': [team.to_dict() for team in game.teams] 'teams': [team.to_dict() for team in game.teams],
'current_turn_team_id': game.current_turn_team_id,
'current_turn_team_name': game.current_turn_team.name if game.current_turn_team else None
} }
if current_question: if current_question:
@@ -42,6 +49,14 @@ def start_game(game, socketio_instance):
game.is_active = True game.is_active = True
game.current_question_index = 0 game.current_question_index = 0
# Set initial turn to the first team (by ID order)
ordered_teams = get_ordered_teams(game)
if ordered_teams:
game.current_turn_team_id = ordered_teams[0].id
else:
game.current_turn_team_id = None
db.session.commit() db.session.commit()
# Emit game_started event # Emit game_started event
@@ -60,6 +75,9 @@ def start_game(game, socketio_instance):
# Emit first question # Emit first question
broadcast_question_change(game, socketio_instance) broadcast_question_change(game, socketio_instance)
# Emit initial turn
broadcast_turn_change(game, socketio_instance)
def next_question(game, socketio_instance): def next_question(game, socketio_instance):
"""Move to next question""" """Move to next question"""
@@ -209,6 +227,7 @@ def restart_game(game, socketio_instance):
# Reset game state # Reset game state
game.is_active = False game.is_active = False
game.current_question_index = 0 game.current_question_index = 0
game.current_turn_team_id = None # Reset turn
# Reset phone-a-friend lifelines for all teams # Reset phone-a-friend lifelines for all teams
for team in game.teams: for team in game.teams:
@@ -283,3 +302,52 @@ def broadcast_audio_seek(game, position, socketio_instance):
'game_id': game.id, 'game_id': game.id,
'position': position 'position': position
}, room=f'game_{game.id}_contestant') }, room=f'game_{game.id}_contestant')
def advance_turn(game, socketio_instance):
"""Advance to the next team's turn (cycles through teams by ID order)"""
ordered_teams = get_ordered_teams(game)
if not ordered_teams:
return None
if game.current_turn_team_id is None:
# No current turn, set to first team
next_team = ordered_teams[0]
else:
# Find current team index and advance to next
current_index = None
for i, team in enumerate(ordered_teams):
if team.id == game.current_turn_team_id:
current_index = i
break
if current_index is None:
# Current team not found, set to first
next_team = ordered_teams[0]
else:
# Cycle to next team (modulo for wrap-around)
next_index = (current_index + 1) % len(ordered_teams)
next_team = ordered_teams[next_index]
game.current_turn_team_id = next_team.id
db.session.commit()
# Broadcast turn change to both rooms
broadcast_turn_change(game, socketio_instance)
return next_team
def broadcast_turn_change(game, socketio_instance):
"""Broadcast turn change to all connected clients"""
ordered_teams = get_ordered_teams(game)
turn_data = {
'current_turn_team_id': game.current_turn_team_id,
'current_turn_team_name': game.current_turn_team.name if game.current_turn_team else None,
'all_teams': [{'id': t.id, 'name': t.name} for t in ordered_teams]
}
socketio_instance.emit('turn_changed', turn_data, room=f'game_{game.id}_contestant')
socketio_instance.emit('turn_changed', turn_data, room=f'game_{game.id}_admin')

View File

@@ -111,6 +111,17 @@
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 8px; border-radius: 8px;
background: white; background: white;
transition: all 0.3s ease;
}
.team-card-active-turn {
border: 3px solid #673AB7;
background: #EDE7F6;
box-shadow: 0 0 10px rgba(103, 58, 183, 0.3);
}
.team-card-active-turn .team-name {
color: #673AB7;
} }
.team-card-header { .team-card-header {

View File

@@ -21,6 +21,8 @@ export default function GameAdminView() {
const [timerExpired, setTimerExpired] = useState(false); const [timerExpired, setTimerExpired] = useState(false);
const [timerPaused, setTimerPaused] = useState(false); const [timerPaused, setTimerPaused] = useState(false);
const [newTeamName, setNewTeamName] = useState(""); const [newTeamName, setNewTeamName] = useState("");
const [currentTurnTeamId, setCurrentTurnTeamId] = useState(null);
const [currentTurnTeamName, setCurrentTurnTeamName] = useState(null);
useEffect(() => { useEffect(() => {
loadGameState(); loadGameState();
@@ -37,6 +39,8 @@ export default function GameAdminView() {
setCurrentQuestion(response.data.current_question); setCurrentQuestion(response.data.current_question);
setQuestionIndex(response.data.current_question_index); setQuestionIndex(response.data.current_question_index);
setTotalQuestions(response.data.total_questions); setTotalQuestions(response.data.total_questions);
setCurrentTurnTeamId(response.data.current_turn_team_id);
setCurrentTurnTeamName(response.data.current_turn_team_name);
} catch (error) { } catch (error) {
console.error("Error loading game state:", error); console.error("Error loading game state:", error);
} }
@@ -77,12 +81,19 @@ export default function GameAdminView() {
setTimerPaused(false); setTimerPaused(false);
}); });
socket.on("turn_changed", (data) => {
console.log("Turn changed:", data);
setCurrentTurnTeamId(data.current_turn_team_id);
setCurrentTurnTeamName(data.current_turn_team_name);
});
return () => { return () => {
socket.off("question_with_answer"); socket.off("question_with_answer");
socket.off("score_updated"); socket.off("score_updated");
socket.off("timer_paused"); socket.off("timer_paused");
socket.off("lifeline_updated"); socket.off("lifeline_updated");
socket.off("timer_reset"); socket.off("timer_reset");
socket.off("turn_changed");
}; };
}, [socket]); }, [socket]);
@@ -209,6 +220,15 @@ export default function GameAdminView() {
} }
}; };
const handleAdvanceTurn = async () => {
try {
await adminAPI.advanceTurn(gameId);
} catch (error) {
console.error("Error advancing turn:", error);
alert("Error advancing turn");
}
};
const handleToggleAnswer = async () => { const handleToggleAnswer = async () => {
const newShowAnswer = !showAnswer; const newShowAnswer = !showAnswer;
try { try {
@@ -417,7 +437,7 @@ export default function GameAdminView() {
) : ( ) : (
<div className="teams-list"> <div className="teams-list">
{teams.map((team) => ( {teams.map((team) => (
<div key={team.id} className="team-card"> <div key={team.id} className={`team-card ${team.id === currentTurnTeamId ? 'team-card-active-turn' : ''}`}>
<div className="team-card-header"> <div className="team-card-header">
<div className="team-name-section"> <div className="team-name-section">
<strong className="team-name">{team.name}</strong> <strong className="team-name">{team.name}</strong>
@@ -538,6 +558,23 @@ export default function GameAdminView() {
{/* Button grid - 2 columns on desktop, 1 on mobile */} {/* Button grid - 2 columns on desktop, 1 on mobile */}
<div className="controls-button-grid"> <div className="controls-button-grid">
{gameState?.is_active && teams.length > 0 && (
<button
onClick={handleAdvanceTurn}
className="btn-next-turn"
style={{
padding: "0.75rem 1.5rem",
background: "#673AB7",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "1rem",
}}
>
Next Turn {currentTurnTeamName ? `(${currentTurnTeamName})` : ""}
</button>
)}
{currentQuestion && ( {currentQuestion && (
<button <button
onClick={handleToggleAnswer} onClick={handleToggleAnswer}

View File

@@ -18,6 +18,8 @@ export default function ContestantView() {
const [timerActive, setTimerActive] = useState(false); const [timerActive, setTimerActive] = useState(false);
const [timerPaused, setTimerPaused] = useState(false); const [timerPaused, setTimerPaused] = useState(false);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [currentTurnTeamId, setCurrentTurnTeamId] = useState(null);
const [currentTurnTeamName, setCurrentTurnTeamName] = useState(null);
useEffect(() => { useEffect(() => {
// Load initial game state // Load initial game state
@@ -140,6 +142,8 @@ export default function ContestantView() {
setTimerSeconds(30); setTimerSeconds(30);
setTimerActive(false); setTimerActive(false);
setTimerPaused(false); setTimerPaused(false);
setCurrentTurnTeamId(null);
setCurrentTurnTeamName(null);
}); });
socket.on("lifeline_updated", (data) => { socket.on("lifeline_updated", (data) => {
@@ -154,6 +158,12 @@ export default function ContestantView() {
setTimerPaused(false); setTimerPaused(false);
}); });
socket.on("turn_changed", (data) => {
console.log("Turn changed:", data);
setCurrentTurnTeamId(data.current_turn_team_id);
setCurrentTurnTeamName(data.current_turn_team_name);
});
return () => { return () => {
socket.off("game_started"); socket.off("game_started");
socket.off("question_changed"); socket.off("question_changed");
@@ -163,6 +173,7 @@ export default function ContestantView() {
socket.off("game_ended"); socket.off("game_ended");
socket.off("lifeline_updated"); socket.off("lifeline_updated");
socket.off("timer_reset"); socket.off("timer_reset");
socket.off("turn_changed");
}; };
}, [socket]); }, [socket]);
@@ -283,6 +294,25 @@ export default function ContestantView() {
> >
{currentQuestion ? ( {currentQuestion ? (
<div style={{ width: "100%", textAlign: "center" }}> <div style={{ width: "100%", textAlign: "center" }}>
{/* Turn indicator */}
{currentTurnTeamName && (
<div
className="turn-indicator"
style={{
fontSize: "2rem",
fontWeight: "bold",
marginBottom: "1.5rem",
padding: "1rem 2rem",
background: "#673AB7",
color: "white",
borderRadius: "8px",
display: "inline-block",
animation: "pulse 2s infinite",
}}
>
{currentTurnTeamName}'s Turn
</div>
)}
{/* Timer progress bar */} {/* Timer progress bar */}
<div <div
style={{ style={{
@@ -476,6 +506,11 @@ export default function ContestantView() {
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "baseline", alignItems: "baseline",
fontSize: "1.8rem", fontSize: "1.8rem",
padding: "0.5rem 1rem",
borderRadius: "8px",
background: (team.team_id || team.id) === currentTurnTeamId ? "#673AB7" : "transparent",
color: (team.team_id || team.id) === currentTurnTeamId ? "white" : "inherit",
transition: "all 0.3s ease",
}} }}
> >
<div <div

View File

@@ -54,3 +54,20 @@ button:focus,
button:focus-visible { button:focus-visible {
outline: 4px auto -webkit-focus-ring-color; outline: 4px auto -webkit-focus-ring-color;
} }
/* Turn indicator pulse animation */
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(103, 58, 183, 0.4);
}
70% {
box-shadow: 0 0 0 15px rgba(103, 58, 183, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(103, 58, 183, 0);
}
}
.turn-indicator {
animation: pulse 2s infinite;
}

View File

@@ -106,6 +106,7 @@ export const adminAPI = {
api.post(`/admin/game/${gameId}/team/${teamId}/use-lifeline`), api.post(`/admin/game/${gameId}/team/${teamId}/use-lifeline`),
addLifeline: (gameId, teamId) => addLifeline: (gameId, teamId) =>
api.post(`/admin/game/${gameId}/team/${teamId}/add-lifeline`), api.post(`/admin/game/${gameId}/team/${teamId}/add-lifeline`),
advanceTurn: (id) => api.post(`/admin/game/${id}/advance-turn`),
}; };
// Categories API // Categories API

View File

@@ -0,0 +1,34 @@
"""Add current_turn_team_id to games
Revision ID: a1b2c3d4e5f6
Revises: 1252454a2589
Create Date: 2026-01-18 15:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a1b2c3d4e5f6'
down_revision = '90b81e097444'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('games', schema=None) as batch_op:
batch_op.add_column(sa.Column('current_turn_team_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_games_current_turn_team_id', 'teams', ['current_turn_team_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('games', schema=None) as batch_op:
batch_op.drop_constraint('fk_games_current_turn_team_id', type_='foreignkey')
batch_op.drop_column('current_turn_team_id')
# ### end Alembic commands ###