From 89def12cdc705e2ed000143b1878b34b0f62670e Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 10 May 2026 18:31:21 +0200 Subject: [PATCH] feat: persist season scoring rules --- AGENTS.md | 19 +- README.md | 4 + tests/test_database.py | 271 ++++++++++++++++++ tests/test_scoring.py | 59 +++- tests/test_thread_prediction_handler.py | 3 + tests/test_user_commands.py | 33 ++- typer_bot/commands/user_commands.py | 36 ++- typer_bot/database/connection.py | 29 +- typer_bot/database/scores.py | 23 +- typer_bot/database/seasons.py | 135 ++++++++- .../handlers/thread_prediction_handler.py | 2 +- typer_bot/utils/__init__.py | 10 +- typer_bot/utils/scoring.py | 44 ++- 13 files changed, 632 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bdcac6d..301fb49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,9 +29,23 @@ You are working on `TyperBot`, a Discord bot for football prediction leagues. SQLite. Tables are initialized in `typer_bot/database/connection.py`. ```sql +seasons ( + id INTEGER PK, + guild_id TEXT, + name TEXT, + status TEXT DEFAULT 'active', -- 'active' or 'archived' + exact_score_points INTEGER DEFAULT 3, + correct_outcome_points INTEGER DEFAULT 1, + wrong_outcome_points INTEGER DEFAULT 0, + late_prediction_points INTEGER DEFAULT 0, + created_at DATETIME, + ended_at DATETIME +) + fixtures ( id INTEGER PK, guild_id TEXT, -- Discord guild/server ID owning the league state + season_id INTEGER FK, week_number INTEGER, games TEXT, -- Newline separated: "Team A - Team B\nTeam C - Team D" deadline DATETIME, @@ -73,7 +87,7 @@ scores ( fixture_id INTEGER FK, user_id TEXT, user_name TEXT, - points INTEGER, -- 3 (exact), 1 (outcome), 0 (miss) + points INTEGER, -- Calculated from the fixture season's scoring rules exact_scores INTEGER, correct_results INTEGER, UNIQUE(fixture_id, user_id) @@ -83,6 +97,7 @@ guild_config ( guild_id TEXT PK, admin_role_id TEXT, league_channel_id TEXT, + active_season_id INTEGER, created_at DATETIME, updated_at DATETIME ) @@ -96,7 +111,7 @@ guild_config ( - `typer_bot/commands/admin_commands.py`: `/admin` command surface and orchestration for admin workflows, including admin calculation cooldown state. - `typer_bot/utils/config.py`: Centralized configuration (data paths via env vars). - `typer_bot/utils/prediction_parser.py`: Central logic for parsing "2-1" or "2:1" strings. -- `typer_bot/utils/scoring.py`: Point calculation rules. +- `typer_bot/utils/scoring.py`: Point calculation using season scoring rules. - `typer_bot/utils/logger.py`: structured logging configuration for local and deployed environments. - `typer_bot/utils/db_backup.py`: Automatic database backup after successful score calculation. - `scripts/restore_db.py`: Manual database restore from a host or container shell. diff --git a/README.md b/README.md index e8efce4..6169c5e 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,15 @@ Team E - Team F 3:2 ``` ## Scoring +- Scoring rules are stored per season. The defaults are: - Exact score: 3 points - Correct outcome: 1 point - Wrong outcome: 0 points - Late full predictions: 0 points unless an admin waives the penalty - Late predictions with missing games: excluded from scoring until reviewed by an admin +- Rule values must be whole numbers greater than or equal to zero. +- Changing rules is blocked after scores exist for that season, so stored scores do not silently go stale. +- Starting a new season resets its scoring rules to the defaults. ## Operational constraints - Match data, predictions, results, and scores are stored in SQLite. diff --git a/tests/test_database.py b/tests/test_database.py index 725af83..96d4b0c 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -378,6 +378,75 @@ async def test_start_new_season_resets_next_fixture_week(self, temp_db_path): assert new_week == 1 assert new_fixture["season_id"] == active_season["id"] + @pytest.mark.asyncio + async def test_start_new_season_uses_fresh_default_scoring_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + custom_rules = { + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 1, + "late_prediction_points": 1, + } + await db.update_active_scoring_rules("111111", custom_rules) + + await db.start_new_season("111111", "Next Season") + + seasons = await db.get_seasons("111111") + active_rules = await db.get_active_scoring_rules("111111") + assert seasons[0]["scoring_rules"] == custom_rules + assert active_rules == { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } + + @pytest.mark.asyncio + async def test_scoring_rule_updates_preserve_omitted_values(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules( + "111111", + { + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 1, + "late_prediction_points": 1, + }, + ) + + await db.update_active_scoring_rules("111111", {"late_prediction_points": 2}) + + assert await db.get_active_scoring_rules("111111") == { + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 1, + "late_prediction_points": 2, + } + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("rules", "message"), + [ + ({"exact_score_points": -1}, "zero or greater"), + ({"exact_score_points": "many"}, "whole numbers"), + ({"exact_points": 5}, "Unknown scoring rule"), + ], + ) + async def test_invalid_scoring_rule_updates_do_not_mutate_existing_rules( + self, temp_db_path, rules, message + ): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) + existing_rules = await db.get_active_scoring_rules("111111") + + with pytest.raises(ValueError, match=message): + await db.update_active_scoring_rules("111111", rules) + + assert await db.get_active_scoring_rules("111111") == existing_rules + @pytest.mark.asyncio async def test_fixture_queries_default_to_active_season(self, temp_db_path): db = Database(temp_db_path) @@ -558,6 +627,201 @@ async def test_archived_fixture_delete_requires_active_season(self, temp_db_path class TestScores: + @pytest.mark.asyncio + async def test_result_correction_recalculates_with_active_season_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules( + "111111", {"exact_score_points": 5, "correct_outcome_points": 2} + ) + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) + await db.recalculate_fixture_scores(fixture_id) + + await db.save_results_with_recalc(fixture_id, ["2-0"]) + + scores = await db.get_scores_for_fixture(fixture_id) + assert scores[0]["points"] == 2 + + @pytest.mark.asyncio + async def test_prediction_replacement_recalculates_with_active_season_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules( + "111111", {"exact_score_points": 5, "wrong_outcome_points": 1} + ) + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) + await db.recalculate_fixture_scores(fixture_id) + + updated = await db.admin_update_prediction_with_recalc( + fixture_id, "user-1", ["1-2"], "admin-1" + ) + + scores = await db.get_scores_for_fixture(fixture_id) + assert updated is True + assert scores[0]["points"] == 1 + + @pytest.mark.asyncio + async def test_waiver_toggle_recalculates_with_active_season_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules( + "111111", {"exact_score_points": 5, "late_prediction_points": 1} + ) + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], True) + await db.recalculate_fixture_scores(fixture_id) + + waived = await db.toggle_late_penalty_waiver_with_recalc(fixture_id, "user-1") + + scores = await db.get_scores_for_fixture(fixture_id) + assert waived is True + assert scores[0]["points"] == 5 + + @pytest.mark.asyncio + async def test_partial_approval_recalculates_with_active_season_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) + await db.recalculate_fixture_scores(fixture_id) + await db.save_prediction( + fixture_id, + "partial", + "Partial User", + ["2-1"], + True, + predicted_game_indexes=[0], + pending_partial_approval=True, + ) + + approved = await db.approve_partial_prediction(fixture_id, "partial", "admin-1") + + scores = await db.get_scores_for_fixture(fixture_id) + assert approved is True + assert [(score["user_id"], score["points"]) for score in scores] == [ + ("partial", 5), + ("user-1", 5), + ] + + @pytest.mark.asyncio + async def test_partial_rejection_recalculates_with_active_season_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) + await db.save_prediction( + fixture_id, + "partial", + "Partial User", + ["2-1"], + True, + predicted_game_indexes=[0], + pending_partial_approval=True, + ) + await db.recalculate_fixture_scores(fixture_id) + + rejected = await db.reject_partial_prediction(fixture_id, "partial") + + scores = await db.get_scores_for_fixture(fixture_id) + assert rejected is True + assert [(score["user_id"], score["points"]) for score in scores] == [("user-1", 5)] + + @pytest.mark.asyncio + async def test_recalculate_fixture_scores_uses_active_season_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules( + "111111", + { + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 1, + "late_prediction_points": 0, + }, + ) + fixture_id = await db.create_fixture( + "111111", 1, ["A - B", "C - D", "E - F"], datetime.now(UTC) + ) + await db.save_results(fixture_id, ["2-1", "1-1", "2-0"]) + await db.save_prediction( + fixture_id, + "user-1", + "User One", + ["2-1", "2-2", "0-2"], + False, + ) + + await db.recalculate_fixture_scores(fixture_id) + + scores = await db.get_scores_for_fixture(fixture_id) + assert scores[0]["points"] == 8 + assert scores[0]["exact_scores"] == 1 + assert scores[0]["correct_results"] == 1 + + @pytest.mark.asyncio + async def test_late_prediction_uses_active_season_penalty_rule(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules("111111", {"late_prediction_points": 1}) + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "late", "Late User", ["2-1"], True) + await db.save_prediction(fixture_id, "waived", "Waived User", ["2-1"], True) + await db.set_late_penalty_waiver(fixture_id, "waived", True) + + await db.recalculate_fixture_scores(fixture_id) + + scores = await db.get_scores_for_fixture(fixture_id) + assert [(score["user_id"], score["points"]) for score in scores] == [ + ("waived", 3), + ("late", 1), + ] + + @pytest.mark.asyncio + async def test_scoring_rules_are_guild_isolated(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) + guild_one_fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + guild_two_fixture_id = await db.create_fixture("222222", 1, ["A - B"], datetime.now(UTC)) + for fixture_id in (guild_one_fixture_id, guild_two_fixture_id): + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) + await db.recalculate_fixture_scores(fixture_id) + + guild_one_scores = await db.get_scores_for_fixture(guild_one_fixture_id) + guild_two_scores = await db.get_scores_for_fixture(guild_two_fixture_id) + assert guild_one_scores[0]["points"] == 5 + assert guild_two_scores[0]["points"] == 3 + + @pytest.mark.asyncio + async def test_scoring_rule_changes_are_blocked_after_scores_exist(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) + await db.recalculate_fixture_scores(fixture_id) + + with pytest.raises(ValueError, match="Cannot change scoring rules"): + await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) + + assert await db.get_active_scoring_rules("111111") == { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } + @pytest.mark.asyncio async def test_save_scores_does_not_mutate_when_write_lock_is_held( self, temp_db_path, monkeypatch @@ -1273,9 +1537,16 @@ async def test_initialize_adds_missing_columns(self, temp_db_path): await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) fixture = await db.get_current_fixture("111111") + scoring_rules = await db.get_active_scoring_rules("111111") assert fixture is not None assert "message_id" in fixture assert fixture["week_number"] == 1 + assert scoring_rules == { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } @pytest.mark.asyncio async def test_initialize_migrates_legacy_results_to_unique_latest_row(self, temp_db_path): diff --git a/tests/test_scoring.py b/tests/test_scoring.py index 01d25b6..be1b725 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -42,6 +42,22 @@ def test_wrong_prediction(self): assert result["exact_scores"] == 0 assert result["correct_results"] == 0 + def test_custom_scoring_rules(self): + result = calculate_points( + ["2-1", "1-1", "0-2"], + ["2-1", "2-2", "2-0"], + scoring_rules={ + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 1, + "late_prediction_points": 0, + }, + ) + + assert result["points"] == 8 + assert result["exact_scores"] == 1 + assert result["correct_results"] == 1 + def test_mixed_results_multiple_games(self): """Multiple games with exact, correct, and wrong outcomes.""" predictions = ["2-1", "3-0", "1-1", "0-2"] @@ -58,7 +74,19 @@ def test_late_prediction_penalty(self): assert result["points"] == 0 assert result["exact_scores"] == 0 assert result["correct_results"] == 0 - assert result["penalty"] == "Late prediction - 100% penalty applied" + assert result["penalty"] == "Late prediction penalty applied" + + def test_custom_late_prediction_points(self): + result = calculate_points( + ["2-1"], + ["2-1"], + is_late=True, + scoring_rules={"late_prediction_points": 1}, + ) + + assert result["points"] == 1 + assert result["exact_scores"] == 0 + assert result["correct_results"] == 0 def test_empty_predictions(self): """Empty prediction lists should return 0 points.""" @@ -193,3 +221,32 @@ def test_late_penalty_is_applied_once_for_all_score_recalculation_paths(self): ("waived", 3), ("late", 0), ] + + def test_scores_with_custom_rules(self): + scores = build_fixture_scores( + [ + { + "user_id": "exact", + "user_name": "Exact User", + "predictions": ["2-1"], + "predicted_game_indexes": [0], + "is_late": False, + "late_penalty_waived": False, + }, + { + "user_id": "late", + "user_name": "Late User", + "predictions": ["2-1"], + "predicted_game_indexes": [0], + "is_late": True, + "late_penalty_waived": False, + }, + ], + ["2-1"], + {"exact_score_points": 5, "late_prediction_points": 1}, + ) + + assert [(score["user_id"], score["points"]) for score in scores] == [ + ("exact", 5), + ("late", 1), + ] diff --git a/tests/test_thread_prediction_handler.py b/tests/test_thread_prediction_handler.py index 779494e..90269a8 100644 --- a/tests/test_thread_prediction_handler.py +++ b/tests/test_thread_prediction_handler.py @@ -110,6 +110,7 @@ async def test_saves_valid_predictions(self, handler, fixture_with_thread, mock_ @pytest.mark.asyncio async def test_marks_late_predictions(self, handler, database, mock_message, sample_games): """Should mark predictions as late when past deadline.""" + await database.update_active_scoring_rules("111111", {"late_prediction_points": 1}) deadline = datetime.now(UTC) - timedelta(hours=1) fixture_id = await database.create_fixture("111111", 1, sample_games, deadline) await database.update_fixture_announcement( @@ -128,6 +129,8 @@ async def test_marks_late_predictions(self, handler, database, mock_message, sam assert len(predictions) == 1 assert predictions[0]["is_late"] assert "Late prediction" in mock_message.author.dm_sent[0] + assert "active season's late penalty" in mock_message.author.dm_sent[0] + assert "0 points" not in mock_message.author.dm_sent[0] @pytest.mark.asyncio @pytest.mark.usefixtures("fixture_with_thread") diff --git a/tests/test_user_commands.py b/tests/test_user_commands.py index dddb0da..54f0a41 100644 --- a/tests/test_user_commands.py +++ b/tests/test_user_commands.py @@ -269,6 +269,7 @@ async def test_predict_modal_overwrites_existing_prediction( async def test_predict_modal_marks_late_prediction( self, user_commands, mock_interaction, database ): + await database.update_active_scoring_rules("111111", {"late_prediction_points": 1}) await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) fixture = await database.get_fixture_by_id(1, "111111") assert fixture is not None @@ -291,7 +292,10 @@ async def test_predict_modal_marks_late_prediction( prediction = await database.get_prediction(1, str(mock_interaction.user.id), "111111") assert prediction is not None assert prediction["is_late"] == 1 - assert "Late prediction" in mock_interaction.response_sent[-1]["content"] + content = mock_interaction.response_sent[-1]["content"] + assert "Late prediction" in content + assert "active season's late penalty" in content + assert "0 points" not in content @pytest.mark.asyncio @pytest.mark.usefixtures("fixture_with_dm") @@ -1077,3 +1081,30 @@ async def test_multiple_open_fixtures_show_mixed_prediction_state( assert "Week 2" in content assert f"1. {sample_games[0]} **2-1**" in content assert "No prediction submitted yet." in content + + +class TestHelpCommand: + @pytest.mark.asyncio + async def test_help_uses_active_season_scoring_rules( + self, + user_commands, + mock_interaction, + database, + ): + await database.update_active_scoring_rules( + "111111", + { + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 1, + "late_prediction_points": 1, + }, + ) + + await user_commands.help.callback(user_commands, mock_interaction) + + content = mock_interaction.response_sent[-1]["content"] + assert "Exact score: 5 points" in content + assert "Correct result (win/loss/draw): 2 points" in content + assert "Wrong: 1 point" in content + assert "Late full predictions: 1 point" in content diff --git a/typer_bot/commands/user_commands.py b/typer_bot/commands/user_commands.py index b3cbb70..dc4b429 100644 --- a/typer_bot/commands/user_commands.py +++ b/typer_bot/commands/user_commands.py @@ -16,6 +16,7 @@ format_standings, get_configured_admin_role_mention, is_configured_admin, + normalize_scoring_rules, now, parse_prediction_lines, ) @@ -25,6 +26,26 @@ logger = logging.getLogger(__name__) +def _format_scoring_help(scoring_rules: dict | None) -> str: + rules = normalize_scoring_rules(scoring_rules) + exact_points = _points_label(rules["exact_score_points"]) + outcome_points = _points_label(rules["correct_outcome_points"]) + wrong_points = _points_label(rules["wrong_outcome_points"]) + late_points = _points_label(rules["late_prediction_points"]) + return ( + "**Scoring:**\n" + f"• Exact score: {exact_points}\n" + f"• Correct result (win/loss/draw): {outcome_points}\n" + f"• Wrong: {wrong_points}\n" + f"• Late full predictions: {late_points} unless an admin waives the penalty\n" + "• Late predictions with missing games: pending admin review" + ) + + +def _points_label(points: int) -> str: + return f"{points} point" if points == 1 else f"{points} points" + + async def _require_guild_id(interaction: discord.Interaction) -> str | None: if interaction.guild_id is None: await interaction.response.send_message( @@ -493,7 +514,7 @@ async def on_submit(self, interaction: discord.Interaction): "if an admin approves this late submission with missing games." ) elif is_late: - content += "\n\n⚠️ **Late prediction!** You will receive 0 points for this round." + content += "\n\n⚠️ **Late prediction!** The active season's late penalty applies unless an admin waives it." elif is_partial: content += ( "\n\nℹ️ **Partial prediction saved:** any missing games will count as no prediction. " @@ -619,8 +640,12 @@ async def predict(self, interaction: discord.Interaction): async def help(self, interaction: discord.Interaction): """Display help for users and admins.""" is_admin_user = await is_configured_admin(interaction, self.db) + scoring_rules = None + if interaction.guild_id is not None: + scoring_rules = await self.db.get_active_scoring_rules(str(interaction.guild_id)) + scoring_help = _format_scoring_help(scoring_rules) - user_help = """## 📖 User Commands + user_help = f"""## 📖 User Commands **For Players:** • `/predict` - Choose the week if needed, fill the modal, and post predictions publicly to the fixture thread @@ -649,12 +674,7 @@ async def help(self, interaction: discord.Interaction): • Rejected late submissions are discarded • Public review status stays visible in the fixture thread -**Scoring:** -• Exact score: 3 points -• Correct result (win/loss/draw): 1 point -• Wrong: 0 points -• Late full predictions: 0 points unless an admin waives the penalty -• Late predictions with missing games: pending admin review +{scoring_help} **Input formats:** `2:0`, `2-0`, `2 : 0`""" diff --git a/typer_bot/database/connection.py b/typer_bot/database/connection.py index 57570d7..09af743 100644 --- a/typer_bot/database/connection.py +++ b/typer_bot/database/connection.py @@ -121,6 +121,20 @@ async def _migrate_prediction_columns(db: aiosqlite.Connection) -> None: await db.execute("ALTER TABLE predictions ADD COLUMN public_message_kind TEXT") +async def _migrate_season_columns(db: aiosqlite.Connection) -> None: + columns = await _table_columns(db, "seasons") + scoring_columns = { + "exact_score_points": "INTEGER NOT NULL DEFAULT 3", + "correct_outcome_points": "INTEGER NOT NULL DEFAULT 1", + "wrong_outcome_points": "INTEGER NOT NULL DEFAULT 0", + "late_prediction_points": "INTEGER NOT NULL DEFAULT 0", + } + for column_name, column_definition in scoring_columns.items(): + if column_name not in columns: + logger.info("Adding %s column to seasons table", column_name) + await db.execute(f"ALTER TABLE seasons ADD COLUMN {column_name} {column_definition}") + + async def _validate_fixture_guild_ownership(db: aiosqlite.Connection) -> None: columns = await _table_columns(db, "fixtures") if "guild_id" not in columns: @@ -142,7 +156,7 @@ class Database: """Composition root for SQLite setup and the bot's stable data facade. Callers use this facade instead of reaching into repositories directly. It - owns path setup, schema initialization, additive migrations, and the focused + owns path setup, schema initialization, startup migrations, and the focused repository objects that perform the actual reads and writes. """ @@ -161,7 +175,7 @@ def __init__(self, db_path: str | None = None) -> None: self._seasons = SeasonRepository(self.db_path) async def initialize(self) -> None: - """Create tables, enable WAL mode, and apply additive migrations. + """Create tables, enable WAL mode, and apply startup migrations. Fresh databases get the current schema. Existing databases are migrated in place by adding missing columns and by collapsing ``results`` rows @@ -182,6 +196,10 @@ async def initialize(self) -> None: guild_id TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', + exact_score_points INTEGER NOT NULL DEFAULT 3, + correct_outcome_points INTEGER NOT NULL DEFAULT 1, + wrong_outcome_points INTEGER NOT NULL DEFAULT 0, + late_prediction_points INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, ended_at DATETIME ) @@ -270,6 +288,7 @@ async def initialize(self) -> None: await _validate_fixture_guild_ownership(db) + await _migrate_season_columns(db) await _migrate_prediction_columns(db) await _migrate_results_table(db) @@ -309,6 +328,12 @@ async def get_or_create_active_season(self, guild_id): async def get_seasons(self, guild_id): return await self._seasons.get_seasons(guild_id) + async def get_active_scoring_rules(self, guild_id): + return await self._seasons.get_active_scoring_rules(guild_id) + + async def update_active_scoring_rules(self, guild_id, rules): + return await self._seasons.update_active_scoring_rules(guild_id, rules) + async def start_new_season(self, guild_id, name): return await self._seasons.start_new_season(guild_id, name) diff --git a/typer_bot/database/scores.py b/typer_bot/database/scores.py index ff67685..8249cf0 100644 --- a/typer_bot/database/scores.py +++ b/typer_bot/database/scores.py @@ -6,6 +6,16 @@ from typer_bot.utils import build_fixture_scores + +def _season_row_to_scoring_rules(row: aiosqlite.Row) -> dict: + return { + "exact_score_points": row["exact_score_points"], + "correct_outcome_points": row["correct_outcome_points"], + "wrong_outcome_points": row["wrong_outcome_points"], + "late_prediction_points": row["late_prediction_points"], + } + + logger = logging.getLogger(__name__) @@ -43,7 +53,12 @@ async def _recalculate_scores_in_connection(db: aiosqlite.Connection, fixture_id try: async with db.execute( """ - SELECT f.* + SELECT + f.*, + s.exact_score_points, + s.correct_outcome_points, + s.wrong_outcome_points, + s.late_prediction_points FROM fixtures f JOIN seasons s ON s.id = f.season_id AND s.guild_id = f.guild_id WHERE f.id = ? AND s.status = 'active' @@ -72,7 +87,11 @@ async def _recalculate_scores_in_connection(db: aiosqlite.Connection, fixture_id results = results_row["results"].split("\n") predictions = [_prediction_row_to_score_input(row) for row in prediction_rows] - scores = build_fixture_scores(predictions, results) + scores = build_fixture_scores( + predictions, + results, + _season_row_to_scoring_rules(fixture_row), + ) await db.execute("DELETE FROM scores WHERE fixture_id = ?", (fixture_id,)) for score in scores: diff --git a/typer_bot/database/seasons.py b/typer_bot/database/seasons.py index 16152bd..0c903c0 100644 --- a/typer_bot/database/seasons.py +++ b/typer_bot/database/seasons.py @@ -2,6 +2,8 @@ import aiosqlite +from typer_bot.utils import DEFAULT_SCORING_RULES, normalize_scoring_rules + DEFAULT_SEASON_NAME = "Default Season" ACTIVE_SEASON_STATUS = "active" ARCHIVED_SEASON_STATUS = "archived" @@ -13,6 +15,7 @@ def _validate_guild_id(guild_id: str) -> None: def _row_to_season(row: aiosqlite.Row) -> dict: + scoring_rules = _row_to_scoring_rules(row) return { "id": row["id"], "guild_id": row["guild_id"], @@ -20,9 +23,31 @@ def _row_to_season(row: aiosqlite.Row) -> dict: "status": row["status"], "created_at": row["created_at"], "ended_at": row["ended_at"], + "scoring_rules": scoring_rules, } +def _row_to_scoring_rules(row: aiosqlite.Row) -> dict: + keys = set(row.keys()) + return normalize_scoring_rules( + {key: row[key] for key in DEFAULT_SCORING_RULES if key in keys and row[key] is not None} + ) + + +def _validate_scoring_rules(rules: dict) -> dict: + unknown_rules = set(rules) - set(DEFAULT_SCORING_RULES) + if unknown_rules: + unknown_list = ", ".join(sorted(unknown_rules)) + raise ValueError(f"Unknown scoring rule: {unknown_list}") + try: + normalized = normalize_scoring_rules(rules) + except (TypeError, ValueError) as exc: + raise ValueError("Scoring rule values must be whole numbers.") from exc + if any(value < 0 for value in normalized.values()): + raise ValueError("Scoring rule values must be zero or greater.") + return normalized + + async def _repair_guild_config_active_season_in_connection( db: aiosqlite.Connection, guild_id: str, @@ -82,10 +107,26 @@ async def _create_default_season_in_connection( ) -> dict: cursor = await db.execute( """ - INSERT INTO seasons (guild_id, name, status) - VALUES (?, ?, ?) + INSERT INTO seasons ( + guild_id, + name, + status, + exact_score_points, + correct_outcome_points, + wrong_outcome_points, + late_prediction_points + ) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (guild_id, DEFAULT_SEASON_NAME, ACTIVE_SEASON_STATUS), + ( + guild_id, + DEFAULT_SEASON_NAME, + ACTIVE_SEASON_STATUS, + DEFAULT_SCORING_RULES["exact_score_points"], + DEFAULT_SCORING_RULES["correct_outcome_points"], + DEFAULT_SCORING_RULES["wrong_outcome_points"], + DEFAULT_SCORING_RULES["late_prediction_points"], + ), ) if cursor.lastrowid is None: raise RuntimeError("Failed to create season: lastrowid is None") @@ -158,6 +199,71 @@ async def get_seasons(self, guild_id: str) -> list[dict]: rows = await cursor.fetchall() return [_row_to_season(row) for row in rows] + async def get_active_scoring_rules(self, guild_id: str) -> dict | None: + """Return the active season's scoring rules, if a season exists.""" + _validate_guild_id(guild_id) + season = await self.get_active_season(guild_id) + return season["scoring_rules"] if season else None + + async def update_active_scoring_rules(self, guild_id: str, rules: dict) -> dict: + """Update active-season scoring rules unless stored scores already exist.""" + _validate_guild_id(guild_id) + async with aiosqlite.connect(self.db_path) as db: + await db.execute("BEGIN IMMEDIATE") + try: + db.row_factory = aiosqlite.Row + active_season = await _get_active_season_in_connection(db, guild_id) + if active_season is None: + active_season = await _create_default_season_in_connection(db, guild_id) + normalized = _validate_scoring_rules(active_season["scoring_rules"] | rules) + + async with db.execute( + """ + SELECT 1 + FROM scores score + JOIN fixtures fixture ON fixture.id = score.fixture_id + WHERE fixture.guild_id = ? AND fixture.season_id = ? + LIMIT 1 + """, + (guild_id, active_season["id"]), + ) as cursor: + if await cursor.fetchone() is not None: + message = "Cannot change scoring rules after scores have been calculated for this season." + raise ValueError(message) + + await db.execute( + """ + UPDATE seasons + SET exact_score_points = ?, + correct_outcome_points = ?, + wrong_outcome_points = ?, + late_prediction_points = ? + WHERE id = ? AND guild_id = ? AND status = ? + """, + ( + normalized["exact_score_points"], + normalized["correct_outcome_points"], + normalized["wrong_outcome_points"], + normalized["late_prediction_points"], + active_season["id"], + guild_id, + ACTIVE_SEASON_STATUS, + ), + ) + async with db.execute( + "SELECT * FROM seasons WHERE id = ?", + (active_season["id"],), + ) as cursor: + row = await cursor.fetchone() + if row is None: + raise RuntimeError("Active season disappeared") + + await db.commit() + return _row_to_season(row)["scoring_rules"] + except Exception: + await db.rollback() + raise + async def start_new_season(self, guild_id: str, name: str) -> dict: """Archive the current active season and create a new active season.""" _validate_guild_id(guild_id) @@ -196,8 +302,27 @@ async def start_new_season(self, guild_id: str, name: str) -> dict: ) cursor = await db.execute( - "INSERT INTO seasons (guild_id, name, status) VALUES (?, ?, ?)", - (guild_id, season_name, ACTIVE_SEASON_STATUS), + """ + INSERT INTO seasons ( + guild_id, + name, + status, + exact_score_points, + correct_outcome_points, + wrong_outcome_points, + late_prediction_points + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + guild_id, + season_name, + ACTIVE_SEASON_STATUS, + DEFAULT_SCORING_RULES["exact_score_points"], + DEFAULT_SCORING_RULES["correct_outcome_points"], + DEFAULT_SCORING_RULES["wrong_outcome_points"], + DEFAULT_SCORING_RULES["late_prediction_points"], + ), ) if cursor.lastrowid is None: raise RuntimeError("Failed to create season: lastrowid is None") diff --git a/typer_bot/handlers/thread_prediction_handler.py b/typer_bot/handlers/thread_prediction_handler.py index 4e6597f..d8b2f53 100644 --- a/typer_bot/handlers/thread_prediction_handler.py +++ b/typer_bot/handlers/thread_prediction_handler.py @@ -250,7 +250,7 @@ async def on_message(self, message: discord.Message): else: await message.author.send( "⚠️ **Late prediction!** Your prediction was saved but you will receive " - "0 points for this round since the deadline has passed." + "the active season's late penalty unless an admin waives it." ) elif is_partial: with suppress(discord.Forbidden): diff --git a/typer_bot/utils/__init__.py b/typer_bot/utils/__init__.py index 948e9c0..09f6500 100644 --- a/typer_bot/utils/__init__.py +++ b/typer_bot/utils/__init__.py @@ -19,7 +19,13 @@ parse_prediction_lines, parse_predictions, ) -from .scoring import align_predictions_to_fixture, build_fixture_scores, calculate_points +from .scoring import ( + DEFAULT_SCORING_RULES, + align_predictions_to_fixture, + build_fixture_scores, + calculate_points, + normalize_scoring_rules, +) from .timezone import APP_TZ, format_for_discord, now, parse_deadline, parse_iso __all__ = [ @@ -38,8 +44,10 @@ "format_predictions_preview", "format_standings", "align_predictions_to_fixture", + "DEFAULT_SCORING_RULES", "calculate_points", "build_fixture_scores", + "normalize_scoring_rules", "now", "parse_deadline", "format_for_discord", diff --git a/typer_bot/utils/scoring.py b/typer_bot/utils/scoring.py index 2c1b17e..abdcb7b 100644 --- a/typer_bot/utils/scoring.py +++ b/typer_bot/utils/scoring.py @@ -2,6 +2,21 @@ from collections.abc import Sequence +DEFAULT_SCORING_RULES = { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, +} + + +def normalize_scoring_rules(rules: dict | None = None) -> dict: + """Return scoring rules with defaults for omitted fields.""" + normalized = DEFAULT_SCORING_RULES.copy() + if rules: + normalized.update({key: int(value) for key, value in rules.items() if key in normalized}) + return normalized + def align_predictions_to_fixture( predictions: list[str], @@ -21,21 +36,16 @@ def calculate_points( actual_results: Sequence[str], is_late: bool = False, late_penalty_waived: bool = False, + scoring_rules: dict | None = None, ) -> dict: - """Calculate points. - - Exact: 3pts - Outcome: 1pt - Late: -100% penalty (0pts) - - Returns: dict with points, exact_scores, correct_results, penalty - """ + """Calculate points from prediction rows and season scoring rules.""" + rules = normalize_scoring_rules(scoring_rules) if is_late and not late_penalty_waived: return { - "points": 0, + "points": rules["late_prediction_points"], "exact_scores": 0, "correct_results": 0, - "penalty": "Late prediction - 100% penalty applied", + "penalty": "Late prediction penalty applied", } total_points = 0 @@ -59,15 +69,17 @@ def calculate_points( actual_home, actual_away = parsed_actual if pred_home == actual_home and pred_away == actual_away: - total_points += 3 + total_points += rules["exact_score_points"] exact_count += 1 elif ( (pred_home > pred_away and actual_home > actual_away) or (pred_home < pred_away and actual_home < actual_away) or (pred_home == pred_away and actual_home == actual_away) ): - total_points += 1 + total_points += rules["correct_outcome_points"] correct_count += 1 + else: + total_points += rules["wrong_outcome_points"] return { "points": total_points, @@ -77,8 +89,13 @@ def calculate_points( } -def build_fixture_scores(predictions: Sequence[dict], results: Sequence[str]) -> list[dict]: +def build_fixture_scores( + predictions: Sequence[dict], + results: Sequence[str], + scoring_rules: dict | None = None, +) -> list[dict]: """Build sorted fixture score rows from stored prediction payloads.""" + rules = normalize_scoring_rules(scoring_rules) scores = [] for prediction in predictions: aligned_predictions = align_predictions_to_fixture( @@ -91,6 +108,7 @@ def build_fixture_scores(predictions: Sequence[dict], results: Sequence[str]) -> results, prediction["is_late"], prediction["late_penalty_waived"], + rules, ) scores.append( {