use crate::models::{EnvironmentInfo, GameState};
use chrono::{DateTime, Utc};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::env;
use uuid::Uuid;
const MAX_LEVEL_SCORE: f64 = 115.0;
const DEFAULT_STALE_MINUTES: i64 = 15;
const DEFAULT_MAX_OPEN_FOR_MINUTES: i64 = 4320;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LevelAction {
pub level: u32,
pub cumulative_actions: u32,
}
#[derive(Debug, Clone)]
pub struct Card {
pub game_id: String,
pub total_plays: usize,
pub guids: Vec<String>,
pub levels_completed: Vec<u32>,
pub states: Vec<GameState>,
pub actions: Vec<u32>,
pub resets: Vec<u32>,
pub actions_by_level: Vec<Vec<LevelAction>>,
}
impl Card {
pub fn new(game_id: String) -> Self {
Self {
game_id,
total_plays: 0,
guids: Vec::new(),
levels_completed: Vec::new(),
states: Vec::new(),
actions: Vec::new(),
resets: Vec::new(),
actions_by_level: Vec::new(),
}
}
pub fn current_idx(&self) -> Option<usize> {
(self.total_plays > 0).then(|| self.total_plays - 1)
}
pub fn started(&self) -> bool {
self.total_plays > 0
}
pub fn index_of_guid(&self, guid: &str) -> usize {
for idx in (0..self.guids.len()).rev() {
if self.guids[idx] == guid {
return idx;
}
}
self.total_plays.saturating_sub(1)
}
pub fn new_play(&mut self, guid: &str) {
self.total_plays += 1;
self.guids.push(guid.to_string());
self.levels_completed.push(0);
self.states.push(GameState::NotFinished);
self.actions.push(0);
self.resets.push(0);
self.actions_by_level.push(Vec::new());
}
pub fn increment_reset(&mut self, guid: &str) {
if self.started() {
let idx = self.index_of_guid(guid);
self.resets[idx] += 1;
self.actions[idx] += 1;
}
}
pub fn increment_action(&mut self, guid: &str) {
if self.started() {
let idx = self.index_of_guid(guid);
self.actions[idx] += 1;
}
}
pub fn set_state(&mut self, guid: &str, state: GameState) {
if self.started() {
let idx = self.index_of_guid(guid);
self.states[idx] = state;
}
}
pub fn set_levels_completed(&mut self, guid: &str, current: u32) {
if self.started() {
let idx = self.index_of_guid(guid);
if current != self.levels_completed[idx] {
self.actions_by_level[idx].push(LevelAction {
level: current,
cumulative_actions: self.actions[idx],
});
}
self.levels_completed[idx] = current;
}
}
pub fn most_levels_completed(&self) -> u32 {
self.levels_completed.iter().copied().max().unwrap_or(0)
}
pub fn total_actions(&self) -> u32 {
self.actions.iter().sum()
}
}
#[derive(Debug, Clone)]
pub struct Scorecard {
pub card_id: String,
pub api_key: String,
pub source_url: Option<String>,
pub tags: Option<Vec<String>>,
pub opaque: Option<Value>,
pub competition_mode: Option<bool>,
pub cards: HashMap<String, Card>,
pub open_at: DateTime<Utc>,
pub last_update: DateTime<Utc>,
}
impl Scorecard {
pub fn new(card_id: impl Into<String>, api_key: impl Into<String>) -> Self {
let now = Utc::now();
Self {
card_id: card_id.into(),
api_key: api_key.into(),
source_url: None,
tags: None,
opaque: None,
competition_mode: None,
cards: HashMap::new(),
open_at: now,
last_update: now,
}
}
pub fn new_play(&mut self, game_id: &str, guid: &str) {
self.cards
.entry(game_id.to_string())
.or_insert_with(|| Card::new(game_id.to_string()))
.new_play(guid);
self.last_update = Utc::now();
}
pub fn reset(&mut self, game_id: &str, guid: &str) {
if let Some(card) = self.cards.get_mut(game_id) {
card.increment_reset(guid);
}
self.last_update = Utc::now();
}
pub fn take_action(&mut self, game_id: &str, guid: &str) {
if let Some(card) = self.cards.get_mut(game_id) {
card.increment_action(guid);
}
self.last_update = Utc::now();
}
pub fn win(&mut self, game_id: &str, guid: &str) {
if let Some(card) = self.cards.get_mut(game_id) {
card.set_state(guid, GameState::Win);
}
self.last_update = Utc::now();
}
pub fn game_over(&mut self, game_id: &str, guid: &str) {
if let Some(card) = self.cards.get_mut(game_id) {
card.set_state(guid, GameState::GameOver);
}
self.last_update = Utc::now();
}
pub fn set_levels_completed(&mut self, game_id: &str, guid: &str, level: u32) {
if let Some(card) = self.cards.get_mut(game_id) {
card.set_levels_completed(guid, level);
}
self.last_update = Utc::now();
}
pub fn has_environment(&self, game_id: &str) -> bool {
self.cards.keys().any(|k| k.starts_with(game_id))
}
pub fn total_actions(&self) -> u32 {
self.cards.values().map(|c| c.total_actions()).sum()
}
pub fn won(&self) -> usize {
self.cards
.values()
.filter(|c| c.states.contains(&GameState::Win))
.count()
}
pub fn played(&self) -> usize {
self.cards.values().filter(|c| !c.states.is_empty()).count()
}
}
#[derive(Debug)]
pub struct ScorecardManager {
pub scorecards: HashMap<String, Scorecard>,
pub guids: HashMap<String, String>,
pub stale_seconds: i64,
pub max_open_seconds: i64,
}
impl ScorecardManager {
pub fn new() -> Self {
let stale_min = env::var("STALE_MINUTES")
.ok()
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(DEFAULT_STALE_MINUTES)
.clamp(1, 60);
let max_open_min = env::var("MAX_OPEN_FOR_MINUTES")
.ok()
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(DEFAULT_MAX_OPEN_FOR_MINUTES)
.clamp(60, 60 * 24 * 7);
Self {
scorecards: HashMap::new(),
guids: HashMap::new(),
stale_seconds: stale_min * 60,
max_open_seconds: max_open_min * 60,
}
}
pub fn new_scorecard(
&mut self,
api_key: &str,
source_url: Option<String>,
tags: Option<Vec<String>>,
opaque: Option<Value>,
competition_mode: Option<bool>,
) -> String {
let card_id = Uuid::new_v4().to_string();
let mut sc = Scorecard::new(&card_id, api_key);
sc.source_url = source_url;
sc.tags = tags;
sc.opaque = opaque;
sc.competition_mode = competition_mode;
self.scorecards.insert(card_id.clone(), sc);
card_id
}
pub fn get_scorecard(&self, card_id: &str, api_key: &str) -> Option<&Scorecard> {
self.scorecards
.get(card_id)
.filter(|sc| sc.api_key == api_key)
}
pub fn get_scorecard_mut(&mut self, card_id: &str, api_key: &str) -> Option<&mut Scorecard> {
self.scorecards
.get_mut(card_id)
.filter(|sc| sc.api_key == api_key)
}
pub fn get_scorecard_from_guid(&self, guid: &str) -> Option<&Scorecard> {
let card_id = self.guids.get(guid)?;
self.scorecards.get(card_id)
}
pub fn close_scorecard(
&mut self,
card_id: &str,
api_key: Option<&str>,
) -> Option<(Scorecard, Vec<String>)> {
let matches = self
.scorecards
.get(card_id)
.is_some_and(|sc| api_key.is_none_or(|k| sc.api_key == k));
if !matches {
return None;
}
let sc = self.scorecards.remove(card_id)?;
let guids: Vec<String> = sc.cards.values().flat_map(|c| c.guids.clone()).collect();
for guid in &guids {
self.guids.remove(guid);
}
Some((sc, guids))
}
pub fn add_game(&mut self, card_id: &str, guid: &str) {
if self.scorecards.contains_key(card_id) {
self.guids.insert(guid.to_string(), card_id.to_string());
}
}
pub fn get_stale_cards(&self) -> Vec<String> {
let now = Utc::now();
self.scorecards
.iter()
.filter(|(_, sc)| {
let idle = (now - sc.last_update).num_seconds();
let open = (now - sc.open_at).num_seconds();
idle >= self.stale_seconds || open >= self.max_open_seconds
})
.map(|(id, _)| id.clone())
.collect()
}
}
impl Default for ScorecardManager {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct EnvironmentScoreCalculator {
pub id: Option<String>,
pub guid: Option<String>,
pub resets: Option<u32>,
pub state: Option<GameState>,
pub completed: Option<bool>,
level_indices: Vec<u32>,
level_scores: Vec<f64>,
level_actions: Vec<u32>,
level_baseline_actions: Vec<u32>,
levels_completed: u32,
total_actions: u32,
environments: HashSet<String>,
}
impl EnvironmentScoreCalculator {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: Some(id.into()),
guid: None,
resets: None,
state: None,
completed: None,
level_indices: Vec::new(),
level_scores: Vec::new(),
level_actions: Vec::new(),
level_baseline_actions: Vec::new(),
levels_completed: 0,
total_actions: 0,
environments: HashSet::new(),
}
}
pub fn add_level(
&mut self,
level_index: u32,
completed: bool,
actions_taken: u32,
baseline_actions: u32,
game_id: Option<&str>,
) {
self.total_actions += actions_taken;
if let Some(gid) = game_id {
self.environments.insert(gid.to_string());
}
let score = if completed && actions_taken > 0 {
let ratio = baseline_actions as f64 / actions_taken as f64;
(ratio * ratio * 100.0).min(MAX_LEVEL_SCORE)
} else {
0.0
};
if completed {
self.levels_completed += 1;
}
self.level_indices.push(level_index);
self.level_scores.push(score);
self.level_actions.push(actions_taken);
self.level_baseline_actions.push(baseline_actions);
}
pub fn to_score(&self, include_levels: bool) -> EnvironmentScore {
let score = if self.level_scores.is_empty() {
0.0
} else {
let mut total_score = 0.0_f64;
let mut total_weights = 0_u32;
let mut max_weights = 0_u32;
for (i, &s) in self.level_scores.iter().enumerate() {
let w = self.level_indices[i];
total_score += s * w as f64;
total_weights += w;
if s > 0.0 {
max_weights += w;
}
}
if total_weights == 0 {
0.0
} else {
let raw = total_score / total_weights as f64;
let cap = max_weights as f64 / total_weights as f64 * 100.0;
raw.min(cap)
}
};
EnvironmentScore {
id: self.id.clone(),
guid: self.guid.clone(),
score,
levels_completed: self.levels_completed,
actions: self.total_actions,
resets: self.resets,
state: self.state.clone(),
completed: self.completed,
level_scores: include_levels.then(|| self.level_scores.clone()),
level_actions: include_levels.then(|| self.level_actions.clone()),
level_baseline_actions: include_levels.then(|| {
self.level_baseline_actions
.iter()
.map(|&v| v as i32)
.collect()
}),
number_of_levels: (!include_levels).then_some(self.level_scores.len() as u32),
number_of_environments: (!include_levels).then_some(self.environments.len() as u32),
message: None,
}
}
}
#[derive(Debug, Clone)]
pub struct EnvironmentScore {
pub id: Option<String>,
pub guid: Option<String>,
pub score: f64,
pub levels_completed: u32,
pub actions: u32,
pub resets: Option<u32>,
pub state: Option<GameState>,
pub completed: Option<bool>,
pub level_scores: Option<Vec<f64>>,
pub level_actions: Option<Vec<u32>>,
pub level_baseline_actions: Option<Vec<i32>>,
pub number_of_levels: Option<u32>,
pub number_of_environments: Option<u32>,
pub message: Option<String>,
}
#[derive(Debug, Clone)]
pub struct EnvironmentScoreList {
pub id: String,
pub runs: Vec<EnvironmentScore>,
}
impl EnvironmentScoreList {
pub fn score(&self) -> f64 {
self.runs
.iter()
.map(|r| r.score)
.fold(f64::NEG_INFINITY, f64::max)
}
pub fn actions(&self) -> u32 {
self.runs.iter().map(|r| r.actions).sum()
}
pub fn levels_completed(&self) -> u32 {
self.runs
.iter()
.map(|r| r.levels_completed)
.max()
.unwrap_or(0)
}
pub fn completed(&self) -> bool {
self.runs.iter().any(|r| r.completed.unwrap_or(false))
}
pub fn level_count(&self) -> u32 {
self.runs
.iter()
.map(|r| r.level_scores.as_ref().map_or(0, |v| v.len() as u32))
.max()
.unwrap_or(0)
}
pub fn resets(&self) -> u32 {
self.runs.iter().map(|r| r.resets.unwrap_or(0)).sum()
}
}
#[derive(Debug, Clone)]
pub struct ComputedScorecard {
pub card_id: String,
pub source_url: Option<String>,
pub tags: Option<Vec<String>>,
pub competition_mode: Option<bool>,
pub score: f64,
pub environments: Vec<EnvironmentScoreList>,
pub tags_scores: Vec<EnvironmentScore>,
pub open_at: DateTime<Utc>,
pub last_update: DateTime<Utc>,
}
impl ComputedScorecard {
pub fn from_scorecard(scorecard: &Scorecard, environment_infos: &[EnvironmentInfo]) -> Self {
let info_map: HashMap<&str, &EnvironmentInfo> = environment_infos
.iter()
.map(|e| (e.game_id.as_str(), e))
.collect();
let mut game_scores: HashMap<String, EnvironmentScoreList> = HashMap::new();
let mut tags_accum: HashMap<String, EnvironmentScoreCalculator> = HashMap::new();
for (game_id, card) in &scorecard.cards {
let best_idx = card
.levels_completed
.iter()
.enumerate()
.max_by_key(|&(_, lc)| lc)
.map(|(i, _)| i);
let Some(best_idx) = best_idx else { continue };
if best_idx >= card.guids.len() {
continue;
}
let mut all_scores: Vec<EnvironmentScore> = Vec::new();
for idx in 0..card.total_plays {
let env_info = info_map.get(game_id.as_str()).copied();
let is_best = idx == best_idx;
let score = Self::calculate_score(
card,
game_id,
idx,
env_info,
is_best.then_some(&mut tags_accum),
);
all_scores.push(score);
}
if !all_scores.is_empty() {
game_scores.insert(
game_id.clone(),
EnvironmentScoreList {
id: game_id.clone(),
runs: all_scores,
},
);
}
}
let environment_list: Vec<EnvironmentScoreList> = game_scores.into_values().collect();
let tags_list: Vec<EnvironmentScore> =
tags_accum.values().map(|c| c.to_score(false)).collect();
let score = if environment_list.is_empty() {
0.0
} else {
environment_list.iter().map(|e| e.score()).sum::<f64>() / environment_list.len() as f64
};
Self {
card_id: scorecard.card_id.clone(),
source_url: scorecard.source_url.clone(),
tags: scorecard.tags.clone(),
competition_mode: scorecard.competition_mode,
score,
environments: environment_list,
tags_scores: tags_list,
open_at: scorecard.open_at,
last_update: scorecard.last_update,
}
}
fn calculate_score(
card: &Card,
game_id: &str,
idx: usize,
env_info: Option<&EnvironmentInfo>,
mut tags_accum: Option<&mut HashMap<String, EnvironmentScoreCalculator>>,
) -> EnvironmentScore {
let levels_completed = card.levels_completed.get(idx).copied().unwrap_or(0);
let guid = card.guids.get(idx).cloned().unwrap_or_default();
let actions = card.actions.get(idx).copied().unwrap_or(0);
let resets = card.resets.get(idx).copied().unwrap_or(0);
let state = card
.states
.get(idx)
.cloned()
.unwrap_or(GameState::NotPlayed);
let completed = state == GameState::Win;
let baseline = match env_info {
None => None,
Some(info) if info.baseline_actions.as_ref().is_none_or(|b| b.is_empty()) => None,
Some(info) => info.baseline_actions.as_ref(),
};
let Some(baseline) = baseline else {
let actions_by_level = card
.actions_by_level
.get(idx)
.map(|a| a.as_slice())
.unwrap_or(&[]);
let raw_level_actions: Vec<u32> = {
let mut prev = 0u32;
let mut out = Vec::new();
for la in actions_by_level {
out.push(la.cumulative_actions.saturating_sub(prev));
prev = la.cumulative_actions;
}
if state != GameState::Win {
out.push(actions.saturating_sub(prev));
}
out
};
let n = raw_level_actions.len();
return EnvironmentScore {
id: Some(card.game_id.clone()),
guid: Some(guid),
score: 0.0,
levels_completed,
actions,
resets: Some(resets),
state: Some(state),
completed: Some(completed),
level_actions: Some(raw_level_actions),
level_baseline_actions: Some(vec![-1; n]),
level_scores: Some(vec![0.0; n]),
number_of_levels: None,
number_of_environments: None,
message: env_info
.map(|_| {
"Human baseline actions are not available for this environment".to_string()
})
.or_else(|| Some(format!("No matching EnvironmentInfo found for {game_id}"))),
};
};
let mut calc = EnvironmentScoreCalculator::new(card.game_id.clone());
calc.guid = Some(guid);
calc.resets = Some(resets);
calc.state = Some(state);
calc.completed = Some(completed);
let actions_by_level = card
.actions_by_level
.get(idx)
.map(|a| a.as_slice())
.unwrap_or(&[]);
let mut prev_actions = 0u32;
for (level_idx, bl) in baseline.iter().enumerate() {
let (level_actions, level_completed) = if let Some(la) = actions_by_level.get(level_idx)
{
let taken = la.cumulative_actions.saturating_sub(prev_actions);
prev_actions = la.cumulative_actions;
(taken, level_idx < actions_by_level.len() || completed)
} else {
let taken = actions.saturating_sub(prev_actions);
prev_actions = actions;
(taken, false)
};
calc.add_level(
(level_idx + 1) as u32,
level_completed,
level_actions,
*bl,
Some(game_id),
);
if let (Some(ta), Some(tags)) =
(tags_accum.as_mut(), env_info.and_then(|e| e.tags.as_ref()))
{
for tag in tags {
ta.entry(tag.clone())
.or_insert_with(|| EnvironmentScoreCalculator::new(tag.clone()))
.add_level(
(level_idx + 1) as u32,
level_completed,
level_actions,
*bl,
Some(game_id),
);
}
}
}
calc.to_score(true)
}
pub fn total_environments_completed(&self) -> usize {
self.environments.iter().filter(|e| e.completed()).count()
}
pub fn total_environments(&self) -> usize {
self.environments.len()
}
pub fn total_levels_completed(&self) -> u32 {
self.environments.iter().map(|e| e.levels_completed()).sum()
}
pub fn total_levels(&self) -> u32 {
self.environments.iter().map(|e| e.level_count()).sum()
}
pub fn total_actions(&self) -> u32 {
self.environments.iter().map(|e| e.actions()).sum()
}
}