diff --git a/Cargo.lock b/Cargo.lock index 6d89a2b..8e9f75f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,7 +132,7 @@ dependencies = [ [[package]] name = "agni-core" version = "0.1.0" -source = "git+https://github.com/abysl/agni#6fad1183b5cc244d6c75f55d6f49774262e3e2ec" +source = "git+https://github.com/abysl/agni#1726c7ee59b5eb91e0ea473e539a4c7ca6234d0e" dependencies = [ "serde", ] @@ -140,7 +140,7 @@ dependencies = [ [[package]] name = "agni-deck" version = "0.1.0" -source = "git+https://github.com/abysl/agni#6fad1183b5cc244d6c75f55d6f49774262e3e2ec" +source = "git+https://github.com/abysl/agni#1726c7ee59b5eb91e0ea473e539a4c7ca6234d0e" dependencies = [ "serde", ] @@ -148,7 +148,7 @@ dependencies = [ [[package]] name = "agni-engine-host" version = "0.1.0" -source = "git+https://github.com/abysl/agni#6fad1183b5cc244d6c75f55d6f49774262e3e2ec" +source = "git+https://github.com/abysl/agni#1726c7ee59b5eb91e0ea473e539a4c7ca6234d0e" dependencies = [ "agni-sim", "blake3", @@ -161,7 +161,7 @@ dependencies = [ [[package]] name = "agni-importers" version = "0.1.0" -source = "git+https://github.com/abysl/agni#6fad1183b5cc244d6c75f55d6f49774262e3e2ec" +source = "git+https://github.com/abysl/agni#1726c7ee59b5eb91e0ea473e539a4c7ca6234d0e" dependencies = [ "agni-deck", "agni-mtg", @@ -176,7 +176,7 @@ dependencies = [ [[package]] name = "agni-mtg" version = "0.1.0" -source = "git+https://github.com/abysl/agni#6fad1183b5cc244d6c75f55d6f49774262e3e2ec" +source = "git+https://github.com/abysl/agni#1726c7ee59b5eb91e0ea473e539a4c7ca6234d0e" dependencies = [ "agni-core", "agni-deck", @@ -186,7 +186,7 @@ dependencies = [ [[package]] name = "agni-net" version = "0.1.0" -source = "git+https://github.com/abysl/agni#6fad1183b5cc244d6c75f55d6f49774262e3e2ec" +source = "git+https://github.com/abysl/agni#1726c7ee59b5eb91e0ea473e539a4c7ca6234d0e" dependencies = [ "agni-core", "agni-sim", @@ -204,12 +204,12 @@ dependencies = [ [[package]] name = "agni-plugin-sdk" version = "0.1.0" -source = "git+https://github.com/abysl/agni#6fad1183b5cc244d6c75f55d6f49774262e3e2ec" +source = "git+https://github.com/abysl/agni#1726c7ee59b5eb91e0ea473e539a4c7ca6234d0e" [[package]] name = "agni-riftbound" version = "0.1.0" -source = "git+https://github.com/abysl/agni#6fad1183b5cc244d6c75f55d6f49774262e3e2ec" +source = "git+https://github.com/abysl/agni#1726c7ee59b5eb91e0ea473e539a4c7ca6234d0e" dependencies = [ "agni-core", "agni-deck", @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "agni-riftbound-turns" version = "0.1.0" -source = "git+https://github.com/abysl/agni#6fad1183b5cc244d6c75f55d6f49774262e3e2ec" +source = "git+https://github.com/abysl/agni#1726c7ee59b5eb91e0ea473e539a4c7ca6234d0e" dependencies = [ "agni-plugin-sdk", ] @@ -227,7 +227,7 @@ dependencies = [ [[package]] name = "agni-sim" version = "0.1.0" -source = "git+https://github.com/abysl/agni#6fad1183b5cc244d6c75f55d6f49774262e3e2ec" +source = "git+https://github.com/abysl/agni#1726c7ee59b5eb91e0ea473e539a4c7ca6234d0e" dependencies = [ "agni-core", "ciborium", @@ -4902,7 +4902,7 @@ dependencies = [ [[package]] name = "kai" -version = "0.21.2" +version = "0.21.3" dependencies = [ "agni-core", "agni-deck", diff --git a/Cargo.toml b/Cargo.toml index 83fe351..c44cb34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kai" -version = "0.21.2" +version = "0.21.3" edition = "2021" default-run = "kai" diff --git a/src/ai/driver.rs b/src/ai/driver.rs index 797eafa..195eadd 100644 --- a/src/ai/driver.rs +++ b/src/ai/driver.rs @@ -880,11 +880,22 @@ pub fn load_deck(seat: &mut Seat, path: &str, out: &mut Out) { } } +pub fn deal_rules_enforced(options: Option<&[u8]>) -> bool { + agni_riftbound::TableOptions::enforced_in(options) +} + pub fn deal(seat: &mut Seat, link: &mut dyn Link, out: &mut Out) { let Some(record) = seat.deck.as_mut() else { out.line("no deck loaded — `deck ` first"); return; }; + let enforced = seat.session.as_ref().is_some_and(|session| { + deal_rules_enforced(session.state().options.as_ref().map(|options| &options[..])) + }); + if let Err(error) = crate::deck::actions::validate_deal(&record.deck, enforced) { + out.line(format!("deck refused: {error}")); + return; + } record.seat = PlayerId(seat.seat); if crate::deck::battlefield::needs_choice(record) { out.line("choose a battlefield first: `battlefield `"); diff --git a/src/deck/actions.rs b/src/deck/actions.rs index 6553686..7c3b4d5 100644 --- a/src/deck/actions.rs +++ b/src/deck/actions.rs @@ -47,9 +47,12 @@ pub fn search_cards(cards: &Catalog, query: &str, offset: usize) -> Value { pub fn inspect(draft: &Draft) -> Value { let deck = ImportedDeck::Riftbound(draft.deck.clone()); + let report = deck + .report() + .expect("Riftbound decks have legality reports"); json!({"label": draft.label, "summary": deck.summary(), "list": super::exchange::render(&draft.deck, super::exchange::Share::TextList).unwrap_or_default(), - "validation": super::import::findings_text(&draft.report), "unresolved": draft.unresolved}) + "validation": super::import::findings_text(&report), "unresolved": draft.unresolved}) } pub fn edit(draft: &mut Option, cards: &Catalog, args: &Value) -> Result { @@ -162,15 +165,24 @@ pub fn selection_legal(verdict: agni_riftbound::legality::Verdict, enforced: boo !enforced || !matches!(verdict, agni_riftbound::legality::Verdict::Broken(_)) } -pub fn validate_selection(draft: &Draft, enforced: bool, confirmed: bool) -> Result<(), String> { - if !selection_legal(draft.report.verdict, enforced) { - return Err(super::import::findings_text(&draft.report)); +pub fn validate_deal(deck: &ImportedDeck, enforced: bool) -> Result<(), String> { + let Some(report) = deck.report() else { + return Ok(()); + }; + if selection_legal(report.verdict, enforced) { + Ok(()) + } else { + Err(super::import::findings_text(&report)) } - if matches!( - draft.report.verdict, - agni_riftbound::legality::Verdict::Broken(_) - ) && !confirmed - { +} + +pub fn validate_selection(draft: &Draft, enforced: bool, confirmed: bool) -> Result<(), String> { + let deck = ImportedDeck::Riftbound(draft.deck.clone()); + validate_deal(&deck, enforced)?; + let report = deck + .report() + .expect("Riftbound decks have legality reports"); + if matches!(report.verdict, agni_riftbound::legality::Verdict::Broken(_)) && !confirmed { return Err( "This free-table deck is invalid. Confirm explicitly to seat it anyway.".into(), ); @@ -304,4 +316,52 @@ mod tests { .unwrap() .is_empty()); } + + fn entry(name: &str, id: &str) -> agni_riftbound::DeckEntry { + agni_riftbound::DeckEntry { + card: agni_riftbound::ResolvedCard { + name: name.into(), + riftbound_id: id.into(), + ..Default::default() + }, + count: 1, + } + } + + #[test] + fn selection_and_deals_recheck_banned_sideboards() { + let mut deck = crate::deck::pool::deck("lillia-house").unwrap(); + deck.sideboard = vec![entry("Stacked Deck", "ogn-183-298")]; + let imported = ImportedDeck::Riftbound(deck.clone()); + let report = imported.report().unwrap(); + assert!(report.findings.iter().any(|finding| { + matches!( + &finding.rule, + agni_riftbound::legality::Rule::Banned { name } if name == "Stacked Deck" + ) && finding.zone == agni_riftbound::legality::Zone::Sideboard + })); + assert!(validate_deal(&imported, true).is_err()); + assert!(validate_deal(&imported, false).is_ok()); + + let mut draft = Draft::from_deck( + crate::deck::pool::deck("lillia-house").unwrap(), + "stale", + Origin::New, + ); + draft.deck = deck; + assert!(validate_selection(&draft, true, false).is_err()); + assert!(validate_selection(&draft, false, false).is_err()); + assert!(validate_selection(&draft, false, true).is_ok()); + } + + #[test] + fn other_titles_stay_available_to_enforced_deals() { + let mut deck = crate::deck::pool::deck("lillia-house").unwrap(); + deck.main_deck[0].card.name = "Ekko - Another Title".into(); + let imported = ImportedDeck::Riftbound(deck); + assert!(imported.report().unwrap().findings.iter().all(|finding| { + !matches!(&finding.rule, agni_riftbound::legality::Rule::Banned { .. }) + })); + assert!(validate_deal(&imported, true).is_ok()); + } } diff --git a/src/deck/import.rs b/src/deck/import.rs index 33ed0d1..3b7cc9b 100644 --- a/src/deck/import.rs +++ b/src/deck/import.rs @@ -107,6 +107,16 @@ impl ImportedDeck { cards } + pub fn report(&self) -> Option { + match self { + Self::Riftbound(deck) => Some(agni_riftbound::legality::check( + deck, + agni_riftbound::legality::Mode::Standard, + )), + Self::Mtg(_) => None, + } + } + pub fn summary(&self) -> Vec { match self { Self::Riftbound(deck) => { @@ -168,9 +178,7 @@ impl ResolvedImport { } pub fn report(&self) -> Option { - self.riftbound().map(|deck| { - agni_riftbound::legality::check(deck, agni_riftbound::legality::Mode::Standard) - }) + self.deck.report() } } diff --git a/src/deck/pool.rs b/src/deck/pool.rs index fa8963b..5f1e7fc 100644 --- a/src/deck/pool.rs +++ b/src/deck/pool.rs @@ -489,6 +489,24 @@ mod tests { } let report = agni_riftbound::legality::check(&deck, agni_riftbound::legality::Mode::Standard); + if held.slug == "kha-zix-hotkee" { + assert_eq!( + deck.main_deck + .iter() + .find(|entry| entry.card.name == "Stacked Deck") + .map(|entry| entry.count), + Some(3) + ); + assert_eq!(report.breaks(), 1, "{:?}", report.findings); + assert_eq!(report.verdict, agni_riftbound::legality::Verdict::Broken(1)); + assert!(report.findings.iter().any(|finding| { + matches!( + &finding.rule, + agni_riftbound::legality::Rule::Banned { name } if name == "Stacked Deck" + ) + })); + continue; + } assert_eq!(report.breaks(), 0, "{}: {:?}", held.slug, report.findings); assert_eq!( report.verdict, diff --git a/src/menu/editor.rs b/src/menu/editor.rs index 8f74002..b33c879 100644 --- a/src/menu/editor.rs +++ b/src/menu/editor.rs @@ -362,7 +362,7 @@ pub fn editor_screen( } let now = ui.input(|input| input.time); for action in actions { - run_action(action, now, menu, my_seat, decks); + run_action(action, now, enforced, menu, my_seat, decks); } } @@ -1408,7 +1408,14 @@ pub fn browser_edit(draft: &Draft, catalog: &Catalog, action: BrowserAction) -> }) } -fn run_action(action: Action, now: f64, menu: &mut Menu, my_seat: &MySeat, decks: &mut DeckParams) { +fn run_action( + action: Action, + now: f64, + enforced: bool, + menu: &mut Menu, + my_seat: &MySeat, + decks: &mut DeckParams, +) { let game_tag = agni_riftbound::GAME.to_string(); let DeckParams { editor, @@ -1515,6 +1522,12 @@ fn run_action(action: Action, now: f64, menu: &mut Menu, my_seat: &MySeat, decks editor::close(editor, menu); } Action::Seat { next_game } => { + if let Err(error) = + crate::deck::actions::validate_selection(draft, enforced, sheet.seat_armed) + { + sheet.status(error, now); + return; + } let saved = if cfg!(target_arch = "wasm32") { Err(WEB_SAVE_NOTE.to_string()) } else { diff --git a/src/net/mod.rs b/src/net/mod.rs index 278d6af..299ee06 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -1453,12 +1453,20 @@ pub fn deal_setup( }) } +pub fn deck_refusal( + record: &crate::deck::import::SeatedDeckRecord, + enforced: bool, +) -> Option { + crate::deck::actions::validate_deal(&record.deck, enforced).err() +} + pub fn route_deck_deals( mut requests: MessageReader, mut table: ResMut, mut mirror: ResMut, mut host: ResMut, mut info: ResMut, + choice: Res, seated: Res, my_seat: Res, generation: Res, @@ -1469,6 +1477,10 @@ pub fn route_deck_deals( let Some(record) = &seated.0 else { return; }; + if let Some(reason) = deck_refusal(record, rules_enforced(&info, &choice)) { + info.status = format!("deck refused — {reason}"); + return; + } let groups = crate::deck::import::body_plan(record, deal_setup(&info, &my_seat, &generation)); send_deal( groups, @@ -1486,6 +1498,7 @@ pub fn route_battlefield_placements( mut mirror: ResMut, mut host: ResMut, mut info: ResMut, + choice: Res, seated: Res, my_seat: Res, generation: Res, @@ -1496,6 +1509,10 @@ pub fn route_battlefield_placements( let Some(record) = &seated.0 else { return; }; + if let Some(reason) = deck_refusal(record, rules_enforced(&info, &choice)) { + info.status = format!("deck refused — {reason}"); + return; + } let groups = crate::deck::import::battlefield_plan(record, deal_setup(&info, &my_seat, &generation)); send_deal( @@ -1576,6 +1593,7 @@ pub fn route_deck_reloads( mut generation: ResMut, mut host: ResMut, mut info: ResMut, + choice: Res, seated: Res, my_seat: Res, ) { @@ -1585,6 +1603,10 @@ pub fn route_deck_reloads( let Some(record) = &seated.0 else { return; }; + if let Some(reason) = deck_refusal(record, rules_enforced(&info, &choice)) { + info.status = format!("deck refused — {reason}"); + return; + } let groups = crate::deck::import::deal_plan_for(record); if groups.is_empty() { return; @@ -2005,6 +2027,28 @@ mod tests { use agni_sim::engine::NativeEngine; use agni_sim::log::encode_log; + #[test] + fn deck_refusal_rechecks_the_registered_deck_before_dealing() { + let mut deck = crate::deck::pool::deck("lillia-house").unwrap(); + deck.sideboard = vec![agni_riftbound::DeckEntry { + card: agni_riftbound::ResolvedCard { + name: "Ekko, Recurrent".into(), + riftbound_id: "ogn-110-298".into(), + ..Default::default() + }, + count: 1, + }]; + let record = crate::deck::import::SeatedDeckRecord { + seat: PlayerId(0), + deck: crate::deck::import::ImportedDeck::Riftbound(deck), + faces: Default::default(), + battlefield: None, + battlefield_played: false, + }; + assert!(deck_refusal(&record, true).is_some()); + assert!(deck_refusal(&record, false).is_none()); + } + #[test] fn new_tables_and_resets_rearm_the_selected_deck_only_after_seating() { let mut watch = NewGameWatch::default(); diff --git a/wiki/development.md b/wiki/development.md index b5edef7..bc8b14f 100644 --- a/wiki/development.md +++ b/wiki/development.md @@ -151,3 +151,11 @@ environment. A native linker/pkg-config error usually means a required system library or build tool is missing, not that a Rust test failed. Run commands from the repository root unless a guide explicitly says otherwise. + +## Riftbound legality integration + +Kai recomputes Riftbound legality when a list is displayed, selected, or dealt. Rules-enforced tables refuse a registered deck with a breaking finding; an explicitly confirmed free table can still use that list. This keeps historical and saved lists viewable without replacing cards or removing their scripts. + +The September 18, 2026 Standard and Constructed 2v2 ban source is [Riftbound's September ban-list update](https://playriftbound.com/en-us/news/announcements/september-ban-list-updates-effective-september-18-2026). Kai pins Agni's aggregate integration commit [`1726c7ee59b5eb91e0ea473e539a4c7ca6234d0e`](https://github.com/abysl/agni/commit/1726c7ee59b5eb91e0ea473e539a4c7ca6234d0e), pending the upstream [Zed](https://github.com/abysl/agni/pull/2), [First Mate](https://github.com/abysl/agni/pull/3), [Mech](https://github.com/abysl/agni/pull/4), and [ban-list](https://github.com/abysl/agni/pull/5) pull requests merging. + +Kai validates its locally registered deck before it creates or sends a deal or reload request. The existing peer wire message contains deal groups rather than the full registered deck, so a host cannot independently re-run this deck check for an arbitrary remote request without a protocol change. Agni remains the authority for simulation and rule enforcement.