| """Deterministic scoring module. |
| |
| Computes team scores entirely in Python β never delegated to an LLM. |
| Score breakdown is transparent and stored in ``scoring_explanation`` so |
| it can be displayed in the UI and included in story packets. |
| """ |
|
|
| from datetime import datetime, timezone |
| from typing import Optional |
|
|
|
|
| |
| HINT_PENALTY = 5 |
| FAST_FINISH_BONUS = 20 |
| COMPLETION_RATIO_BONUS = 10 |
| MAX_TIME_BONUS = 15 |
|
|
|
|
| |
|
|
| def _build_task_map(game: dict) -> dict[str, dict]: |
| """Index tasks by ``task_id`` for O(1) lookups.""" |
| return {t["task_id"]: t for t in game.get("tasks", [])} |
|
|
|
|
| def _parse_ts(ts_str: str) -> Optional[datetime]: |
| """Parse an ISO-8601 timestamp string, returning None on failure.""" |
| try: |
| |
| ts = ts_str.replace("Z", "+00:00") |
| return datetime.fromisoformat(ts) |
| except (ValueError, TypeError): |
| return None |
|
|
|
|
| def _seconds_between(start: str, end: str) -> Optional[float]: |
| """Return seconds between two ISO-8601 timestamps, or None.""" |
| dt_start = _parse_ts(start) |
| dt_end = _parse_ts(end) |
| if dt_start and dt_end: |
| return max(0, (dt_end - dt_start).total_seconds()) |
| return None |
|
|
|
|
| |
|
|
| def compute_scores(events: list[dict], game: dict) -> dict: |
| """Compute final scores from gameplay events. |
| |
| Scoring logic: |
| β’ Each ``task_completed`` awards the task's base ``points``. |
| β’ Each ``hint_used`` deducts ``HINT_PENALTY`` points. |
| β’ ``task_skipped`` awards 0 points for that task. |
| β’ ``fast_finish`` bonus: +``FAST_FINISH_BONUS`` if all tasks are |
| completed before the game's duration elapses. |
| β’ ``completion_ratio`` bonus: +``COMPLETION_RATIO_BONUS`` if the |
| team completed more than 75 % of tasks. |
| β’ ``time_bonus``: up to ``MAX_TIME_TIME_BONUS`` points for finishing |
| early, scaled linearly by how much time remains. |
| |
| Args: |
| events: List of gameplay event dicts. |
| game: The original game definition (with tasks, setup, etc.). |
| |
| Returns: |
| Scoring output dict matching the ``event_schema`` score contract: |
| ``{"team_scores": [...], "winner": str, "scoring_explanation": [...]}`` |
| """ |
| task_map = _build_task_map(game) |
| duration_seconds = game.get("setup", {}).get("duration_minutes", 45) * 60 |
|
|
| |
| team_data: dict[str, dict] = {} |
|
|
| for ev in events: |
| team_id = ev.get("team_id", "team-a") |
| if team_id not in team_data: |
| team_data[team_id] = { |
| "completed_tasks": [], |
| "skipped_tasks": [], |
| "hints_used": 0, |
| "photos_uploaded": 0, |
| "journals": [], |
| "first_event_ts": ev.get("timestamp"), |
| "last_event_ts": ev.get("timestamp"), |
| } |
|
|
| td = team_data[team_id] |
| ev_type = ev.get("event_type") |
| payload = ev.get("payload", {}) |
| ts = ev.get("timestamp", "") |
|
|
| |
| if ts < td["first_event_ts"]: |
| td["first_event_ts"] = ts |
| if ts > td["last_event_ts"]: |
| td["last_event_ts"] = ts |
|
|
| if ev_type == "task_completed": |
| task_id = payload.get("task_id") |
| if task_id and task_id not in td["completed_tasks"]: |
| td["completed_tasks"].append(task_id) |
|
|
| elif ev_type == "task_skipped": |
| task_id = payload.get("task_id") |
| if task_id and task_id not in td["skipped_tasks"]: |
| td["skipped_tasks"].append(task_id) |
|
|
| elif ev_type == "hint_used": |
| td["hints_used"] += 1 |
|
|
| elif ev_type == "photo_uploaded": |
| td["photos_uploaded"] += 1 |
|
|
| elif ev_type == "journal_recorded": |
| td["journals"].append(payload) |
|
|
| |
| all_tasks = game.get("tasks", []) |
| total_possible_tasks = len(all_tasks) |
| team_scores: list[dict] = [] |
| explanations: list[str] = [] |
|
|
| for team_id, td in team_data.items(): |
| base_points = 0 |
| for task_id in td["completed_tasks"]: |
| task = task_map.get(task_id, {}) |
| pts = task.get("points", 0) |
| base_points += pts |
|
|
| |
| hint_penalty = td["hints_used"] * HINT_PENALTY |
|
|
| |
| completed_count = len(td["completed_tasks"]) |
| completion_ratio = completed_count / total_possible_tasks if total_possible_tasks else 0 |
|
|
| |
| elapsed = _seconds_between(td["first_event_ts"], td["last_event_ts"]) |
| time_bonus = 0 |
| if elapsed is not None and elapsed < duration_seconds: |
| remaining_ratio = 1 - (elapsed / duration_seconds) |
| time_bonus = min(MAX_TIME_BONUS, round(remaining_ratio * MAX_TIME_BONUS)) |
|
|
| |
| fast_finish = FAST_FINISH_BONUS if completed_count >= total_possible_tasks and elapsed is not None and elapsed < duration_seconds else 0 |
|
|
| |
| completion_bonus = COMPLETION_RATIO_BONUS if completion_ratio > 0.75 else 0 |
|
|
| total_points = max(0, base_points - hint_penalty + time_bonus + fast_finish + completion_bonus) |
|
|
| |
| team_explanation = [ |
| f"Base points from {completed_count} completed tasks: +{base_points}", |
| f"Hints used: {td['hints_used']} Γ {HINT_PENALTY} penalty = -{hint_penalty}", |
| ] |
| if time_bonus > 0: |
| team_explanation.append(f"Time bonus for finishing early: +{time_bonus}") |
| if fast_finish > 0: |
| team_explanation.append(f"Fast-finish bonus (all tasks): +{fast_finish}") |
| if completion_bonus > 0: |
| team_explanation.append(f"Completion ratio bonus (>75%): +{completion_bonus}") |
| team_explanation.append(f"Final total: {total_points}") |
|
|
| bonuses = [] |
| if fast_finish > 0: |
| bonuses.append("fast_finish") |
| if completion_bonus > 0: |
| bonuses.append("completion_ratio") |
|
|
| team_scores.append({ |
| "team_id": team_id, |
| "points": total_points, |
| "base_points": base_points, |
| "hint_penalty": hint_penalty, |
| "time_bonus": time_bonus, |
| "fast_finish_bonus": fast_finish, |
| "completion_bonus": completion_bonus, |
| "completed_tasks": completed_count, |
| "total_tasks": total_possible_tasks, |
| "hints_used": td["hints_used"], |
| "bonuses": bonuses, |
| "scoring_breakdown": team_explanation, |
| }) |
|
|
| explanations.append(f"Team {team_id}: {total_points} pts ({completed_count}/{total_possible_tasks} tasks)") |
|
|
| |
| winner: Optional[str] = None |
| if team_scores: |
| |
| ranked = sorted( |
| team_scores, |
| key=lambda t: (t["points"], -t["hints_used"]), |
| reverse=True, |
| ) |
| winner = ranked[0]["team_id"] |
|
|
| if len(ranked) > 1 and ranked[0]["points"] == ranked[1]["points"]: |
| explanations.append( |
| f"β Tie between teams with {ranked[0]['points']} pts β " |
| "tie-breaker: fewer hints used wins." |
| ) |
| |
| ranked_tied = sorted( |
| ranked, |
| key=lambda t: t["hints_used"], |
| ) |
| winner = ranked_tied[0]["team_id"] |
| explanations.append(f"Tie-break winner: {winner} (fewer hints)") |
|
|
| return { |
| "team_scores": team_scores, |
| "winner": winner, |
| "scoring_explanation": explanations, |
| } |
|
|