From be471cfa88f76d309b8c151cc4947b84e78188e2 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Tue, 22 Sep 2026 01:55:05 -0500 Subject: [PATCH 1/2] fix: enforce Riftbound ban legality at deal boundaries --- Cargo.lock | 38 +++++++++++----------- Cargo.toml | 2 +- src/ai/driver.rs | 11 +++++++ src/deck/actions.rs | 78 +++++++++++++++++++++++++++++++++++++++------ src/deck/import.rs | 14 ++++++-- src/deck/pool.rs | 18 +++++++++++ src/menu/editor.rs | 17 ++++++++-- src/net/mod.rs | 44 +++++++++++++++++++++++++ wiki/development.md | 8 +++++ 9 files changed, 196 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d89a2b..3c37b6c 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", @@ -1609,7 +1609,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-time", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3206,7 +3206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4902,7 +4902,7 @@ dependencies = [ [[package]] name = "kai" -version = "0.21.2" +version = "0.21.3" dependencies = [ "agni-core", "agni-deck", @@ -5643,7 +5643,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6209,7 +6209,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7103,7 +7103,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7161,7 +7161,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7566,7 +7566,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9115,7 +9115,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] 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..f53e7aa 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), [Mecha](https://github.com/abysl/agni/pull/4), and ban-list 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. From b8b61b996ffa1aa8e8d1851fcee9351933bae1fe Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Tue, 22 Sep 2026 01:59:15 -0500 Subject: [PATCH 2/2] Keep the audit dependency update scoped to Agni Preserve existing Windows dependency edges while changing the Agni revision and Kai package version. Locked offline Cargo metadata accepts the minimized graph. Link every upstream audit PR from the integration note. --- Cargo.lock | 16 ++++++++-------- wiki/development.md | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c37b6c..8e9f75f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1609,7 +1609,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-time", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3206,7 +3206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5643,7 +5643,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6209,7 +6209,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7103,7 +7103,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7161,7 +7161,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7566,7 +7566,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9115,7 +9115,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/wiki/development.md b/wiki/development.md index f53e7aa..bc8b14f 100644 --- a/wiki/development.md +++ b/wiki/development.md @@ -156,6 +156,6 @@ Run commands from the repository root unless a guide explicitly says otherwise. 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), [Mecha](https://github.com/abysl/agni/pull/4), and ban-list pull requests merging. +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.