From 81db143e736d889f825b10a8d96c325f1825bf8f Mon Sep 17 00:00:00 2001 From: ashish-066 Date: Sun, 23 Aug 2026 22:39:36 +0530 Subject: [PATCH] feat: implement monthly lives and recovery streak system --- src/graphql/models.rs | 15 +++++ src/graphql/queries.rs | 73 +++++++++++++++++++++++- src/tasks/status_update.rs | 113 ++++++++++++++++++++++++++++++++----- 3 files changed, 187 insertions(+), 14 deletions(-) diff --git a/src/graphql/models.rs b/src/graphql/models.rs index 4a0892f..3864f3a 100644 --- a/src/graphql/models.rs +++ b/src/graphql/models.rs @@ -40,6 +40,8 @@ pub struct MemberStatus { pub streak: Option, #[serde(rename = "consecutiveMisses")] pub consecutive_misses: Option, + #[serde(rename = "lifeStatus")] + pub life_status: Option, } #[derive(Clone, Debug, Deserialize)] @@ -66,3 +68,16 @@ pub struct AttendanceRecord { #[serde(rename = "timeIn")] pub time_in: Option, } + +#[derive(Debug, Deserialize, Clone)] +pub struct LifeStatus { + #[serde(rename = "memberId")] + pub member_id: i32, + pub lives: i32, + #[serde(rename = "recoveryStreak")] + pub recovery_streak: i32, + #[serde(rename = "isProbation")] + pub is_probation: bool, + #[serde(rename = "lastResetMonth")] + pub last_reset_month: i32, +} diff --git a/src/graphql/queries.rs b/src/graphql/queries.rs index 4a737fc..81e9ffb 100644 --- a/src/graphql/queries.rs +++ b/src/graphql/queries.rs @@ -20,7 +20,7 @@ use chrono::{Local, NaiveDate}; use serde_json::Value; use tracing::debug; -use crate::graphql::models::{AttendanceRecord, Member}; +use crate::graphql::models::{AttendanceRecord, LifeStatus, Member}; use super::GraphQLClient; @@ -43,6 +43,13 @@ impl GraphQLClient { maxStreak } consecutiveMisses + lifeStatus { + memberId + lives + recoveryStreak + isProbation + lastResetMonth + } } track year @@ -225,4 +232,68 @@ impl GraphQLClient { .collect(); Ok((exists, roles)) } + + pub async fn update_life_status( + &self, + member_id: i32, + lives: i32, + recovery_streak: i32, + is_probation: bool, + last_reset_month: i32, + ) -> anyhow::Result { + let query = r#" + mutation($memberId: Int!, $lives: Int!, $recoveryStreak: Int!, $isProbation: Boolean!, $lastResetMonth: Int!) { + updateLifeStatus(input: { + memberId: $memberId + lives: $lives + recoveryStreak: $recoveryStreak + isProbation: $isProbation + lastResetMonth: $lastResetMonth + }) { + memberId + lives + recoveryStreak + isProbation + lastResetMonth + } + }"#; + + let variables = serde_json::json!({ + "memberId": member_id, + "lives": lives, + "recoveryStreak": recovery_streak, + "isProbation": is_probation, + "lastResetMonth": last_reset_month, + }); + + let response = self + .http() + .post(self.root_url()) + .bearer_auth(self.api_key()) + .json(&serde_json::json!({ + "query": query, + "variables": variables + })) + .send() + .await? + .json::() + .await?; + + if response.get("errors").is_some() { + anyhow::bail!("GraphQL error: {:?}", response["errors"]); + } + + let data = response + .get("data") + .and_then(|data| data.get("updateLifeStatus")) + .ok_or_else(|| { + anyhow::anyhow!( + "Malformed response: Could not access updateLifeStatus from {}", + response + ) + })?; + + let status: LifeStatus = serde_json::from_value(data.clone())?; + Ok(status) + } } diff --git a/src/tasks/status_update.rs b/src/tasks/status_update.rs index 998fb75..0231514 100644 --- a/src/tasks/status_update.rs +++ b/src/tasks/status_update.rs @@ -17,6 +17,7 @@ along with this program. If not, see . */ use std::collections::HashMap; +use chrono::Datelike; use serenity::all::{CacheHttp, ChannelId, Context, CreateEmbed, CreateMessage, GuildId}; use serenity::async_trait; use tracing::instrument; @@ -57,11 +58,12 @@ pub async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow: let mut members = client.fetch_member_data(yesterday).await?; members.retain(|member| !matches!(member.year, Some(4) | None)); + let processed_members = process_lives(&client, &members, now).await?; // naughty_list -> members who did not send updates - let (naughty_list, years_on_break) = categorize_members(&members); + let (naughty_list, years_on_break) = categorize_members(&processed_members); kick_lazy_bums(&ctx, naughty_list.values().flatten().cloned().collect()).await; - let embed = generate_embed(members, naughty_list, years_on_break).await?; + let embed = generate_embed(processed_members, naughty_list, years_on_break).await?; let msg = CreateMessage::new().embed(embed); let status_update_channel = ChannelId::new(STATUS_UPDATE_CHANNEL_ID); @@ -70,6 +72,85 @@ pub async fn status_update_check(ctx: Context, client: GraphQLClient) -> anyhow: Ok(()) } +async fn process_lives( + client: &GraphQLClient, + members: &[Member], + now: chrono::DateTime, +) -> anyhow::Result> { + let current_month = now.month() as i32; + let mut processed = Vec::new(); + + for member in members { + let Some(status) = &member.status else { + continue; + }; + let Some(on_date) = &status.on_date else { + continue; + }; + + let (mut lives, mut recovery_streak, mut is_probation, mut last_month) = + if let Some(life) = &status.life_status { + ( + life.lives, + life.recovery_streak, + life.is_probation, + life.last_reset_month, + ) + } else { + (3, 0, false, current_month) + }; + + // 1. Monthly reset logic: if a new month has started, reset to 3 lives + if last_month != current_month { + lives = 3; + recovery_streak = 0; + is_probation = false; + last_month = current_month; + } + + // 2. Evaluate yesterday's status update if not on status break + if !on_date.on_break { + if on_date.is_sent { + recovery_streak += 1; + if recovery_streak == 4 { + recovery_streak = 0; + if lives < 3 && !is_probation { + lives += 1; + } + } + } else { + recovery_streak = 0; + if lives > 0 { + lives -= 1; + if lives == 0 { + is_probation = true; + } + } + } + } + + // 3. Save new life status back to Root + let updated_life = client + .update_life_status( + member.member_id, + lives, + recovery_streak, + is_probation, + last_month, + ) + .await?; + + // 4. Update the Member object + let mut updated_member = member.clone(); + if let Some(s) = &mut updated_member.status { + s.life_status = Some(updated_life); + } + processed.push(updated_member); + } + + Ok(processed) +} + fn categorize_members(members: &Vec) -> (GroupedMember, Vec) { let mut naughty_list: HashMap, Vec> = HashMap::new(); let mut members_by_year: HashMap> = HashMap::new(); @@ -182,13 +263,14 @@ async fn kick_lazy_bums(ctx: &Context, naughty_list: Vec) { let guild_id = GuildId::new(AMFOSS_GUILD_ID); for member in naughty_list { - let consecutive_misses = member + let is_probation = member .status .as_ref() - .and_then(|s| s.consecutive_misses) - .unwrap_or(0); + .and_then(|s| s.life_status.as_ref()) + .map(|l| l.is_probation) + .unwrap_or(false); - if consecutive_misses > 3 { + if is_probation { let Some(id_str) = member.discord_id.as_deref() else { warn!("Cannot kick {}: Missing Discord ID", member.name); return; @@ -204,7 +286,7 @@ async fn kick_lazy_bums(ctx: &Context, naughty_list: Vec) { return; } }; - let reason = "You have been kicked for not sending status updates, reach out to a mentor for further details."; + let reason = "You have been kicked for not sending status updates and losing all 3 lives. Reach out to a mentor for further details."; match guild_id .kick_with_reason(ctx.http(), discord_id, reason) @@ -212,7 +294,7 @@ async fn kick_lazy_bums(ctx: &Context, naughty_list: Vec) { { Ok(_) => { info!( - "Kicked Member: {}, ID: {} for failing to send updates.", + "Kicked Member: {}, ID: {} for failing to send updates (lost all lives).", member.name, member.member_id ) } @@ -236,11 +318,16 @@ fn format_defaulters(naughty_list: &GroupedMember) -> String { } for member in missed_members { - let status = match member.status.as_ref().and_then(|s| s.consecutive_misses) { - None => ":zzz:", - Some(1) => ":x:", - Some(2) => ":x::x:", - Some(3) => ":x::x::x:", + let lives = member + .status + .as_ref() + .and_then(|s| s.life_status.as_ref()) + .map(|l| l.lives) + .unwrap_or(3); + let status = match lives { + 3 => ":heart::heart::heart:", + 2 => ":heart::heart:", + 1 => ":heart:", _ => ":headstone:", }; description.push_str(&format!("- {} | {}\n", member.name, status));